Changelog¶
Changelog¶
Unreleased¶
Walls thinner than a bin keep the bins beside them apart¶
A slit, hole or concave corner of the arena's outline that lay inside one of two neighboring cells, rather than on the side they share, left the two bins connected. A 0.5 cm wall in 2 cm bins gave a geodesic distance of 4 cm between bins on either side instead of the ~54 cm path around its end, and paths, smoothing and decoding passed through it. Environments whose outline has no such feature are unchanged.
Fixes
- Each bin stands for the piece of its cell inside the arena that holds its center, and two neighboring bins stay connected only where their pieces meet along their shared side (or, for corner neighbors, at their shared corner). This applies to grids, hexagons, rebinned grids and rotated environments. Among the built-in mazes it closes one diagonal pair in the Hamlet maze at 2 cm bins (two at 4 cm) and one in the rat HexMaze, all corner cuts through a third bin; at 8 cm bins the Hampton Court maze no longer has 197 neighbor pairs across its partitions.
- A position computed onto a slanted outline that rounds a few units in the
last place outside it is now inside (
Environment.contains,bin_atand every lookup). Lookup, ray casts and the simulated walker share one on-boundary tolerance, sosimulate_trajectory_ouno longer needs its own rule for astart_positionon a wall. - Lap trajectories between planar cells follow the shortest route inside the two bins' pieces. On the Hampton Court maze 12 of 771 diagonal steps along the outline lengthen by up to 0.09 cm, because they no longer cut through a third bin; other seeded laps move by at most 3e-6 cm.
Removed
- The Hampton Court and repeated T-maze factories no longer warn that
bin_sizeis coarser than their thinnest wall: bins can no longer join through it.
Faster simulations and validation¶
A cleanup of neurospatial.simulation shares work that does not depend on
the cell, and fixes two places where simulated cells ignored a wall.
Seeded sessions with uniform field coverage in 2-D get new field centers;
other seeded simulations give the same spikes as before.
generate_population_spikescomputes each population's gaze points (spatial-view cells), wall distances (border cells) and geodesic bin lookups (geodesic place and object-vector cells) once instead of once per cell. Four spatial-view cells at 10⁵ samples take 0.47 s instead of 1.5 s.- Boundary-vector rays are cast about 2.4 times faster in walled mazes,
and spatial-view cells with
require_visibility=True(and viewsheds) build their sight-line walls once instead of once per sample. validate_simulationandplot_session_summaryestimate every cell's rate map in onecompute_spatial_ratescall; validating 50 cells is about 20 times faster. Importingneurospatial.simulationno longer loadsscipy.stats.- A boundary-vector cell's memory no longer grows with each cell: at 3 × 10⁵ samples a cell needs 50 MB beyond the shared rays instead of 870 MB.
Fixes
- Uniform field coverage in 2-D keeps centers clear of declared internal walls. The distance to a wall used to be measured to the edge of the bins only, straight through internal walls, so in a box split by a 70 cm wall 6 of 40 field centers sat within 5 cm of it. Centers now keep the same clearance from internal walls as from the outline, and in mazes no center sits closer to a wall than before.
- A boundary-vector cell at a position exactly on a wall fired at its
baseline: rays leaving the floor from there saw no wall. They now meet
it at distance 0, as
compute_boundary_distancesmeasures, so the rate on the wall equals the rate just inside it.
Breaking changes
simulate_trajectory_sinusoidalno longer takesseed, which never changed its output.- The session and example functions refuse NaN durations, sizes and
spacings, and counts that are not positive integers. A NaN
grid_spacingused to build grid cells with NaN spacing. - Head-direction and boundary-vector
ground_truthreport a preferred direction of exactly −π as +π, the package's (−π, π] convention.
Simulations produce what they say and refuse what they cannot simulate¶
A review of neurospatial.simulation found sessions whose ground truth
did not match the spikes they produced:
- Sinusoidal sessions fed linear distance to 2-D place cells, so most cells stayed silent.
border_cell_sessionput every place field on the wall, and every border cell in a session was the same cell.validate_simulationcompared geodesic cells against a Euclidean field and crashed on mixed sessions.- Laps on a track not aligned with x stood still.
- Negative rates became silence.
add_modulationhalved the rate at any depth and put spikes at the wrong phase.
These now behave as documented or raise with a message saying what to fix. Every model shares one call signature and is immutable. Seeded simulations give different numbers than before: OU headings, per-unit seeds and the default lap route all changed.
Breaking changes
- Every neural model takes
firing_rate(times, positions, *, headings=None)and has arequires_headingsproperty. Head-direction cells used to take headings first, and spatial-view and object-vector cells tookpositionsfirst.generate_population_spikesno longer special-cases model types. When a model needs headings and none are passed, it uses the movement direction, held through stops. A custom model written to theNeuralModelprotocol can now receive headings. - Models are frozen dataclasses with read-only arrays. Assigning
centerorpreferred_directionafter construction used to leave the rates unchanged whileground_truthreported the new value. - A model's attributes are its constructor arguments, and a parameter left
to its default reads None:
PlaceCellModel(env, seed=1).centerand.widthare None.ground_truthholds the values the model fires with, such as the drawn center, the default width inenv.unitsand a boundary-vector cell's default tolerance; read them there. Sodataclasses.replaceand pickling build exactly the model the constructor builds:replace(model, seed=2)draws again,replace(model, env=env_in_metres, center=...)expresses the default width in metres, and a boundary-vector cell's defaultdistance_tolerancefollows a replacedpreferred_distance. These used to keep the first model's resolved values.seedis now an attribute. A model that draws a parameter withseed=Nonedraws a seed from the operating system first and keeps it there, soreplace(model, max_rate=30)and pickling keep the drawn center or direction instead of drawing again.ground_truthalways includescell_typeand every parameter needed to rebuild the model (for example placemetric). - Object-vector cells:
distance_widthis nowdistance_toleranceanddirection_kappais nowdirection_concentration, the names used by boundary-vector and head-direction cells. simulate_sessiontakes model parameter names (grid_spacing,grid_orientation,distance_tolerance,preferred_distance, ...) instead ofspacing,orientation,border_widthanddistance. It refuses a parameter for a cell type the session does not simulate, which it used to drop silently. Inborder_cell_session,border_widthis replaced bydistance_tolerance.validate_simulationreturns aSimulationValidationResultwithsummary(),summary_table(),report()andplot(ax=None), instead of a dict;show_plotsis removed.SimulationSessionholds read-only arrays, tuples of spike trains and models, and read-only mappings. It refuses positions that do not match the environment's dimensions, times that do not increase, spike times outside the session and non-integer unit ids.- Maze dimension names say what they measure:
corridor_widthreplaceswidthin the T, Y, plus, linear, repeated-T and repeated-Y mazes, andarm_widthin the radial maze;- the W-maze's
width/heightare nowouter_arm_spacing/arm_length; - Hampton Court's
width/heightare nowframe_width/frame_depth.
port_type names the reward hardware on every port. Sungod's
home/center/arm labels moved to location, and its track end nodes are
named after their ports (arm_port_k). make_star_graph is removed.
Sessions and validation
- Sinusoidal trajectories return positions in environment coordinates,
shape
(n_time, env.n_dims). They pause exactly at the track ends forpause_duration; the old pauses began short of the ends and lasted 2–5 s regardless ofpause_duration. - Each border cell fires along its own wall. Border cells are short-range boundary-vector cells with a preferred direction drawn per cell and a preferred distance of 0–8 cm. That range puts field centres at the 8.8 cm mean that Høydal et al. (2019) report.
- Uniform field centres give every cell its own bin, spread over arenas of
any shape. They are laid out by farthest-point sampling along the bins,
over the bins that keep 40% of their local half-width from the walls: 20%
of the side in a square arena, the middle of each corridor in a maze. A
bin that touches a wall is never one of them, also on hexagonal and
triangular-mesh layouts, whose corner bins have only wall bins as
neighbors. Cells beyond those bins take bins nearer the walls one at a
time, so adding a cell adds one center and moves none. The
old layout snapped a grid over the bounding box to the nearest bins, so
an L-shaped arena gave 50 cells 27 distinct centres and a plus maze gave
20 cells 16, none in the outer part of any arm. It also left the last
column empty, and mixed sessions put every place cell at low x. More
cells than bins now raise instead of sharing centres.
linear_track_sessionspreads fields over the whole track, not just 40–160 cm of 200 cm. grid_cell_sessiondraws the trajectory, the grid phases and the spikes from separate seed streams. It used one seed for all three, which correlated unit 0's spikes with the path.validate_simulationbuilds each cell's true field with the cell's own metric. A geodesic cell that was recovered correctly scored r = 0.79 against a Euclidean field and failed. Units that are not place cells are listed asexcludedand do not count toward the verdict, so a border or mixed session passes when its place cells are recovered; before, they raised. A place unit that cannot be evaluated still blocks a pass, and a selection with no evaluated place unit cannot pass.SimulationValidationResult.max_center_errorholds a fixed center-error limit, soreport()states it even for an empty selection, which used to raiseIndexError.validate_simulationandplot_session_summaryrefuse a session on an egocentric polar environment up front, withCartesianOnlyErrornaming the function. The error used to come fromcompute_spatial_rateinside them, and only when a unit had spikes.plot_session_summarydraws 1-D sessions, such aslinear_track_session, as position against time with a rate curve per cell; it used to raiseIndexError. Rate maps are drawn byEnvironment.plot_fieldin physical units, with unsupported bins shown as missing, where 2-D grids used to be labelled in bin indices. 3-D sessions raiseNotImplementedErrorpointing to the fastplotlib 3-D field plot.SimulationSessioncompares and hashes by identity, like the models.==between two sessions used to raise, because it compared their arrays element by element.
Cell models and spikes
- Model parameters are validated when the model is built. NaN, negative
rates,
baseline_rate > max_rate, a field centre or width of the wrong dimension and unknown metrics are refused.firing_raterefuses positions whose width differs from the environment's.generate_poisson_spikesraises on negative rates; it used to clip them to 0. - Spatial view cells give NaN, not baseline, where position or heading is
missing or the animal is outside the walls.
fov=now requires the preferred location to be inside the field of view, with or withoutrequire_visibility; before, it had no effect withoutrequire_visibility. Visibility withboundary_source="analysis"is about 70 times faster. add_modulationkeeps a spike with probability(1 + d cos(2πft − φ)) / (1 + d):- spikes concentrate at phase
modulation_phase, where they used to sit at −modulation_phase; - the rate at the preferred phase equals the input rate;
- the kept fraction, 1/(1+d), goes smoothly to 1 as the depth goes to 0.
To keep the mean rate, modulate the rate before generate_poisson_spikes.
- Head-direction mean_vector_length includes the baseline rate (κ = 2,
20 Hz peak, 5 Hz baseline: 0.335, not 0.698). tuning_width is NaN when
the curve never falls to half its peak, instead of π.
- Grid cells refuse polar environments. Geodesic object-vector cells refuse
an object outside the environment; it used to be moved silently to the
nearest bin.
- Each unit draws its spikes from its own SeedSequence child. Runs with
seed=0 and seed=1 used to share spike trains shifted by one unit.
- generate_population_spikes casts the rays of boundary-vector cells on
the same walls once per population instead of once per cell. 30 cells
over 5 min at 100 Hz in a circular arena take 9 s instead of 4.5 min,
with identical rates and spikes.
Trajectories
- Default laps run between the two bins farthest apart, so a north–south track works, along the shortest route by distance. A route of fewest steps zigzagged across a circular arena: 133 cm against 108 cm across a 100 cm diameter.
- Lap pause samples have
lap_id = -1anddirection = "pause", and lap boundaries no longer repeat a sample. - OU headings use the exact integral of the turning rate. At
dt = 0.1s the old update overstated heading variance by 17%. In 3-D, each step turns the heading by a von Mises-Fisher angle, so its correlation isexp(-t/τ)at anydt. At lag τ the old Euler step gave 0.48 atdt = τ, not 0.37. - Default speeds are 8 cm/s (OU) and 10 cm/s (sinusoidal), expressed in
env.units. - Sinusoidal trajectories on a circular track run once around the loop and
back. A track with several joined loops or a loop with arms (Hamlet,
crossword, honeycomb, Hampton Court, rat hexmaze) raises and points to
simulate_trajectory_laps(outbound_path=...); it used to cover a quarter of the track. A track in separate pieces raises too; it used to run back and forth on one piece only. - Periodic OU walks wrap at the sides of the region the environment covers, inside both its arena and its bins. With 3 cm bins on a 100 cm box they wrapped at the grid's ±1 cm overhang and left 5% of samples outside the arena.
- Unknown
boundary_modevalues,periodicon an environment that is not a rectangular box, durations shorter than one sample and non-finite or non-integer lap settings raise. - A fast walker no longer stalls in sharp wall corners.
Mazes
- Every maze's dimensions are checked when built: negative, NaN or overlapping sizes raise with the limit they break. Before, a negative side length built a normal box, and a short T-maze arm swapped the wells.
make_watermazerefuses a platform that does not fit in the pool.- The square maze's NE/NW/SE/SW regions are quadrants, as in the watermaze; they were single points.
- Mazes warn when the bin size splits the maze into pieces or leaves a reward region without a bin.
MazeEnvironmentsis frozen.- Estimated sizes are marked as estimates in the maze guide.
- Square-maze and watermaze quadrants give each bin to exactly one quadrant
(see the next section). Before, the default square maze counted 256
quadrant bins for 225 bins and the watermaze 4556 for 4405, and quadrant
visit times from
detect_region_visitssummed to 688 s in a 600 s session.
Regions that share an edge split its bins¶
- A bin center or position exactly on a region's edge belongs to the region
when nudging it a hair north-east (37° from east, a fixed fraction of the
region's coordinate size) would move it inside. Regions that share an
edge, such as quadrants or maze arms cut along a line, now partition the
bins and positions on it; before, an edge center was in every region it
touched. A rectangle holds its south and west edges but not its north and
east edges, like a bin
[a, b), so a box drawn through a row of bin centers covers as many bins as fit in its area. The rim of a point region'spoint_radiusdisc follows the same rule. Interior points are unchanged, and a region drawn as an environment's arena outline still holds every bin of an environment built from that outline. - Where the nudge north-east would leave the space a point can occupy, the
point is nudged south-west instead, so a position on an arena's wall is in
the quadrant beside it. For bins that space is the environment, and a bin
center's nudge never leaves it, so bins follow the north-east rule.
points_in_any_regionandregions_containing_pointsknow no environment and use the union of the polygon regions passed (all of them, also those not named inregion_names): a position on that union's outline is in a region, and a lone box holds its whole edge. Without this, tracking clipped to the square maze's walls lost its samples on the north and east walls from every quadrant. - One rule decides membership everywhere:
bins_in_region,region_mask,region_membership,subset(region_names=...),subset(polygon=...),regions_to_mask,distance_to(region=...),points_in_any_region,regions_containing_points, and the behavior analyses built on them (detect_region_visits,segment_trials,detect_runs_between_regions, decision and VTE analyses).detect_region_visitsaccepts quadrants together instead of refusing them as overlapping. include_boundary=is removed from all of these. Passing it raisesTypeError.point_radiusmust be positive; a disc of radius 0 holds no position.- Seeded results can shift where bin centers lay on a region's north or east edge: in the segmentation snapshots, a run leaving a source box whose east edge passes through a column of bin centers now departs one sample earlier.
Project vision¶
- Added a project vision page with the long-term goals and what is out of scope, linked from the README, the documentation home page and the contributor guide.
Spatial information of very small rates¶
- Skaggs spatial information (
spatial_information,batch_spatial_informationand the rate results' information) is computed on each map divided by its peak rate, which it does not depend on. A map of subnormal rates (5e-324 Hz) fires everywhere but its mean rate underflowed to 0, so it was reported as silent (NaN); it now gets 0 bits per spike for uniform firing and log2(n) for one active bin of n. The JAX backend flushes subnormal inputs to zero, so there such a map is silent.
Decoding refuses missing data instead of decoding it, and follows the published methods¶
A review of neurospatial.decoding found several paths that returned
plausible but wrong numbers: missing rates pulled the posterior toward the
bins where they were missing, NaN posterior rows became track position 0 in
replay fits, a NaN spike count removed a neuron from assembly activation,
and polar summaries averaged angles across the seam. These now raise or
give NaN with a message saying what to fix. Assembly activation, replay
line detection and the MAP bin now follow the cited methods. Posterior
calibration is tested: across unequal-bin, polar, maze and holed-grid
environments, how often the true bin falls inside a credible region
matches the region's mass to within 0.0032.
Posterior and decoders
- A bin where any neuron's rate is NaN gets zero posterior probability, with a warning naming how many bins were excluded. Dropping the masked neuron's term gave that bin likelihood 1, so partially masked bins were favoured (10 flat neurons with 9 masked at one bin put all mass there).
log_poisson_likelihoodrefuses NaN, infinite and negative rates; it passed NaN through, andnormalize_to_posteriorthen made every row uniform.normalize_to_posteriorraises on rows with NaN or +inf in everyhandle_degeneratemode (only all −inf rows are degenerate), and itsaxisparameter is removed.- Prior shape, weights and mass are checked even with
validate=False; a negative prior gave negative posterior mass. decode_session_summaryandBayesianDecoder.predict_summaryreport polarmean_positionin the animal's plane, like the full decode; they averaged raw (distance, angle).BayesianDecoder.predict,predict_summaryandscoreraise when only one of the fitted models and the prediction spikes carries unit labels; they paired trains with models by position, which decoded with the wrong models when the order differed. Label spikes once (SpikeTrains(..., unit_ids=...)or a pynappleTsGroup) and pass the same object to both steps.- A fitted
BayesianDecoderrefusesdataclasses.replace(decoder, env=...)with a different environment.from_modelsrefuses negative rates, and NaN or infinite rates insidespatial_support. - Session decoding raises when every spike lies before or after the decode
bins, also with
warn_on_drop=False. The drop warnings from decoding and encoding name a likely millisecond/second mix-up when the spike times span about 1000 times the span oftimes. decode_positionanddecode_position_summarytake a rate array or aSpatialRatesResult; other objects raiseTypeError.bin_spikes_in_timeexplains at_startafter the last spike instead of suggestingdt <= 0.- The streamed session summary selects each block's spikes by binary search: 0.38 s instead of 0.51 s for 100 units, 1 h at 25 ms and 101 bins.
Decode results
DecodingResult.map_estimateis renamedmap_bin, and the summary'smap_estimatealias is removed.posterior_mode(posterior), which ranked bins by mass, is replaced bymap_bin(env, posterior), which ranks by density like every other MAP.- Undecodable (all-NaN) rows report
map_bin == -1and NaN positions; they reported bin 0's centre. MAP ties within rounding go to the lowest bin, and NaN or infinite bins are never the MAP bin. DecodingResultandDecodingSummaryrefuse invalid posteriors (rows that are negative, non-finite or do not sum to 1, except all-NaN rows), unordered times, malformed spike windows, out-of-range MAP bins and peak probabilities.DecodingResult's derived arrays are read-only, and results compare by identity (==raised).DecodingSummary.map_positionis derived frommap_binand is no longer a constructor argument.peak_probis renamedpeak_probability(also the data-frame column, xarray variable andmean_peak_probabilitykey).summary()on both results averages over decoded time bins and reportsn_undecodable; one undecodable row mademean_entropyNaN, and an empty streamed summary reported 0 (a certain posterior). With nothing decoded the means are NaN.- Exports on polar environments name columns
map_distance,map_angle,mean_distanceandmean_angle; they were labelledxandy. posterior_entropyis float64 on every path, andcredible_regionnever includes zero-mass bins.
Replay and decoding metrics
fit_linear_trajectory,fit_isotonic_trajectoryanddetect_trajectory_radonrefuse posterior rows that are NaN, infinite, negative or do not sum to 1, and mismatched, non-finite or identical times. A NaN row was read as the track start: two NaN rows turned a forward sweep into a −46° (reverse) Radon line.fit_linear_trajectoryrequiresn_samples >= 2.method="map"in the trajectory fits uses the highest-density bin; on unequal track bins it reported 120 cm/s for a stationary MAP.detect_trajectory_radon(env, posterior, times, *, band=None, max_speed=None)implements the replay line search of Davidson et al. (2009): it searches speeds and start positions and scores each trajectory by the mean over time bins of the posterior mass withinbandof it (15 cm by default, their value), with the median of that mass over the track for time bins where the trajectory leaves the track. It reportsspeed(env units per second),start_positionattimes[0], the replayscore(in [0, 1], equal for forward and reversed sweeps and independent of the decode's bin size) and the sinogram oversinogram_speedsandsinogram_start_positions.theta_range/theta_step,angle_degreesand the pixeloffsetare removed: the angle and offset depended on bin size, and the score was larger for one direction of the same event.- New
replay_trajectory_significancetests the replay score against Davidson et al.'s column-cycle, unit-identity and pseudo-event shuffles (1500 each by default), refitting the trajectory to every shuffle, and reports each p-value and the largest. Trajectories that enter the track during the event are searched as well as those that leave it, and scores equal up to rounding compare equal, so ties with the observed score count against it. A 0.5 s event on a 2 m track takes about 7 s with all three shuffles. Times that make the search implausibly large (for example times in milliseconds) are refused. - A stationary decode has
r_squared = NaN(it was 1, which passed replay screens) and an isotonicdirectionofNone. decoding_error(metric="euclidean")on polar environments returns the distance in the animal's plane; it mixed cm and radians. Infinite positions raise. Geodesic positions more than one bin spacing outside the environment's bounding box give NaN with a warning instead of a snapped finite error; positions inside the box are measured from their nearest bin.decoding_correlation(env, decoded, actual, weights=None)takesenv, correlates along-track position on tracks, leaves out (with a warning) dimensions that do not vary instead of returning NaN, refuses polar environments and refuses negative or non-finite weights.
Assemblies and reactivation
- Assembly analyses raise on NaN, infinite or negative spike counts, naming the neuron columns and the number of gap bins.
assembly_activation,AssemblyDetectionResult.activationsandreactivation_strengthuse the projector activation strength R(t) with the diagonal removed (Lopes-dos-Santos et al. 2013; Peyrache et al. 2010), so a burst of one member is not co-activation; the linear projection scored one member bursting alone above its own 2 SD cutoff.reactivation_strengthis mean R(match) / mean R(template): about 0 without co-activation and 1 for identical periods. It is NaN when undefined, and neurons constant in a period are excluded with a warning.AssemblyDetectionResult.activationsis time-first,(n_time_bins, n_assemblies).detect_assemblies(algorithm=)is renamedmethod=. Withn_components="auto", it returns no patterns (with a warning) when no eigenvalue exceeds the Marchenko–Pastur bound; it returned one. Constant neurons are excluded for every method, components without variance are dropped, and patterns are unit length and sorted by explained variance. NMFexplained_variance_ratiois the variance along the pattern, wᵀSw / tr(S).pairwise_correlationsreturns NaN for pairs with a constant neuron (they were 0, which lowered EV from 0.94 to 0.30 when one member was silent), and itsz_score_inputargument is removed.explained_variance_reactivationreturns NaN statistics when undefined.ExplainedVarianceResult.reversed_evis renamedreversed_explained_varianceand is NaN without a control period.AssemblyDetectionResult.patternsis a tuple, and pattern and result fields are validated (member indices, explained-variance ratio, unit IDs).
Regions cleanup and faster membership¶
Region membership and region bin masks test raw coordinates against a
prepared polygon instead of building one shapely point per position:
regions_containing_points on 1e6 positions and 22 regions takes 0.6 s
(was 3.6 s), with identical results. The LabelMe and CVAT readers share one
pixel-frame shift and one region builder; their output is unchanged except
LabelMe circle vertices, which can differ by float rounding (1e-15 px).
Animation(..., show_regions=...)frames, rendered or exported with Matplotlib, leave a polygon region's holes unfilled; they were drawn filled, so a ring-shaped region looked like a solid square.- The napari animation backend draws polygon regions with holes, like the notebook widget, fastplotlib and exported files; it refused them.
- neurospatial requires shapely 2.1 or newer (was 2.0). The fastplotlib
renderer and napari's holed regions triangulate polygons with
shapely.constrained_delaunay_triangles, which shapely 2.0 lacks. plot_regionsrefuses aworld_to_pixelthat changes the shape of its input or returns NaN, naming the region, instead of failing inside Matplotlib.Regions.bufferrefuses NaN or infinite source coordinates as such; it said the buffer "leaves nothing".- A transform passed to
points_in_any_region,regions_containing_pointsor the annotation readers reports a bad output in one message: the transform's argument name, how many points failed and the first of them.
Regions are validated, and annotation readers read what tools write¶
A Region is checked when it is built, so every analysis that uses regions
sees well-defined geometry. Readers for LabelMe, CVAT and masks now read each
shape as the tool defines it, or refuse it, instead of silently dropping or
reinterpreting shapes.
Breaking:
env.regionsis typedMapping[str, Region], so a type checker flagsenv.regions.add(...);with_regions, the factories'regions=and every function that only reads regions accept anyMapping[str, Region].Region.areaandRegion.centerreplaceRegions.area(name)andRegions.region_center(name).Regions.list_names()is removed (uselist(regions)).Regions.to_dataframe()andregions_to_dataframe()are replaced byregions_table(regions): one row per region, indexed by name, withkind,n_dims,area,centerandmetadata.points_in_any_regionandregions_containing_pointstakepositions(waspts), shape(n_positions, 2); a bare(2,)point is refused. A point region is a disc of radiuspoint_radius=(Euclidean), which must be given when a selected region is a point: the old unit-less ±1.0 square was 1 cm, 1 px or 1 m depending on the data.regions_containing_pointshas typed overloads forreturn_dataframe.plot_regionsreturns theAxesand keeps axis limits the caller set.mask_to_region(mask, *, region_name, pixel_to_world=None, simplify_tolerance=0.0)replacesmask_img=andapprox_tol_px=; it traces exact pixel cells (the old default simplified by about 1 px), keeps holes and no longer needs OpenCV.load_cvat_xmlandregions_from_cvattakeimage=(name or id), required when the export annotates more than one image.Regionis unhashable,n_dimsis a property, and metadata errors areValueError. Region and annotation-draft metadata must be standard JSON: string keys, finite Python numbers (NumPy scalars such asnp.float64are refused; convert withfloat(...)); record a missing value asNone.update_regionkeeps the region's position in the collection (it moved to the end), soenv.region_membership()columns stay put, and accepts a metadata-only update.regions_from_jsonrefuses a file without theRegions-v1format tag (it warned and loaded), andregions_to_jsonwrites standard JSON.- The unused NWB region JSON helpers and their legacy reader are removed.
Input that now raises (each was silently accepted):
Regionrefuses an empty or non-string name, non-finite or empty point coordinates, and polygons that are empty, self-intersecting, zero-area, have NaN vertices or Z coordinates. A self-intersecting outline covered 55 bins of a 10 cm grid while reporting area 0.Regionsholds onlyRegionvalues of one dimension (a lineLandmarkwas published as a region).Regions.bufferrefuses a result that is empty or split into several parts.with_regionsrefuses a region whosemetadata["coord_system"]differs from the environment'sunits, such as pixel annotations on a centimeter environment.with_metadata(units=...)and a length-preservingapply_transform(..., units=...)refuse to label an environment in units other than a region's recorded ones, since neither converts the region.points_in_any_regionandregions_containing_pointsrefuse NaN or infinite positions, which were reported as outside every region, a transform output that is not finite(n, 2), and repeatedregion_names.load_labelme_jsondispatches onshape_type: rectangles, circles and points become regions (they were dropped), open lines are refused (they were closed into polygons), and malformed shapes, an empty file, duplicate labels (nowlabel_0,label_1, ...) and a top-level list of shapes no longer warn-and-skip or crash.load_cvat_xmlrefuses video track exports and files with no shapes (both returned empty regions), and raises on malformed shapes instead of skipping them.mask_to_regionrefuses a mask with more than one connected area (pixels touching only at a corner are separate); it kept the largest contour and silently dropped the rest, such as specks in a thresholded or SAM mask. Select the component you want first.plot_regionsrefuses unknownper_region_kwargsnames and point regions that are not 2-D.
Results that change:
- Regions from LabelMe and CVAT are in neurospatial's pixel frame, where
pixel
(x, y)is centred on(x, y), as in video frames,mask_to_regionandVideoCalibration. Both tools put pixel(c, r)on[c, c + 1], so their shapes move half a pixel up and left; calibrated regions were half a pixel down and right. - CVAT masks are decoded as CVAT writes them, run lengths over the mask's
own bounding box placed at
left/top; they were read as start/length pairs over the whole image and came out as slivers. Rotated CVAT boxes are rotated, and ellipses are read. mask_to_regioncovers whole pixel cells (pixel(x, y)spans[x-0.5, x+0.5]); it traced pixel centres, half a pixel inside the mask, and filled holes. Outlines keep only their corners.plot_regionsdraws polygon holes open whatever their winding, andenv.plotwidens its limits to show the regions it draws past the bins.with_metadata(units=...)relabels each region's recordedcoord_systemthat matched the old units.
Behavior follow-ups: geodesic corners, unreachable samples, undefined correlations¶
Results that change (each was plausible and wrong):
metric="geodesic"distances from positions (compute_step_lengths,traveled_path_length,shortest_path_length,compute_path_efficiency,approach_rate,distance_to_reward, goal directions, pairwisemean_square_displacement) could cut across a missing cell or the end of a wall. Line of sight was decided between bin centres and then reused for the actual positions, so two positions whose bin centres see each other were joined by a straight line even when the line between the positions leaves the environment: on an L-shaped grid the distance read 1.41 where any path inside is at least 1.81. Every straight piece from a position is now checked, and a blocked piece routes through bin centres instead. Distances only grow; most are unchanged, and in a typical open-field session about 3% of distances to a goal grow, by at most 14%. Distances between bins (distance_to_region,path_progress,cost_to_goal, the reward fields) are unchanged.detect_goal_directed_runskeeps approaches that follow a sample in a part of the environment not connected to the goal. Such a sample made the summed path length infinite, so every later approach scored NaN and was dropped; it now splits the recording like a gap.trajectory_similarity(method="correlation")is NaN when the correlation is undefined: fewer than three aligned pairs with both samples inside the environment (two points always correlate ±1), or a trajectory that stays in one bin. With no usable pair it returned 1.0, a perfect match. NaN is not 0; skip such pairs withnp.nanmeanornp.isfinite.metric="geodesic"on triangular meshes no longer detours around mesh vertices. A straight line passing within about 0.01 probe spacings of a vertex where two triangles meet only at that point was treated as blocked, so distances on meshes were too long (on a convex 40×40 mesh, 7% of bin-to-bin distances were longer than the straight line, by up to 85%). Lines past or through an interior vertex are now checked around the fan of triangles at that vertex; a pinch, where the environment narrows to a single point, still blocks. Mesh distances mostly get shorter; a few change by about 1% either way where the path now bends at different bin centres. Grids and hexagonal layouts are unchanged.mean_square_displacementgives the same value at a lag whatevermax_tauis; on the uniform-sampling fast path the values shifted by about 1e-13 withmax_tau.- Error messages for an invalid choice argument (
compare_spatial_rates,population_vector_correlation,detect_region_crossings, and theRun,Trial,LapandCrossingrecords) and for an invalidvalid=sample mask read the same everywhere.
Faster (same results):
metric="geodesic"on hexagonal layouts is about 4× faster and on triangular meshes about 2.5× faster. Rate-weighted geodesic costs on grids are up to 2× faster. On grid mazes, line-of-sight probes that land in a wall are recognized without the bin locator (Hampton Court 1.2×, the rat hexmaze 1.1×).
Behavior cleanup and faster analyses¶
Behavior functions share one metric resolver, one position_bins
validator, one movement-heading rule and one attempt state machine for runs
and trials. Results for valid input are unchanged except where listed below
or at float rounding where noted.
Breaking:
running_direction_labels(successful_only=)is renamedsuccess_only=, matchingruns_to_direction_labelsandRun.success.compute_turn_anglesandcompute_trajectory_curvatureare removed. Usecompute_planar_turn_angles(times, positions)for the turn at each frame; its newsmooth_window=(seconds, default 0) gives the smoothed turn the old curvature function returned, for examplesmooth_window=0.2for 30–60 Hz video. Usecompute_planar_curvatureto compare path shape (1/cm) across frame rates and speeds, andcompute_movement_angular_velocityfor rad/s. The smoothing now weights neighbours by elapsed time, so a bridged gap no longer counts as one frame; pauses are NaN instead of taking the turn between the positions on either side. Unsigned 3-D turns are no longer offered; passpositions[:, :2]for horizontal-plane turns.heading_direction_labels(times, positions, *, min_speed, ...)takes a trajectory only. To label headings you already have, for example fromheading_from_body_orientation, use the newheadings_to_direction_labels(headings, speeds, *, min_speed, n_directions=8)instead ofspeed=/heading=. It refuses infinite headings and negative or infinite speeds, which were read as "not observed" or "stationary".mean_square_displacement(max_tau=None)measures lags up to a quarter of the recording, not half: past that, few independent displacements back each value, as its own error messages already advised. Values at the lags still reported are unchanged (FFT path: within 2e-13 relative). Passmax_tauto keep longer lags.
Input that now raises (each was silently accepted):
distance_to_rewardrefuses an unknownmetric;"Euclidean"or a typo ran the geodesic metric. It also refuses empty input, repeated timestamps and decreasing times whenreward_positionsis given, and checksmodefirst.position_binsoutside[-1, n_bins)raiseBinIndexOutOfRangeError, and float or boolean bins raiseTypeError, indetect_boundary_crossings(float bins were truncated, 0.9 read as bin 0),decision_region_entry_time(out-of-range bins read as "never enters"),detect_region_visits(skipped as missing) anddistance_to_decision_boundary(returned NaN).- A NaN, infinite or negative
min_speedraises inheading_from_velocity,instantaneous_goal_alignment,goal_biasandcompute_goal_directed_metrics;goal_bias(min_speed=nan)returned NaN. segment_by_velocityrefuses empty input (it returned a(0, 2)array) and checks all timestamps, so a repeated timestamp next to a NaN row raises.time_to_goalandtrials_to_region_arraysrefuse empty, unsorted, repeated or non-finitetimes.
Results that change:
distance_to_rewardwith all-NaN positions and inferred rewards returns NaN with the unknown-reward warning; it raised numpy's "array of sample points is empty".running_direction_labelsgives its excluded-interval warning once, not twice per end region.- Behavior and event analyses warn "Interval filtering excluded ALL
trajectory intervals" when every gap across missing samples is longer than
max_gap, even though each sampling interval is shorter (10 Hz tracking with every other row NaN andmax_gap=0.15). The check ran before missing samples were bridged, so these calls returned empty or NaN results silently. The warning names the longest gap and says to raisemax_gap. All-NaN input warns too. - The geodesic path engine returns NaN for a target outside the environment; it measured to the last bin's field. Public functions already refused such targets, so their results do not change.
- Error messages for a geodesic metric without
env, an unknown metric, mismatchedposition_bins/times, and positions without x and y columns read the same in every behavior function.
Faster (same results):
mean_square_displacementwith the euclidean metric and uniform sampling uses FFT correlations per recording segment: 54k samples took 36 s and take 0.01 s. Values agree with the pair loop to about 1e-12 relative (below 1e-10 at 500k samples). Irregular sampling andmetric="geodesic"keep the pair loop.trajectory_similarity(method="dtw")is about 100× faster.segment_trials,detect_region_crossings,detect_runs_between_regionsandrunning_direction_labelscompute region masks once per call instead of once per recording segment (running_direction_labelswith 500 epochs: 0.44 s → 0.02 s); direction labels,time_to_goalandtrials_to_region_arraysslice sorted times per interval instead of building one full-length mask per trial (1.8M samples, 450 trials: ~1 s → 0.05 s).- The 0.1 s position smoothing used by
angular_efficiency,compute_path_efficiencyandsubgoal_efficiencyis 3× faster at high sampling rates (rounding-level differences, below 1e-13). - Geodesic
distance_to_rewardprepares all reward fields in one batch. - Geodesic path lengths (
compute_step_lengths,traveled_path_length,compute_path_efficiency) and othermetric="geodesic"measures are 7–10× faster on grid environments (a 54k-sample walk in a 10k-bin arena with a wall: 60 s → 6 s), with identical results: each distance field grows only as far as the farthest bin it is asked about, and line-of-sight checks skip stretches that lie in fully open, connected blocks of cells. Hexagonal and mesh layouts gain less. The cached distance fields stay within their memory budget, and geodesicmean_square_displacementpair tables use about half the memory.
Behavior analyses measure what they say¶
Results that change (each was plausible and wrong):
metric="geodesic"has one meaning acrossneurospatial.behavior: the length of the shortest path between two positions that stays inside the environment. It goes around declared walls, outline slits and missing cells, never leaves the environment, and on a track runs along the centreline whatever the track width or bin size; bin inputs are measured from bin centres. Path lengths summed bin-to-bin graph steps (a straight oblique run read 0.79–0.83 efficient, depending on bin size and sampling rate; it now reads 1.0), andpath_progress,distance_to_region,cost_to_goal,distance_to_reward,distance_to_boundary,mean_square_displacement, the decision boundaries and Voronoi labels, the reward fields,detect_goal_directed_runsand the lap far-side test used bin-graph distances.Environment.distance_betweenandops.distance_fieldstill measure along the bin graph. Behavior geodesic functions refuse egocentric polar environments.angular_efficiency(andcompute_path_efficiency,subgoal_efficiency) measures heading toward the goal. It ignoredgoaland only measured straightness, so a straight run away from the goal scored 1.0. Each step's angle to the goal direction counts in proportion to its length, measured on positions smoothed over 0.1 s, so tracking jitter during pauses weighs almost nothing; undermetric="geodesic"the goal direction is where the in-environment shortest path leads. It now takes(times, positions, goal)plusmetric,env,max_gap,epochsandbreak_before.compute_decision_analysis().chosen_goalis the first goal entered after the decision-region entry. It was the first goal in list order visited at any time, so swappinggoal_regionschanged the answer.distance_to_decision_boundaryis(d2 − d1) / 2, the distance to the boundary; it returned twice that. TheDecisionBoundaryMetricsfield is renameddistance_to_decision_boundary.- Bins equidistant from two goals are labelled
-1(undecided) bygeodesic_voronoi_labels; ties went to goal 0, so a symmetric T-maze recorded crossings for right turns only. detect_lapslap direction uses the closed, centred lap path; it depended on where the coordinate origin was. A region lap is a trip that leaves the start region on one side and returns on the other, so out-and-backs and boundary jitter are not laps, a recording that starts inside the region keeps its first lap, and a start region that does not cut a loop raises; region laps reportoverlap_score=NaN. Template laps close at passes through the start line and start after the last turn back, so laps run at different speeds are found with correct boundaries and aborted excursions are not merged into the next lap.detect_runs_between_regionsends a run when the animal re-enters the source (outcome"returned"); runs overlapped and were double-counted, andrunning_direction_labelsmarked walks back home as outbound.segment_trialsreports a goal entry just pastmax_durationas a timeout; the trial was dropped.- Region events (crossings, trials, runs, laps) at an epoch's stop sample are no longer counted inside that half-open epoch.
mean_square_displacementreports the lags it measured (multiples of the sample interval);linspacelabels were up to 16% off, biasing α.compute_trajectory_curvatureis NaN where a turn is undefined (run ends, stationary samples) and smooths only defined turns; zeros there leaked into real turns. Turns with 3+ position columns are the 3-D angle (a 3-D turn of π/3 read π/4). A missing 1-D sample is NaN, not a reversal (π).distance_to_boundary("edge")usesEnvironment.boundary_bins; on a maze with a junction almost every bin counted as an edge. It and geodesicdistance_to_rewardmeasure from the position, not its bin centre.cost_to_goal(cost_map=)is a cost per unit length integrated along the cheapest in-environment path, so the result no longer depends on bin size; with no costs it equals the geodesic distance.graph_turn_sequencelabels the corners of the simplified route; it gave different turns at 30 and 500 Hz and read bin −1 as the last bin.- Geodesic
approach_rateis continuous (it was mostly zeros with spikes at bin crossings);distance_to_reward(metric="euclidean")is exact, not bin centre to bin centre. compute_goal_directed_metricsmeasures its distances withmetricand only insideepochs.- Goal alignment is NaN at the goal itself (
arctan2(0, 0)scored −1). path_progressisd_start / (d_start + d_goal): 1 only at the goal, below 1 in a wrong arm or past the goal (both read 1.0).heading_direction_labelscentres sectors on the cardinal directions and labels them by centre ("0°","90°", …); straight runs split between two labels.region_reward_field(decay="linear")gives unreachable bins 0; a disconnected component made the whole field flat.compute_home_rangeis an occupancy level set that includes every bin tied at the cutoff.subgoal_efficiencyskips missing rows (one NaN made every segment NaN).
Breaking:
Trialand between-regionRunrecordoutcome("reached","timeout","returned","censored");successis derived. A trial cut off by a tracking gap, an epoch edge or the recording end is censored, not failed.Runalso namessource_regionandtarget_region;write_trialsstores anoutcomecolumn (outcomes=replacessuccesses=).segment_by_velocityanddetect_goal_directed_runsreturn(n, 2)[start, stop)epochs instead ofRunobjects.detect_goal_directed_runsscores each approach to the goal (departure to arrival) instead of each whole recording stretch, so repeated trips give one row each and rest is left out;min_progress=Nonedefaults to 20 cm in the environment's units.compute_vte_sessionkeeps oneVTETrialResultper input trial, with astatusfield and NaN metrics for unmeasured trials; trials were dropped, so results did not line up withtrials.VTESessionResultderives its counts and means and addstrials_table().classify_vte(NaN)isNone.min_speedresult fields aremin_observed_speed;suggests_deliberation(max_mean_speed=)replacesmin_speed=.GoalDirectedMetrics.time_to_goalistime_to_reach_goal.subgoal_efficiency(env, times, positions, subgoals, ...)takestimes.graph_turn_sequence(tolerance=)replacesmin_samples_per_edge=.distance_to_boundary(boundary_type="obstacle")is removed (it could not work).DecisionAnalysisResult.boundaryisNonewith one goal; crossing times and directions are read-only arrays.- Derived result fields are no longer constructor arguments:
PathEfficiencyResult.efficiency,SubgoalEfficiencyResultmeans,LabelEpochsResult.epoch_labels/n_samples,cumulative_distance. Behavior result types check their fields at construction. - Threshold screens (
is_efficient,is_goal_directed,classify_vte,suggests_deliberation) are inclusive (>=).
Refused instead of guessed: unsorted, NaN or duplicate times and float bins
in the segmentation detectors, compute_home_range and
decision_region_entry_time; percentile=0.95; epochs without times;
min_overlap <= 0 (looped forever); negative or NaN costs in cost_to_goal
(hung); a misspelled metric; NaN min_speed; headings in degrees (warns);
reward times outside the recording (warns); non-positive max_distance,
reference_speed, window_duration, max_tau; alpha outside [0, 1].
Added: MSDResult is exported; region_reward_field(max_distance=);
DecisionAnalysisResult.goal_regions/chosen_goal_region;
Crossing.region.
Docs: missing samples are described as bridged within max_gap (about 15
docstrings said they cut the computation); restrict defaults to
closed="left"; corrected MSD exponent classes and citations (IdPhi:
Papale et al. 2012, Cogn Affect Behav Neurosci); removed misattributed
citations and the user-guide "Replay Analysis" section.
Event analyses refuse bad input instead of returning wrong numbers¶
Breaking:
time_to_nearest_event,event_count_in_windowandevent_indicatorname their first argumenttimes, notsample_times, like every other per-sample function.windowmay be passed by position, in the canonical events orderf(times, event_times, window);window=still works.events_to_intervalsraises when an interval stops before it starts; it warned and kept the negative-duration row, which also passed anymax_durationfilter. Sequential pairing (match_by=None) also raises unless the sorted starts and stops alternate,start[k] <= stop[k] <= start[k+1]: a missed exit plus an extra one kept the counts equal and silently paired every later event with the wrong partner, returning overlapping intervals. Pair by an id column withmatch_by=instead.PeriEventResultandPopulationPeriEventResultderivebin_centers,firing_rate(s),mean_firing_rate,n_unitsandmean_histograminstead of taking or caching them, so they can no longer disagree withwindow,bin_sizeandhistograms(a hand-built result withn_units=7and 3 rows reported 7 units). Drop those arguments when building a result by hand. Construction now checks every array's shape against the binswindowandbin_sizegive, a finite positivebin_size, and integer event counts withn_events >= 1.PopulationPeriEventResulttakesmean_sem, the standard error across events of the population-mean histogram, andplot()draws it as the band. The band was the mean of the per-unit SEMs, which is not the uncertainty of the plotted curve: two units firing on alternate events have an exactly flat population mean, and got a ±0.17 count band.peri_event_histogramandpopulation_peri_event_histogramrefuse a NaN or infinitebin_sizeand one longer than the window, which returned an empty histogram and all-NaN summaries, and 2-Devent_times, which were flattened into extra events.ContinuousEventResultderivesrelative_timesfromwindowanddtinstead of taking it. Drop that argument when building a result by hand.add_positionsrefuses unsorted trajectorytimes, which it sorted, and NaN or infinite event timestamps, which it turned into NaN positions, like every other events function. Sort the trajectory and drop events without a time first.
Results that change:
add_positionssamples positions exactly asalign_continuous_to_eventssamples a signal. An event exactly at a tracked sample gets that position even when the sample is isolated by gaps longer thanmax_gap(it was NaN), and a one-sample trajectory is valid input. A sample exactly at an epoch's stop is outside the half-open epoch, so an event there is NaN (it got the sample's position).align_continuous_to_eventswraps sampled circular values to (−π, π], the convention ofwrap_angleand of its own circularmeans; they were in [−π, π), so due West read −π invaluesand +π inmeans. An exact half-turn between two samples is now interpolated the positive way.-
The PSTH
baseline_rate(insummary()andsummary_table()) averages only bins that end at or before the event. A bin whose center was negative but which straddled the event counted as baseline: spikes only at +10 ms and +20 ms gave a 5 Hz baseline instead of 0. -
event_count_in_windowandevent_indicatorcount each event at exactly one sample when bin-centred windows tile time. Window edges computed twice with different float rounding (s[k] + dt/2ands[k+1] - dt/2) left gaps and overlaps: 11,999 events on a 20 Hz camera clock gave 12,462 counts in 10 Hz bins, and events exactly at sample midpoints were lost about half the time. Edges that agree up to rounding are now one instant. align_eventskeeps column dtypes and the row labels ofevents. It copied each row through a float Series, so integer ids came back as floats and two ids above 2**53 could merge into one; the index was reset to 0..n-1. An event in several windows repeats its label.
Added:
epochs=ontime_to_nearest_event,event_count_in_windowandevent_indicator: when events were recorded. A sample whose value depends on unrecorded time is NaN instead of counting the unseen stretch as "no event": a 5 s reward-history window at the first second of a recording reported 0 rewards. Withepochs, counts and the indicator are float64 (the indicator 1.0 / 0.0 / NaN); without it they keep their int64 and bool dtypes. A time to the nearest event is NaN when an unrecorded event could be nearer.PeriEventResult.summary_table(), with the same columns as the population result's. PSTHsummary_table()andto_dataframe()recordwindow,bin_size,n_eventsandn_events_droppedindf.attrs, andto_dataframe()has afiring_rate_semcolumn in Hz next to the count-unitsem.PeriEventResult.firing_rate_semgives the same array.
Fixed:
- Every events function reports bad spike, sample or event times the same way: non-numeric input names the argument instead of raising NumPy's conversion error, and NaN or infinite times give their count and first index.
peri_event_histogramandpopulation_peri_event_histogramwarn once, not once per unit plus once more, for a window longer than 60 s.align_continuous_to_eventsqueries each event at exactly 0 s. The gridstart + dt * jcan land a few 1e-17 s before the event (window(-0.33, 0.33),dt=0.03), so a categorical or"previous"feature read the sample before an event that lies on a sample: the zone at the event was the previous zone.- With
epochsorspike_window, an event window that ends exactly at an epoch's stop is kept. Its stop, computed asevent + window[1], can round one ulp past the epoch (0.1 + 0.2against0.3), which dropped the event fromperi_event_histogram, marked itoutside_epochsinalign_continuous_to_eventsand gave NaN inevent_count_in_window, so identical trials were kept or dropped by where they fell on the clock. time_to_nearest_event(signed=True, max_time=..., epochs=...)is NaN where an unrecorded event could be nearer than the true nearest event, not only nearer thanmax_time: the sign of a clipped value comes from the true nearest event, so a seen event 5 s back read as +max_timealthough an unseen one 2.6 s ahead was possible. Without any event,signed=Falsewithmax_timegivesmax_time(no event is nearer), checked againstepochslike any other value, instead of all NaN; a session without rewards is a known covariate, as inevent_count_in_window.time_to_nearest_eventrefusesmax_time=True, which clipped every value to 1 s, and an infinite or non-numericmax_time; passNonefor no clipping.align_eventsrefuses a NaN or infinite event or reference time, which it dropped without a word, and a non-DataFrame input with aTypeError.filter_by_intervalsrefuses NaN event times (withinclude=Falsethey were kept as "outside every artifact") and NaN or inverted intervals, which silently matched nothing. Membership is one binary search per event instead of an events x intervals array.events_to_intervalsandintervals_to_eventsrefuse NaN times, a NaNmatch_bykey and a NaN or negativemax_duration(NaN dropped every interval).intervals_to_events(which="both")lists a stop before a start at the same time, and an empty input gives a floattimestampcolumn.add_positionsrefuses positions with more than 3 columns (the 4th and later were dropped) or none, repeated trajectory times (which of two positions at one instant won depended on sort order;align_continuous_to_eventsalready refused them) and non-numeric timestamps, which it parsed.event_count_in_windowandevent_indicatorrefuse a NaN or infinite window bound (a NaN start gave negative counts) and 2-D time arrays.time_to_nearest_eventrefusesmax_time=nan, which returned all NaN.ContinuousEventResultturns list and tuple fields into arrays and tuples before checking them: a listevent_statuspassed the checks and then reported 0 retained events. Construction also checksdt,window,max_gap,epochs, interpolation per value kind, uniquefeature_ids, counts within the retained events, finite values whereversupportis True, and the unit of circular features, and thatepochsare sorted and separated asas_intervalsreturns them.align_continuous_to_eventsstores every radian spelling ("radian","radians") as"rad".
Faster:
peri_event_histogramandpopulation_peri_event_histogramcount every event at once and keep one unit's counts in memory at a time. With 1e4 events, one unit of 1e6 spikes takes 20 ms instead of 115 ms, and 100 units of 1e5 spikes take 1.4 s instead of 5.6 s and peak at 25 MB instead of 965 MB. Counts are unchanged.import neurospatial.eventsno longer imports pandas.in_epochs,restrict_spike_trainsand pose heading find each time's epoch with one binary search instead of comparing every time with every epoch: 1e6 times against 1000 epochs take 9 ms instead of 1.2 s, without the 1 GB times x epochs array. Masks are unchanged.
Encoding cleanup and faster analyses¶
Single-unit and population rate maps now share one code path in every family, and the shuffle tests bin the trajectory once instead of once per shuffle. Rate maps, scores, p-values and labels for a given seed are unchanged, except where listed under "Breaking" and "Results that change" or at float rounding where noted.
Breaking:
fill_valueis removed fromcompute_spatial_rate,compute_spatial_rates,has_place_field,is_place_cellandplace_cell_significance, and fromanalysis_settings. Rate maps always store bins without enough occupancy as NaN: missing, not 0 Hz. The option changed only the stored array; decoding, the predicates, the shuffle test and every result metric already treated filled bins as missing, so passingfill_value=0.0beforeBayesianDecoder.from_ratesdid nothing. For a tool that needs finite values, fill a copy withnp.nan_to_num(rates.firing_rates)and keeprates.spatial_support; a zero-filled raw array still needsprior=rates.spatial_support * env.bin_sizesto decode.
Results that change:
DirectionalPlaceFields.correlation,rate_map_correlationsandpopulation_vector_correlationcall a correlation undefined by the rulefield_stabilityandcompare_spatial_ratesuse: at least three points, and a map is flat when its range is within 1e-12 of its largest rate. Two shared bins gave exactly ±1 and now give NaN; a 5 Hz map with 1e-15 rounding noise gave a finite r and now gives NaN.population_vector_correlationneeds at least three units: with two it returned ±1 at every location, and it now raises.object_vector_scorerefuses a map with negative or infinite rates, for example a baseline-subtracted or z-scored map. Random noise scored 1.0, and an infinite bin silently dropped its ring. NaN bins are still unvisited.- The JAX backend turns on JAX float64 when it is chosen, not when one of
its modules is first imported. The first JAX analysis in a process could
run in float32 (directional rates and binned object-vector maps came back
float32, with JAX truncation warnings); every JAX call now computes in
float64. Choosing
backend="jax"(or"auto"with JAX installed) setsjax_enable_x64for the process, as the first JAX computation did before. - Binned smoothing on the JAX backend stays in JAX instead of round-tripping through NumPy, so its rates can differ from before at float rounding (up to 2e-14 relative).
ViewRatesResult.peak_locations()uses the occupancy support and returns NaN for a silent unit, matchingpeak_location()andsummary_table(). It could report a rate in a never-viewed bin and returned the first bin for an all-zero map.
Faster:
place_cell_significanceandspatial_cell_type_significancebin the trajectory once for all shuffles instead of once per unit and shuffle: 50 units with 100 shuffles took 2.0 s instead of 16 s at 2e5 samples and 8.6 s instead of 79 s at 1e6 samples.compute_spatial_ratesfills missing positions and bins the trajectory once for the population instead of once per unit (1e6 samples, 100 units: 0.5 s instead of 1.8 s).head_direction_cell_significance,spatial_view_cell_significance,object_vector_cell_significanceandegocentric_object_vector_cell_significance(and the predicates withcriterion="shuffle") reuse the observed binning, analyzed intervals and occupancy in every shuffle and only recount spikes: about 2x faster for head direction and object vectors, 1.4x for view cells.- Calling
spatial_information_bias()on each unit of a population reuses the population's estimate instead of recomputing it per unit (100 units, 1600 bins: 0.05 s instead of 5.8 s). population_coveragediffuses all candidate field peaks in one batch (about 5x faster).compute_field_emduses sparse transport constraints, and withmetric="geodesic"computes shortest paths only from bins with mass: peak memory 634 MB to 15 MB for 214 active bins in a 10,000-bin arena, and geodesic runs 5-10x faster.spatial_autocorrelation, grid scores and their shuffles reuse the terms that depend only on the map's missing bins (about 1.7x faster for the autocorrelogram). Values change only at float rounding (relative differences up to about 1e-14).compute_phase_precessionshuffles build their slope basis once (up to about 2x faster), and the default smoothing bandwidth for environments without metric units is found about 30x faster.grid_score,batch_grid_scores,grid_propertiesand the grid statistic ofspatial_cell_type_significancebuild the five autocorrelogram rotations once per map shape (batch_grid_scoreson 100 maps: 100 ms instead of 180 ms;spatial_cell_type_significancefor 50 units with 200 shuffles in a 100 cm box: 16 s instead of 25 s on one core). Rotated autocorrelograms differ only at float rounding (up to 1.3e-14), and seeded p-values are unchanged.import neurospatial.encodingno longer imports matplotlib orscipy.stats(median 0.22 s instead of 0.43 s).
Messages that change:
- A bad spike train in a
*_significancefunction names the unit, e.g. "place_cell_significance (neuron 1): spike_times must be ...". - Timestamp errors read the same in every family: "times has N non-finite
value(s), first at index I." and "times must be monotonically
non-decreasing; it decreases at N place(s), first a -> b at index K.".
Non-finite times no longer also report their ordering. Unsorted times say
how to sort them (
order = np.argsort(times, kind="stable"), applied to every per-sample array), and calls without positions are not told to pass position rows. compute_object_vector_rate,compute_egocentric_rateandcompute_view_ratescheckmethod(and, for views,gaze_modelandview_distance) before other settings, matching their single-unit or population counterparts. An invalidmethodwith no environment or a unitless one used to report a misleading "needs a bandwidth" error.spatial_view_cell_significancereports wrong-lengthgaze_offsetsand positions that do not match the environment with the same errors ascompute_view_rates(it raised a NumPy broadcasting error), and warns once instead of twice about headings that look like degrees.object_vector_cell_significance,egocentric_object_vector_cell_significanceand, without an environment,compute_object_vector_ratesreport positions with the wrong number of columns (for example(n, 3)) instead of raising a NumPy broadcasting error.smooth_rate_maps_batchrefuses a negative, NaN or infinitemin_occupancyon the JAX backend too, so the view and object-vector shuffle tests refuse it before shuffling. Every smoothing method compiles underjax.jitwith traced counts, occupancy and threshold; tracing with JAX float64 off raises an error that says to enablejax_enable_x64first, instead of failing with a buffer-size mismatch.- Single-unit
gaussian_kderate maps on the JAX backend can differ from before at float rounding (about 1e-15 relative).
Unequal bins and bin numbering¶
Bins can differ in size (cells clipped by an outline or split by a wall,
hexagonal, mesh and track bins, Environment.from_bins), and their numbering
is arbitrary. Several metrics weighted every bin equally or read meaning into
the bin numbers. On equal bins numbered along the track, results are unchanged
except where noted.
Results that change:
- Field geometry weights each bin by its rate times its area (or length):
rate_map_centroid(both methods),field_shift_distance,field_shape_metricsandcompute_field_emd. A uniform field on a 10 cm square cut into strips 1, 1 and 8 cm wide had its centroid at x = 2.67 instead of 5.compute_field_emd(normalize=False)now moves rate × area, so its value carries the bin measure's units. - Coverage and overlap are fractions of space, not of bins:
spatial_coverage_single_cell, population coverage and redundancy,field_overlap(a Jaccard index of areas),compute_region_coverageandSpatialRateResult.region_coverage.in_out_field_ratioaverages rates over space. compare_spatial_ratesreportsmean_rate_aandmean_rate_bas firing over time (occupancy-weighted, spikes per second on the common bins) instead of an unweighted mean of bins: 28 spikes in 8 s now reads 3.5 Hz where it read 3.0 Hz when one bin was visited longer than another.- Border score: a field's wall coverage is the fraction of the whole wall it covers, as in Solstad et al. (2008); unvisited wall counts as not covered. Before, unvisited wall was left out of the wall's length, so missing data raised the score: a field along 20% of one wall scored 0.23 with every wall visited, 0.39 with half of the other wall bins unvisited and 0.78 with only the field visited; it now scores 0.23 in all three. The distance term weights each field bin by its rate times its area. Fully visited maps on equal bins score as before; the share of untuned simulated units passing the 0.5 cutoff in the user guide's example fell from 46% to 8% because 3% of the wall was unvisited.
- Decoding: the default prior is uniform over space (mass proportional to
bin size) instead of equal mass per bin, which pulled estimates toward
small bins (untuned cells on a track with one long bin decoded to 66.7 cm
instead of 50). MAP estimates (
map_estimate,map_position,confusion_matrix(method="map")) andcredible_regionrank bins by density, mass divided by bin size; the credible region includes every bin tied with its boundary bin, so it no longer depends on bin numbering. An explicitprioris a mass per bin: passprior=rates.spatial_support * env.bin_sizesfor a uniform prior on supported bins. A time bin that no position can explain keeps the prior. An environment that combines lengths and areas (a corridor joined to an arena) refuses the default prior, MAP and credible region, since density has no common unit there; decode each component withenv.subset, or pass a prior. - Replay fits read each bin's position along the track instead of its
index:
fit_linear_trajectoryandfit_isotonic_trajectoryreport slopes in environment units per second (not bins per second), anddetect_trajectory_radonresamples unequal bins onto equal columns. Renumbering the bins changed a clean sweep's fit from slope 100 bins/s, r² = 1.00 to slope −19.4, r² = 0.04; it now fits 500 cm/s, r² = 1.00 either way. Environments without a position along a track are refused. spatial_autocorrelation_radialcounts each pair of bins in both orders around one mean per distance band. Pairs were ordered by bin number, so renumbering a five-bin path changed one band from +1 to −1; values also change for bins numbered in order (a linear ramp reads 0.67 at the first lag instead of 1).trajectory_similarity:method="correlation"correlates positions along the track, not bin numbers (renumbering changed one score from 0.00 to 0.44), and refuses environments without a track coordinate;method="jaccard"is a Jaccard index of areas.shuffle_place_fields_circular,shuffle_posterior_circularandshuffle_posterior_weighted_circularshift along the track instead of rolling bin numbers, which broke fields into pieces on a masked 2-D grid, and refuse environments without a track order.
Breaking API changes:
envis the first argument ofin_out_field_ratio,spatial_coverage_single_cell,field_overlap,detect_trajectory_radon,fit_isotonic_trajectory(no longer an optional keyword),shuffle_place_fields_circular,shuffle_posterior_circularandshuffle_posterior_weighted_circular.PopulationCoverageResulthas abin_sizesfield.
Missing tracking and max_gap¶
Results that change:
- The head-direction tuning test measures its heading autocorrelation time and its spike-count windows in seconds on the observed time, not in rows. Missing headings given as NaN rows and the same rows deleted now give the same p-value (they gave 0.070 and 0.047 on one recording with 20% of frames lost, on either side of 0.05), and dropped frames no longer shorten the window. On regularly sampled recordings no calibration verdict changed.
- Animation overlays sample between the real observations on either side of
a bridged gap instead of treating the filled-in row as an observation.
With
interp="nearest"the switch falls at the midpoint between the two observations (for samples at 0, 0.4 and 0.8 s with the middle one missing, a frame at 0.5 s showed the first position; it now shows the last, as with the row deleted), and head-direction arrows follow the real observations. Trails and track paths join the two observed rows. A sample a custom adapter rejects without givinginterval_validis crossed like a deleted row. simulate_trajectory_oukeeps a walk that starts on a wall inside. An outward first step from the outline was not reflected, so a walk started on the west wall of a 100 cm box left the arena in 99 of 100 samples. Steps from an internal wall may leave to either side; walks that start away from walls are unchanged.-
simulate_trajectory_ourefuses astart_positionoutside the environment, or one withoutenv.n_dimscoordinates, instead of simulating a walk the walls keep outside. A start on the outline, including one that rounds just past a slanted wall, is accepted. -
A missing tracking sample (a NaN row, for example a low-confidence DeepLabCut frame) is treated exactly like a deleted row. A gap is the time between consecutive valid samples; a gap no longer than
max_gapis bridged (its time goes to the last valid position and spikes in it count), and a longer gap is excluded. Before, one NaN frame always excluded both neighboring intervals and split the run, whatevermax_gapsaid: on simulated 30 Hz tracking with dropped frames and NaN bursts, occupancy differed from the same recording with the rows deleted by 7.0 of 60 s, one bin's rate by 4.2 Hz, and 21 running bouts were found instead of 19. This applies to occupancy and rate maps of every cell family, environment occupancy,bin_sequenceand transitions, speed and kinematics (which use the real time between valid samples across a bridged gap), behavior segmentation, region visits, trials, path efficiency (which starts and ends at the first and last observed samples), continuous alignment, decoding ground truth, decode time bins, the all-units-silent warning, visibility and animation overlays.decode_sessionanddecode_session_summarytile decode bins on the tracked clock, so a tracking loss longer thanmax_gapis not decoded, as when its rows are deleted; to decode spans without tracking, predict with a fittedBayesianDecoderontimes=np.arange(t0, t1, dt).bin_sequencedrops NaN samples instead of reporting −1, so −1 means only "outside the environment", and functions that takeposition_binsbridge bin −1 like a missing sample. max_gapdefaults to 1.0 s (was 0.5 s) in every function that takes it, from one shared constant. The value is calibrated by simulation: on 15 minutes of linear-track running tracked at 30, 60 and 120 Hz with 1–5% dropped frames, DeepLabCut-style bursts of 1–15 low-confidence frames, 2–5 s tracking losses and a 10 s pickup, occupancy, rate maps, bout counts and complete runs were all near their best formax_gapbetween 0.55 and 2.0 s; 1.0 s is the middle of that range. At 0.5 s the longest routine bursts (15 frames at 30 Hz) split running bouts. Choose another value when your tracking differs: longer than routine dropped or low-confidence stretches, shorter than real tracking losses or pickups.- A NaN that means "undefined" rather than "not tracked" (for example
heading_from_velocitywhile the animal is still) is bridged like missing tracking when it is shorter thanmax_gap. Pass a smallermax_gapwhere that would invent values, as the behavior-around-events guide shows.
Encoding review (neurospatial.encoding)¶
Grid score¶
Results that change:
spatial_autocorrelationreturns the Pearson correlation at every lag over the bins visited in both copies of the map, full size(2N - 1, 2N - 1). It was a circular FFT of N×N lags, so shifts wrapped across the arena and unvisited bins counted as zero firing. A lag is defined only when at least a quarter of the visited bins (and at least 20) overlap; others are NaN, so no correlation rests on a strip of bins along two walls. Grids with many inactive bins, and arenas that fill only part of their bounding box, are no longer refused.grid_scoresweeps the annulus outward from the central peak and keeps the best mean over three consecutive radii (the BNT/opexebo gridness score). The fixed ring at 0.2–0.5 of the half-width missed the first ring of fields once the spacing was a sizeable part of the arena: ideal 40–60 cm grids in a 100 cm box scored as low as −0.18 and were labeled "place". They now score +1.33 to +1.41 and are labeled "grid". 1–8% of circularly time-shifted shuffles reach 0.4, so the docs call 0.4 a screening convention and point tospatial_cell_type_significancefor a verdict.grid_scaleandgrid_propertiesreport a spacing, orientation and orientation spread only from one hexagon's first ring: at least 3 autocorrelogram peaks within 1.5 times the nearest one, whose directions agree within 15° modulo 60°, and whose neighbors are all defined lags. Otherwise, or when the grid score is NaN, they are NaN. Second-ring and edge peaks gave wrong spacings (66 cm for a 50 cm grid, 71 cm for an 85 cm grid), and every time-shifted shuffle got a spacing (now 20%). Peaks are separated by the central-peak radius instead of 5 pixels, so they follow field size, not bin size.threshold_relis relative to the zero-lag correlation.- On layouts without a rectangular grid (hexagonal, track, mesh),
SpatialRateResult.grid_score()andSpatialRatesResult.grid_scores()raiseNotImplementedError, asspatial_autocorrelationdoes, instead of returning NaN; NaN now means an all-NaN or constant map.summary_table()records the refusal indf.attrs["unavailable_metrics"], and a tuned cell is labeled "undetermined" instead of "place".
Breaking API changes:
grid_scorelosesinner_radius_fractionandouter_radius_fraction.grid_scaleandgrid_propertieslosemin_distanceand takepixel_size=(the side of an autocorrelogram pixel,autocorrelation_pixel_size(env)) instead ofbin_size=;n_peaksmust be at least 3.GridPropertiesis frozen with read-only arrays.peak_coordsare offsets in the environment's units (they were autocorrelogram pixels) andn_peaksis derived from them.periodicity_scoreis removed. A hexagonal lattice's radial autocorrelation peaks at unevenly spaced distances by construction, so the score penalized true grids, and no threshold separated grids from shuffled maps.spatial_autocorrelation_radialstays.
Border score¶
Results that change:
border_scoreis the Solstad et al. (2008) score: coverage is the largest fraction of one wall covered by one field, and distance is the firing-rate-weighted mean distance of field bins to the nearest wall over the radius of the largest inscribed circle. Fields are connected bins at or abovethreshold(0.3) of the peak and at leastmin_area. On rectangular arenas it equals an independent implementation of Solstad's score exactly; the docstring lists where BNT and opexebo differ (they count distance in whole bins, which lowers their scores slightly). Before, coverage was contact over the whole perimeter and distance an unweighted mean over the arena diameter: in a 100 cm box a field on one wall scored 0.07 instead of 0.29 and a broad central field 0.62 instead of 0.12.min_areadefaults to 200 cm² (Solstad et al., 2008) converted toenv.units. Without metric length units (the default forfrom_samplesandfrom_polygon), a field needs at least 9 bins (the BNT/opexebo default) instead of the score being unavailable.summary_table()records the rule indf.attrs["border_field_minimum"].- Walls come from the arena geometry in physical units. An outline splits
into walls at corners, judged against the bin resolution: an octagon has 8
walls (a cell along one side scores 0.90, was 0.53), a box with rounded
corners or a jittered hand trace 4, and a pixel-mask hexagon 6 like its
polygon. Named internal walls are separate walls, and walls declared on the
outline (
OpenArena(walls=...)) replace that piece of it. A round arena offers each field its best half-ring (Høydal et al., 2018): a 90° arc cell scores 0.82 (was 0.66) and a place field near the wall 0.04 (was −0.30). - Unvisited wall bins are missing, not uncovered: coverage is the covered fraction of each wall's observed length, and the score is NaN only when no wall is observed. One unvisited wall bin no longer makes the physical score unavailable for every unit of a realistic session.
- Wall pieces owned by no bin raise a geometry error that says how much wall, where and how to fix it. A geodesic failure of the environment raises once for a population instead of marking every unit as failed.
Added:
observed_wall_fraction(env, firing_rate)(how much wall a map observes) anduntuned_border_score(env)(the score of a map firing everywhere: 0.5 in a convex arena, the baseline to compare against).summary_table()records both indf.attrs["border_observed_wall_fraction"]anddf.attrs["border_score_untuned"].
Head direction¶
Results that change:
- The head-direction screen tests tuning with occupancy as exposure. The
count-weighted Rayleigh test measured where the animal faced, not the
cell: a constant-rate cell's spike headings follow heading occupancy. The
new test is the score test of a Poisson cosine-tuning model with occupancy
as exposure, corrected for spike-count dispersion (bursts, slow rate drift)
estimated from each unit's counts in time windows. Flat-tuned cells with
headings sampled unevenly within ±60° of East passed
is_head_direction_cell(criterion="threshold")20/20 times and now 2/20, while real head-direction cells still pass 20/20. The test gave p < 0.05 for 13–69% of untuned bursting cells, now 4–6%. The p-value is NaN below 50 spikes in visited bins (was 3) and on a hand-built result withoutspike_count_dispersion(pass 1.0 to assume Poisson spiking).classify(),summary_table()andinterpretation()use the same test. - Mean vector length uses only visited heading bins, so
classify(),is_head_direction_cell(),summary_table()andinterpretation()warn when more than 90° of heading was never sampled and point to the shuffle. bin_sizedefaults toNone, meaning 6° in eitherangle_unit, incompute_directional_rate(s),is_head_direction_cellandhead_direction_cell_significance. The old defaultnp.pi / 30was read inangle_unit, so withangle_unit="deg"it gave 0.105° bins (3438 bins). Degree and radian inputs now give identical maps and statistics.- Headings are wrapped to [−π, π) and bins tile that range, so
peak_location(),preferred_direction(),to_dataframe()and the plots agree (one curve read 4.24 and −2.0). Any finite radians are still accepted. Results recorddirection_frame="allocentric". tuning_width(),tuning_width_deg()andtuning_widths()return NaN for a unit whose heading bins were never visited instead of raising.bandwidthmust beNoneor a finite number > 0;bandwidth=0raisedZeroDivisionErrorin radians and silently skipped smoothing in degrees.- Headings read as radians that look like degrees (finite magnitudes above
2π, all within ±360, with a step between samples larger than π) warn and
name
np.radiansorangle_unit="deg": a head-direction cell with mean vector length 0.806 read as 0.015. Unwrapped radians do not warn. The check covers directional, egocentric, view and boundary-vector maps and phase-precession phases. All-NaN headings now say to check the headings, not the positions. head_direction_cell_significanceruns about 3 times faster with bit-identical seeded p-values.- The docs warn that on tracks and mazes heading is locked to position, so a one-direction place cell passes both the screen and the shuffle, and name controls that hold position fixed.
Breaking API changes:
rayleigh_pvalue()is removed; useDirectionalRateResult.tuning_pvalue()orDirectionalRatesResult.tuning_pvalues().summary_table()gainstuning_pvalueandspike_count_dispersioncolumns.
Added:
- Directional results gain keyword-only
spike_count_dispersionanddispersion_doffields,analysis_settings["dispersion_window"], andlargest_unvisited_arc()(radians; also insummary_table().attrs).
Spatial information, sparsity, cell-type labels and shuffle significance¶
Results that change:
- Information screens subtract the information an untuned cell has by
chance. Plug-in Skaggs information is positive for a cell that fires at the
same rate everywhere, so in short recordings untuned Poisson cells passed:
14/20 at 2 minutes for the place screen, 20/20 up to 10 minutes for the
object-vector screens and 20/20 at 2 minutes for the view screen. After the
correction none passed at 20–600 spikes.
is_place_cell(criterion= "threshold"),classify(),label_cell_types(),summary_table(),is_object_vector_cellandis_spatial_view_cellcomparespatial_information() - spatial_information_bias()with their cutoff;spatial_information()still returns the plug-in value. A unit with fewer spikes than the map has effectively independent bins gets a NaN bias and fails the screen with a warning. - Spatial information and sparsity are NaN, not 0, for a silent cell, an
all-NaN map or zero total occupancy, in both backends and in
summary()andsummary_table()alike. Sparsity divides by the peak rate first, so very small or large rates no longer underflow to NaN. - Every metric method on spatial rate results reads rates masked to NaN
outside the occupancy support, so metrics are identical with
fill_value=Noneandfill_value=0.0. Filling raised an untuned cell from 0.092 to 0.175 bits/spike. - Each spatial cell-type label is judged by its own statistic.
label_cell_types()andsummary_table()return "grid", "border", "grid+border" or "place" (tuned and neither). Grid and border cells were gated on the place-cell information cutoff, so every simulated grid cell with 1 Hz background was "unclassified" while 11% of place cells were labeled "grid". On a 1-D track grid and border do not apply and a tuned cell is "place". When a score is unavailable,label_cell_types()labels the cell "undetermined" and warns instead of raising. - A shuffle p-value is NaN when no null score is finite (it was 1.0, which reads as tested and not significant), and a warning names the units whose non-finite null scores were left out.
place_cell_significancebins the trajectory once for every shuffle; null scores are bit-identical and a 4-unit, 50-shuffle run takes 0.05 s instead of 0.22 s.- A "grid" label needs a hexagonal first ring of autocorrelogram peaks (a
finite
grid_propertiesspacing), on the fixed-cutoff screen and the shuffle labels alike. A smoothed-noise map scored 0.50 with shuffle p = 0.025 and no ring, and both called it a grid cell; it now falls through to its other labels. - A shuffle verdict of place, view or object-vector cell needs both a
significant information test and bias-corrected information at the
screen's cutoff:
CellTypeSignificanceResult.label_cell_types()(keywordmin_spatial_info) andcriterion="shuffle"onis_place_cell,is_spatial_view_cell,is_object_vector_cellandis_egocentric_object_vector_cell(keywordmin_info, which no longer raises with the shuffle). Untuned units that passed by chance in 2-minute recordings (p = 0.01–0.04, 0.04–0.17 bits/spike beyond their bias) are no longer called cells. Units with too few spikes for a bias estimate are refused with a warning; the significance functions still return their p-values and their docstrings say a p-value alone is not a verdict. - A shuffle test whose p-value cannot fall below
alphabecause too few null scores are finite (1 / (1 + n_finite) >= alpha) is "undetermined", not "unclassified". p-values are(1 + k) / (1 + n)over thenfinite null scores, as the docs now state, andsummary_table()reports<statistic>_n_finite_null. - The 0.5 and 0.3 bits/spike cutoffs are described everywhere as a common screening convention; no source derives or calibrates them.
Breaking API changes:
count_place_cells(rates, *, min_info=None)takes theSpatialRatesResultand countsrates.classify(min_info=...), so it applies the bias correction. Passing an information array raises.is_place_cellnames its information screencriterion="threshold", like every other cell family (was"spatial_info").place_cell_significancelosesn_jobs.
Added:
spatial_cell_type_significance(env, spike_times, times, positions)circularly shifts each unit's spikes and recomputes spatial information, grid score and border score from the same shifted trains, returning aCellTypeSignificanceResultwith observed and null scores, p-values and asummary_table()of null percentiles and per-test labels. On 100 untuned simulated units, 6%, 1% and 3% had p < 0.05 for information, grid and border score; the fixed 0.5 border cutoff flagged 46%. Scores that cannot be computed get NaN p-values and a recorded reason.spatial_information_bias()on spatial, view and object-vector rate results, and aspatial_information_biascolumn afterspatial_informationin their summary tables, so a unit that fails a screen despite high information shows why.summary_table()of spatial results adds nullablepasses_spatial_information,passes_grid_scoreandpasses_border_scorecolumns.CellTypeSignificanceResult.label_cell_types()andsummary_table()takecorrection="holm"(Holm–Bonferroni across each unit's applicable tests: the chance that an untuned unit gets any label stays atalpha) orcorrection="fdr_bh"(Benjamini–Hochberg across every unit and test: the expected fraction of false labels in the population stays atalpha); the defaultNonetests each statistic atalpha. On 345 untuned simulated units 6.7%, 2.3% and 1.0% got some label with no correction, Holm and BH; all 30 place and 15 grid cells kept their labels. With a correction the table adds<statistic>_p_adjustedcolumns, and a warning says how many shuffles are needed when p-values at the shuffle floor cannot pass it (with 200 shuffles BH labeled no unit at all).
Place fields¶
Results that change:
detect_place_fieldsstops searching at a peak-rate floor. Each field was cut at 0.2 of its own peak and the search continued below ever smaller peaks, so a 20 Hz field plus one isolated 5 Hz bin gave 22 fields; it now gives 1. A candidate must peak atmin_peak_rate(1 Hz, as in FMAToolbox MapStats and opexebo), and a candidate whose peak bin rises toward a neighbor is the flank of an assigned field.min_secondary_peak_fraction(default 0.0) keeps neurocode's 60% rule available; at 0.6 it missed every secondary field at 20–50% of the main peak.- The smallest field follows the map's smoothing instead of a fixed 9 bins (45 cm on a track with 5 cm bins, which rejected every realistic field): a field must be larger than one noisy bin becomes after the smoothing, about 18 cm on a track or 253 cm² in an open field at a 5 cm bandwidth.
- Coalescent fields split by FMAToolbox's MapStats rule, so subfields are
sized like single fields. Untuned 1 Hz track maps give one whole-track
region instead of 2–4 fragments; the docs state that the detector segments
maps and point to
place_cell_significancefor a verdict. - Coverage counts only observed bins.
spatial_coverage_single_cellandpopulation_coveragedivided by every bin, so a cell firing over all of the visited half of an arena had coverage 0.5. Region coverage of a silent map or of a region with no bins, and undefined population ratios, are NaN. field_shape_metricsreports orientation as an axial angle in [−π/2, π/2) (it could read −3.1), andareais physical area, not a bin count.compute_field_emdcompares only bins finite in both maps, without the warning that fired on every default rate map. Geodesic mass moves only along paths; maps with different mass in disconnected parts giveinfinstead of jumping walls. The value is in the environment's units.field_shift_distance(metric="geodesic")raisesIncompatibleEnvironmentErrorunless both environments have the same bins and connectivity, instead of silently returning a Euclidean distance.DirectionalPlaceFields.directionality_indexis the signed index of Navratilova et al. (2012), (Σr_a − Σr_b) / (Σr_a + Σr_b) over bins observed in both directions. It averaged |a − b| / (a + b) per bin, so noisy out-of-field bins dominated: identical bidirectional maps scored 0.30–0.35 (now −0.03 to 0.05) and an outbound-only cell 0.54–0.70 (now 0.90–0.91).compute_directional_place_fieldskeys results by label value in natural order (laps 1, 2, 10, not "1", "10", "2"), and every label that occurs gets an entry; a label with no analyzable time has an all-NaN map.- The docs give the effect of not gating by speed (rest firing raised
information and added fields at track ends) and how to choose
min_speed, and saygaussian_kdeleaks across thin walls (21–60% of the peak in a simulated maze); usediffusion_kdeorbinnedin walled mazes.
Breaking API changes:
detect_place_fieldsandpopulation_coveragetake a required keywordbandwidth(the map's smoothing; 0 for an unsmoothed map) and losemin_size.has_place_fieldpasses the map's own bandwidth.PopulationCoverageResultstores onlyplace_fields,is_observedandbin_centers; counts, bin sets andcoverage_fractionare properties.PlaceFieldsResult.n_excludedis derived fromexcluded_reason.DirectionalPlaceFieldsloses itslabelsargument (labels come from thefiring_rateskeys) and checks its maps against the environment when built.
Phase precession¶
Results that change:
compute_phase_precessionrescales positions to [0, 1] overposition_range(default: the spike extent), andslope_boundsare radians across that range (default ±2π: one theta cycle per field). The old bounds were radians per raw position unit, up to 40 cycles across a 40 cm field, and noisy spikes aliased onto steep slopes. Slope recovery on simulated cells rises from 38–75% to 80–95%.has_phase_precessiondecides on the shuffle p-value alone. Themin_correlationcutoff meant a different error rate at every spike count and rejected a quarter of weakly precessing cells with p < 0.05.- A slope at a
slope_boundsedge (two fields fit as one, or both running directions pooled) warns and is flagged byslope_at_bound.has_phase_precessionwarns only for a significant fit. interpretation()names precession or recession only for a significant fit and prints the correlation and fit quality as numbers, not strong/weak bands.has_phase_precessionraises on configuration errors (a badn_shuffles,slope_boundsorposition_range) instead of returning False; only too few spikes or constant positions return False. An unknownangle_unitraises.- The docs say positions must increase along the direction of travel (one
field and one running direction at a time), state where
theta_phaseputs phase 0 (the theta peak; π is the trough), and add a linear-track recipe.
Breaking API changes:
PhasePrecessionResultis frozen;pvalis renamedp_value;slope_unitsis removed;slopeis in radians per position unit,normalized_slopeis radians acrossposition_range, andoffsetis the phase atposition_range[0]. It gains a requiredslope_at_boundfield.has_phase_precessionlosesmin_correlationand gainsslope_bounds,position_rangeandmin_spikes.
Object-vector, view and boundary-vector cells¶
Results that change:
- Object-vector maps cover every distance inside the environment by default
(the bounding-box diagonal for Euclidean distance, the farthest reachable
bin for geodesic distance), following Høydal et al. (2019). The 0–50 cm
default cut off a cell tuned 60 cm from an object in a 150 cm box, which
reported a preferred distance of 2.5 cm; it now reports 57.5 cm.
analysis_settingsrecordstime_beyond_distance_rangeandfraction_of_time_in_distance_range, and a peak in the outermost ring of a range that cut time off warns. object_vector_scorescores the distance and direction profiles. Distance tuning is the normalized sparsity of the per-ring mean rate (0 when flat, 1 when all firing is in one ring), and both profiles use only rings sampled in every direction, so unvisited bins are not read as silence. A map tuned to direction alone scored 0.943 and now scores 0; simulated object-vector cells score 0.41 against 0.02 for ring cells and 0.00 for untuned cells. The score is NaN with no complete ring or a silent map.- Object-vector and egocentric maps drop samples outside the environment, as place maps do; a sample tracked 1000 cm outside counted as "far from the object".
- Object-vector maps and their shuffle test warn when some directions to the object were never sampled (an object outside the arena or in another frame), which skewed the preferred direction and the score silently.
preferred_distance()andpreferred_direction()of object-vector results are NaN for a unit with no rate instead of raising.- Object-vector, egocentric, boundary-vector and view maps refuse an environment that is not 2-D with a message naming its dimension, and object-vector maps refuse a polar environment.
- Boundary-vector maps keep the 0–50 cm default range of Hinman et al.
(2019) on purpose: an arena-wide range ties firing at one wall to the
opposite wall and put 45% of simulated preferred directions more than 45°
off. They record
time_beyond_distance_rangeandfraction_of_time_in_distance_range(ray time). - View maps cast the gaze to the first wall by default
(
gaze_model="ray_cast"). Fixed-distance gaze passed through walls: in a U-maze all 100 s of view occupancy landed in the arm behind the divider.gaze_model="fixed_distance"now stops at the first wall.ops.compute_viewed_locationalso defaults tomethod="ray_cast". - The docs say that head-direction cells carry view information (the heading fixes which wall is in view) and that a place field passes the object-vector screen with one fixed object, and name the controls: view fields stable across arena halves, and vector maps that correlate across object positions.
Breaking API changes:
- Object-vector, egocentric and boundary-vector maps take
distance_bin_size(default 5 cm inenv.units) anddirection_bin_size(radians, default π/6) instead ofn_distance_binsandn_direction_bins, anddistance_rangedefaults toNone. Defaults in cm need known length units: withenv=Nonepassdistance_range, and withoutenv.unitspassdistance_bin_size(and, for kernel methods,bandwidth, default 5 cm) in the positions' units. object_vector_scorelosesmax_distance_selectivityand takes theObjectVectorRateResultor its(n_distance_bins, n_direction_bins)map; a 1-D array raises with the reshape to use.- Boundary-vector results replace
spatial_information()withdistance_information(): Skaggs information about wall distance along the peak's direction. The whole-map value was not a Skaggs information, since a sample's time goes to every ray. - With
gaze_model="fixed_distance"(andcompute_viewed_location(method= "fixed_distance"))view_distanceis required; it is refused with"ray_cast". The 10-unit default had no source. - Polar results read
distance_range,n_distance_binsandn_direction_binsfromenv; they are no longer constructor fields. Object-vector and boundary-vector results gainmethodandbandwidth.
Rate maps and result classes¶
Results that change:
- Kernel rate maps (
diffusion_kde,gaussian_kde,binned; both backends) are NaN outsidespatial_support(occupied bins at or abovemin_occupancy). Smoothed rates in never-visited bins are extrapolations: with the animal confined to half an arena,peak_location()fell in unvisited space for 54/100 units; now 0/100. Smoothed directional tuning curves mask unvisited headings the same way. peak_location()andpeak_firing_rate()use only the occupancy support. A map without a positive rate has a NaN peak location (it was the first bin's center).- With
time_allocation="start"each spike counts in its interval's start bin, where that interval's time goes. Spikes at their interpolated position disagreed with the occupancy along the direction of travel: an untuned cell on a track had a rate CV of 0.229 around its true rate, now 0.036. min_occupancymust be finite seconds ≥ 0; NaN masked every bin without a warning and negative values skipped masking. Every rate result has a typedmin_occupancyfield that definesspatial_support.rate_map_coherencereturns the Fisher z (arctanh r) of Muller & Kubie (1989) and BNT, not r, and losesmethod(the Spearman option has no counterpart in the cited method).- Every rate result checks its arrays, estimator and bandwidth when built (a directional result with 10 rates, 7 occupancy values and 3 bin centers was accepted).
- Each metric has one name across families: the
summary_table()columnpeak_rateispeak_firing_rate,spatial_infoandview_spatial_infoarespatial_information, andViewRateResult.view_spatial_information()isspatial_information(). unit_tableis copied on every read, so editing it cannot misalign units, and every population family keeps theunit_tableofSpikeTrainsinput and records whether its unit labels were generated.
Added:
- Every rate result counts each unit's spikes outside the analyzed intervals
(before or after tracking, in gaps, outside epochs or
spike_window) asn_spikes_outside_intervals, also asummary_table()column. - Every family records
method(andbandwidth) insummary(),to_dataframe()andsummary_table().attrs. - NWB spatial-rate tables (schema 2.5) store
min_occupancyandn_spikes_outside_intervals; files withoutmin_occupancyare refused, since their support threshold is unknown.
Walls, transparent walls and drop edges¶
Results that change:
- A physical boundary has a kind that says what it blocks:
"wall"blocks movement and gaze,"transparent_wall"and"drop"(the edge of an elevated track or platform) block movement only. Climbing a wall or leaning over a drop is not modeled. Gaze (ray-cast viewed locations, viewsheds, line of sight, view maps,SpatialViewCellModel) stops only at walls; a ray that leaves the floor views nothing (NaN). Border scores, boundary-vector maps, boundary distances,BorderCellModelandBoundaryVectorCellModeluse every kind, so elevated mazes are analyzed instead of refused: a border cell on an elevated square platform scores exactly as in the walled box, as boundary-vector cells respond to drops (Lever et al., 2009) and most border cells keep firing when the walls are removed (van Wijngaarden et al., 2020). Inferred domains (from_samples, masks) have no physical outline, and wall-based analyses still refuse them with a message namingoutline="drop". wall_fit="split"keeps a bin for every position in the arena. A grid drops the cells whose centers lie outside a slanted or curved outline and gives a position in the arena part of such a cell to the nearest neighboring bin; the split cells left that arena out, so those positions had no bin (1.8% of the area of a non-convex arena with slanted edges on 3 cm bins, about 2,500 of 140,000 uniform points). The split cells now take that arena, so every position falls in a piece of the bin the grid gives it and the cells' areas add up to the arena's. Bins along such an outline grow and their centers move toward the outline.Environment.clipkeeps the same arena: a clip that reaches a slanted or curved outline lost the positions a grid gives to a bin from a dropped neighboring cell (2,179 of 105,942 uniform points in a box clip of the same arena). Every position inside the clip polygon that the parent gives a bin now falls in a piece of that bin, and the clipped cells' areas add up to the clipped arena's.bin_mapping_frompaired 30 of those bins with their parent bin although the clipped bin had lost positions; a bin that takes such arena is a different cell, so it is now reported unmapped (its values are not carried) instead.
Breaking API changes:
physical_footprint_knownis removed from every arena.OpenArena(polygon, *, outline="wall", walls=..., wall_kinds="wall")takes the outline's kind (None for a non-physical outline) and one kind per named wall (a single kind, a mapping by wall name, or one per segment); a named wall lying on the outline overrides that stretch of it.MaskArena(outline=)replaces its flag, and arenas exposeoutlineandwall_kinds.neurospatial.arena.BoundaryKindnames the kinds. The geometry helpersboundary_geometry,physical_walls,boundary_viewandphysical_ray_hitstake a requiredblocking="movement" | "gaze". The arena codec (version 3) andSTATE_FORMAT(7) record the kinds and refuse older records; save them again from rebuilt environments. Theelevated_arenamaze helper is removed.
Simulation and mazes¶
Results that change:
simulate_trajectory_ouvaries speed (gamma-distributed with an OU time course) instead of holding it atspeed_mean, mirrors steps off walls instead of reversing them, and never crosses internal walls (53 divider crossings in 15 minutes before). Ten minutes at 30 Hz take 0.07 s instead of 1.1 s.SpatialViewCellModelgets the viewed location the way the view analysis does, by ray casting to the first wall by default; simulated gaze passed through walls in 24% of samples in a box and 83% in the Hampton Court maze.tmaze_alternation_sessionruns to both arms (the right arm had no bins), and uniform field coverage on a track spaces field centers along the track.- Each maze declares its edges as its published apparatus has them. Drop
edges: linear track, radial arm, Barnes, honeycomb, crossword, cheeseboard,
W and plus maze. Opaque walls: hamlet, Hampton Court, repeated T and Y,
rat HexMaze, small hex, square maze, watermaze, and the T and Y mazes
(Wijnen et al., 2024 describe walls; they were elevated before). Sungod
has an opaque outline with 14 transparent stretches between its arms
(
arm_k_wall_facing_arm_j), so simulated and analyzed gaze crosses between arms: 52% of random gaze rays now land farther away (median 4.1 → 41.6 cm). Where a source does not say, the docstring states it and the maze uses the common open build. - Simulated cells default to published values, converted from cm to the
environment's units. Place cells: width 11.1 cm and peak 12.4 Hz from the
median CA1 open-field field in Mizuseki et al. (2012); on the linear-track
and T-maze sessions 17.5 cm and 14.5 Hz from the same paper's track data.
Head direction: peak 35 Hz and concentration 9.1 (a 22.5° half-width) from
Taube, Muller & Ranck (1990). Grid spacing 41 cm, the dorsal-most cells of
Hafting et al. (2005). Border-cell distance tolerance 9.8 cm and
object-vector distance 21.2 cm from Høydal et al. (2019), and
object-vector cells fire at the same vector from every object
(
object_selectivity="any", as Høydal et al. found with 2–6 objects). Values no source supports (baseline rates, the remaining peak rates) are marked illustrative in each docstring. The place width no longer scales withbin_size, so simulated ground truth does not change with the analysis binning; an environment without length units needs an explicitwidth. - Place fields stay Gaussian beyond five widths (the rate sat on a 7.5e-05 Hz floor there), and object-vector cells with very sharp direction tuning return their peak rate at the preferred direction instead of NaN.
validate_simulationcompares each cell's center error with 1.5 times its true field width and requires a field correlation of 0.9 (was a 2-bin center error and 0.5). Calibrated on 180 s and 600 s open-field sessions with 6–17.5 cm fields: 94% of correctly simulated cells pass, 96% of cells whose ground-truth center is moved by one width fail and every cell whose ground-truth width is tripled fails. The old default failed 71% of correct cells and passed 93% of moved ones. Short sessions with small fields (180 s, 6 cm) pass only 69%; the docstring gives the numbers. The per-cell limits are returned asmax_center_errors.from_polygongives a grid cell centered exactly on the outline to its neighboring bin. Positions there were dropped from occupancy, and wall pieces and ray-cast hits there had no bin, so border scores and view maps raised on the T, W and crossword mazes and on curved or slanted arenas. The small hex maze no longer has zero-width slits between wall hexes.
Breaking API changes:
BoundaryCellModelis replaced byBoundaryVectorCellModel(Barry et al., 2006: every visible piece of wall contributes) andBorderCellModel(firing by distance to the nearest wall), measured against the declared walls. Both default their tuning (see above);BoundaryVectorCellModeldraws a random preferred direction from a newseed.boundary_cell_sessionbecomesborder_cell_session(n_boundary_cellsbecomesn_border_cells), andsimulate_session'scell_type="boundary"becomes"border".grid_cell_session(grid_spacing=)defaults to 41.0 (was 50.0), andlinear_track_sessionpasses extra keyword arguments to its place cells.
Operations review (neurospatial.ops)¶
Results that change:
- A NaN bandwidth, wavelet scale,
max_distance_factor, scale-bar length,max_distance, distancecutofforneighbors_withinradius is refused. A sign check alone (value <= 0) let NaN through, so a NaN bandwidth made every basis function NaN and a NaNmax_distancemarked no point outside. Infinite bandwidths, scales, factors and lengths are refused too; an infinite cutoff, radius ormax_distancestill means no limit. env.nearest_binbreaks ties with the same rule asenv.bin_aton nearest-center layouts (tracks): centers equally near within rounding at the coordinates' magnitude, lowest bin index. The former fixed 1e-10 tolerance missed ties far from the origin, so a point midway between two bins of a track at 3.7 km, or of a grid in millimeters at 1 km, could take the higher bin.resample_fieldreads the source field withenv.interpolate(mode="nearest")instead of its own copy of that lookup, so it refuses a field with an infinite value, for both methods, asinterpolatedoes. Finite fields resample to identical values.- New
Environment.bin_spacing: the typical distance between neighboring bins, in the environment's units, computed once over every bin and cached. On a linearized track it is the median bin length along the track; on an egocentric polar environment it is measured between places in the animal's plane. Every default scaled by "a bin spacing" now reads it:nearest_bin(max_distance_factor=),map_probabilities' default limit, the heat-kernel wavelet basis, simulated place-field widths andvalidate_simulation'smax_center_error. On a polar environmentvalidate_simulation, the track-animation warning and fastplotlib point sizes measured (distance, angle) rows, so the angle bin in radians (0.39 for a pi/8 bin) stood in for a length; on a trackmax_distance_factorand the wavelet basis used straight-line neighbors, which can lie on another arm. stats.wrap_anglereturns its documented (-π, π]: a half-turn either way is now +π, not -π. Egocentric bearings use the same function, so the value just above π, which rounded to -π, now also stays at +π.- Viewsheds are exact.
compute_viewshed,compute_view_field,compute_viewshed_trajectoryandvisibility_occupancysampled each of 360 rays at 100 points, so bins smaller than the sample spacing were skipped: in an open 200 cm box with 1 cm bins, 22–66% of the bins counted as visible, andvisibility_occupancygave 0 s to most of the box. A bin is now visible when the direction to its center is in the field of view and the straight line to its center crosses no wall.n_raysis removed.visibility_occupancyover 1000 frames runs in 0.6 s instead of 9–39 s. ViewshedResult.occlusion_mapcounted ray samples per bin (high near the observer), not visibility. It is replaced byvisibility_map: 1 visible, 0 hidden, NaN for a missing observation.visible_bins,n_visible_binsandvisibility_fractionderive from it, and the constructor checks that its fields agree.- Linear occupancy (
time_allocation="linear") on 1-D grids and on grids of three or more dimensions now counts an interval that crosses the edge of the environment like 2-D layouts do. An interval entering from outside added nothing; it now adds the time its path spends inside. Withreturn_seconds=False, an interval leaving the environment added a whole count; it now adds the inside fraction. 2-D layouts are unchanged. resample_fieldfilled destination bins up to 10 source bins beyond the source with the source's edge value. A destination bin outside the source environment is now NaN. A source track built withouttrack_widthis refused, since its extent is unknown.resample_field(method="diffuse")treats missing (NaN) source values the wayenv.smooth(mode="average")does. It averaged over the observed bins without the support floor, so far from every observed bin it divided truncation noise by noise: one observation of 5 on a 100-bin line gave 19 negative bins down to -53,000. Those bins are now NaN.graph_convolve(normalize=False)read NaN as 0 (an all-NaN field became zeros). A bin whose kernel puts weight on a NaN bin is now NaN.spatial_basisin 3-D sized its bandwidths by the square root of the extents' product, about 11 times too broad for a 120 cm cube; it now uses their geometric mean.chebyshev_filter_basisweighted neighbors by edge length, so diagonal neighbors coupled more strongly than face neighbors and bins touching only at a corner coupled most. It now uses the same finite-volume operator as smoothing; values change and layouts without cell faces are refused.estimate_transformreturned a singular transform for collinear landmarks and an identity rotation for coincident ones; both now raise, as do non-finite landmarks (naming the row). The similarity scale is the least-squares estimate, which changes fits on noisy landmarks slightly.- The distance functions indexed their matrices by graph insertion order but
read them by node id, so a graph built out of order gave wrong distances.
They now refuse a graph whose nodes are not
0 .. n_bins - 1, and refuse aweightattribute that some edge lacks (a misspelt name counted hops). - The
geodesic_rbf_basisdesign-matrix recipe usedenv.bin_sequence, which drops repeated bins, so rows no longer matched time bins. The examples now useenv.bin_at(positions)and drop outside samples.
Breaking API changes:
Affine2Dis removed; the 2-D factories returnAffineND.AffineNDrefuses a matrix that is not finite, homogeneous and invertible, stores it read-only, compares with==by value, and refuses points whose last axis does not match (an(x, y, likelihood)array was silently reshaped).VideoCalibrationis frozen, computes its inverse when built, and requires a positive integer frame size.convert_to_cmandconvert_to_pixelsrequirecm_per_px(it defaulted to 1, labelling pixels as cm).from_rotation_matrixrefuses matrices that are not rotations.map_probabilitiestakes the source alignment as oneAffineND,source_transform=, instead ofsource_scale,source_rotation_matrixandsource_translation_vector, soestimate_transform(source_landmarks, target_landmarks)passes straight in.get_2d_rotation_matrixandapply_similarity_transformare removed; the newrotate_2d(angle, *, center=None)builds a rotation in radians, aboutcenterwhen given, so turning an arena about its center no longer needsc - s * R @ c.map_probabilitiesrefuses to map when more thanmax_outside_fraction(new, default 1e-6) of the probability lies outside the target: on source bins whose centers are in no target bin and farther thanmax_distance(defaulttarget_env.bin_spacing) from every target bin center. One rule covers both limits, so an explicitmax_distancealso keeps centers that lie inside a coarser target bin. A smaller share, such as a posterior's far tail, goes to its nearest bin; raisemax_outside_fractionto accept a known mismatch, andmax_distance=math.infkeeps the old nearest-bin assignment. It also refuses non-finite probabilities andeps <= 0.ProbabilityMappingParamsis removed.compute_diffusion_kernelsandgeodesic_rbf_basistakebandwidth=instead ofsigma=.geodesic_distance_matrixlosesn_states.distance_field,neighbors_within(both metrics) and the basis functions refuse-1, out-of-range and non-integer bins instead of dropping them, truncating them or reading-1as the last bin.FieldOfViewlosesblind_spot_behind(the presets already encode it throughleft_angle/right_angle) and refuses non-finite or crossed bounds.compute_viewed_locationdefaults tomax_distance=None(reach every wall; the 100-unit default returned NaN in larger arenas) and losesmethod="boundary", which ran the same code as"ray_cast"; the view encoders andSpatialViewCellModellosegaze_model="boundary".EgocentricFrameis removed; useallocentric_to_egocentricandegocentric_to_allocentric. Geodesiccompute_egocentric_distancerefuses a target outside every bin instead of returning a NaN column, andallocentric_to_egocentricrefuses apointstime axis that differs frompositions.TieBreakStrategyis a stringLiteral; the enum is removed.normalize_fieldrefuses arrays that are not 1-D (a(n_time, n_bins)array was normalized as one distribution) and loses its unusedeps.apply_kernelrefuses NaN fields, pointing toenv.smooth, and refusesbin_sizeswithmode="forward".select_basis_centerslosesmethod="grid", which always raised.- Visibility, egocentric and alignment functions check that positions, headings and points have matching lengths and shapes.
Spike, interval and result inputs¶
- Spike times must be finite everywhere. Decoding (
bin_spikes_in_time,decode_session,BayesianDecoder.predict) silently dropped NaN spike times, biasing counts low, while encoding and peri-event analyses refused them. Now every entry point, includingSpikeTrains, refuses NaN or infinite spike times and names the unit by its label. - A
(n_spikes, 1)column of spike times (the defaultscipy.io.loadmatshape) was read as n_spikes units of one spike each; it now raises and suggestsspike_times[:, 0]. A 2-D array whose columns, not rows, are the units now raises and suggestsspike_times.T, and NaN before a spike in a row is refused instead of being dropped as padding. -
A pynapple
Tsor pandasSeriesof spike times is one unit. Before, it was taken for a group of units, with one unit per spike or a cryptic error. A dict of unit id to spike train is a labelled group, and a DataFrame is refused with how to convert it. -
max_gapis checked wherever it is used. Encoding acceptedmax_gap=-1, NaN or infinity, excluded every interval and recorded the bad value inanalysis_settings; every analysis now refuses amax_gapthat is not a positive finite number of seconds (orNone), including strings and booleans. A speed gate (min_speed) withoutspeednow raises instead of being skipped. - With
max_gapequal to the sampling interval, float rounding of the timestamps makes a quarter to a half of the intervals count as gaps. The mask is unchanged, but the warning now names this case and suggests amax_gapabove the sampling interval, instead of saying (at 1 kHz) thattimesare probably not in seconds or (at 30 Hz) saying nothing. - Decoding warns when whole
dtbins leave more than 10% of the observed time undecoded. Each recording run keeps only whole bins, so with frequent tracking dropouts a largedtsilently dropped the end of every run and its spikes (27% of the observed time in one example atdt=0.5). The warning namesdtandmax_gap;warn_on_drop=Falsesilences it. - Unit labels are checked the same way everywhere.
unit_idsrefuses a single string (which split"ab"into unitsaandb), a scalar, and missing (Noneor NaN) labels, whichsel(unit_id=...)could never find.unit_idsthat equal a spike group's own labels only in value (1.0for1,Truefor1) now raise, since results key shuffle seeds by the label.RateComparisonResultrefuses duplicateunit_ids, and aunit_tablethat is not a DataFrame is named as such instead of reported as a length mismatch. detect_region_visits,DecodingResult.error_againstand the true-position overlay ofDecodingResult.plotnow give the shared excluded-interval warning. Withtimesin milliseconds, visits split at every sample and the decoding error was all NaN, with no warning.- Result and record classes that hold arrays compare by identity. Comparing
two equal-valued copies with
==raised "truth value of an array is ambiguous"; it now returnsFalse, and compare their arrays to test values. - Results without a
to_dataframe()orplot()no longer inherit a stub that raisesNotImplementedError; the method is absent (AttributeError), as the API contract says for results such asResampledPathResultandContinuousEventResult. A result's repr names an error raised by itssummary()instead of showingClassName(...), and rounds NumPyfloat32metrics like other floats. - A
missing_labelthat matches no label but differs from one only in case or spaces ("Rest"for"rest") warns, since that label would otherwise be kept as a real condition or direction. - Every error message built from what/why/fix now labels its reason
Why:; 80+ messages printed the reason as a bare sentence.
Layout review¶
- Track arms that meet at a junction are now all adjacent to one another.
Before, the arm ends at a node were chained in
edge_orderorder, so on T, Y, plus and W mazes one arm reached another only through a third: geodesic distance, smoothing and decoder transitions across the junction were too long for some arm pairs, and which pairs depended onedge_order.env.edge_pairsgains the missing junction pairs. For diffusion, the faces across a junction share the node's conductance (the node eliminated from the star of arm ends), so smoothing across a junction of three or more arms is not faster than along a track. - A track edge between two segments now has the along-track length through
the junction node as its
distance, not the straight chord between the bin centers that cuts the corner. Graph distances across a bend or junction now match the track faces used for smoothing; at a 90° bend with 5 cm bins the edge grows from 3.54 to 5.0 cm. close_gaps=Trueno longer drops the visited bins on the grid's outer ring. The closing step treated space past the grid as empty and eroded the border, so on an open field with 10 cm bins 40 of 121 bins and 19% of samples were lost along the walls. Closing now only adds bins.- Every layout refuses a build that leaves no active bins, with a message
that says why and how to fix it. Before,
from_polygonon a polygon that holds no cell center andfrom_grid_maskwith an all-False mask built an environment with zero bins, on whichoccupancyreturned an empty array, and hexagonal layouts failed with a NumPy reduction error. - Triangular meshes over a polygon with holes now tile the arena exactly.
Only the outer outline was sampled, so triangles spanned each hole's edge:
in a 10 x 10 arena with a 4 x 4 hole, 6% of in-arena positions (a ring
0.5 wide around the hole) got bin -1 and their samples were dropped.
Every ring of the polygon is now a chain of triangle sides, so a hole or
notch narrower than
point_spacingis followed too: a 0.2-wide hole in a 10 x 10 square was bridged by triangles that covered 97 of its 98.8 area. Meshes are triangulated near the origin (the polygon's lower-left bound), so they no longer depend on where the polygon lies; a mesh away from the origin may pick the other, equally valid diagonal in lattice cells. - Bins that meet only outside the arena's outline are no longer neighbors,
in every environment with a known outline (
from_polygon,from_samples(arena=...),rebin,from_bins). Their centers can both be inside while the corner or side they share lies in a slit or past a concave corner, such as the inner corner of a Y-maze junction, so geodesic distance and smoothing passed through the wall.rebinno longer warns about such pairs across a barrier in the outline: they are now closed. - Grid cells are exactly
bin_sizewide. Before, the bin count was rounded up and the cells were shrunk to fit the extent, sobin_size=3on 0-100 cm gave 2.94 cm cells,bin_size=7gave 6.68 x 6.69 cm cells that were not square, andlayout_parameters["bin_size"]named a size no cell had;rebinalready made exact cells. Now an extent that is not a whole number of cells is widened equally past both ends, so every cell center stays inside it. This applies tofrom_samples,from_polygonandRegularGridLayout, with or without an arena: arena bounds that are a whole number of cells still put cell edges on the arena's edge. Rebinning by a whole factor of the bin size now nests the cells. A NaN or infinitedimension_rangesentry is refused instead of silently building one cell. A grid no longer depends on where its range lies: a 1-unit range at 1e6 withbin_size=0.1was read as a single point (a relativenp.isclosetolerance of about 10) and got 11 cells instead of 10. - Every layout checks its cell size (
bin_size,hexagon_width,point_spacing,pixel_size) the same way, with an[E1002]what/why/fix message. A NumPy scalar such asnp.float32(2.0)now works; before,from_samplesraisedTypeError: object of type 'numpy.float32' has no len()andnp.int64(2)gave "length must be 2, got 2.0". NaN or infinite hexagon widths, mesh spacings and pixel sizes, which failed later with unrelated NumPy or locator errors, are refused up front, and a string or boolean size is aTypeErrorinstead of being read as a number.neurospatial.layout.validationnow holds this one check,validate_cell_size; its unusedvalidate_bin_sizeandvalidate_dimension_rangesare removed. - Hexagonal field plots draw unvisited (NaN) bins in
nan_color, as grid, mesh and polygon plots do. They were drawn transparent, so with the default light gray they looked like space outside the arena. GraphLayout.linear_point_to_bin_indmaps the exact end of the track (a reward well at the last node) to the last bin instead of -1, asbin_atdoes. A position counts as on a bin edge only to within the edge's float rounding: the gap-start check used a tolerance relative to the linear coordinate, so 1e6 along a track a position up to 10 units into a gap was given the bin before it.- A built layout engine owns read-only copies of its arrays (
bin_centers,active_mask,grid_edges, and a track's linear centers).MaskedGridLayoutkept the caller's mask itself, so editing it afterbuild()left the engine with more bins than grid cells andfrom_layoutrefused it with an unexplained mismatch. Abuild()that raises now leaves the engine unbuilt (binning()raisesLayoutNotBuiltError) instead of mixing the old bins with the new grid. - A negative or fractional
bin_count_thresholdis refused with a message naming the value. Grid and hexagonal layouts used to accept it silently, as if it were 0. - Track plots (
env.plot()on a track,GraphLayout.plot) show their title and honorfigsize. They silently ignored every keyword argument, including thenode_kwargs,bin_edge_kwargsandshow_bin_edgesoptionsGraphLayout.plotdocumented; an option a track plot does not take is now aTypeError. Every layout engine'splot()draws the same picture asEnvironment.plot, through one shared method. create_layoutis typed to return the built-in engine it builds, so type checkers accept the documented members (bin_centers,layout_type,connectivity,bin_sizes(),plot()) on its result. Before, its return type was the bareLayoutEngineprotocol, which has onlybuild()andbinning().create_layoutwith a keyword its engine does not take raisesTypeError, as Python does for an unexpected keyword, with a message that lists the options the engine'sbuild()takes. It raised a bareValueErrornaming only the unexpected keyword.get_layout_parametersmarks each build option'required': True or False. A required option and one that defaults to None both reported'default': None, so the two could not be told apart.- A built-in layout engine's
layout_typeis typedClassVar[str], so type checkers flaglayout.layout_type = ...on an instance. Assigning it relabeled that one engine, and the environment built from it reported the wrong layout and rebuild hints; a custom label belongs on a subclass. str(LayoutType.REGULAR_GRID)and f-strings give the label ('RegularGrid') on every supported Python version. On Python 3.11 and later they gave'LayoutType.REGULAR_GRID', so messages and file names built from aLayoutTypediffered from those built on 3.10.ScaleBarConfigrefuses invalid options when it is made, with a message that names the option and the allowed values: a zero, negative or non-finitelength, apositionorbox_styleoutside the listed choices, abackground_alphaoutside [0, 1] and a non-positivefont_size. Before, all of these were accepted; an unknown position was silently drawn at the lower right. Scale-bar labels print at most six significant digits, solength=0.1 + 0.2reads "0.3 cm", not "0.30000000000000004 cm". Its docstring now says which options napari ignores and that the fastplotlib backend cannot draw a scale bar; it claimed several options worked on all backends.- Calling an unbuilt layout engine raises
LayoutNotBuiltErrornaming the method that was called, such asTriangularMeshLayout.binningorHexagonalLayout.bin_sizes. Messages named private state (_full_delaunay_tri,hex_radius_, "hexagonal geometry") or an attribute the method read, andShapelyPolygonLayout.outline()raised a bareRuntimeError. ImageMaskLayout.buildrequirespixel_size, asEnvironment.from_pixel_maskalready did. Without it the layout silently used 1.0 per pixel, so positions in cm were binned as if each pixel were 1 cm wide, andlayout_parameters["pixel_size"]recorded None instead of the size used.- A reversed
(max, min)dimension range is refused, with the pair and axis named, bycreate_layout, every layout engine that takes ranges andget_n_bins, asEnvironment.from_samplesalready refused it. The layouts silently reordered it, so a swapped pair (often the wrong column or sign) built a grid without a word, andget_n_binsreturned a negative count.get_n_binsalso ignores positions rows with a NaN or infinite coordinate, and refuses a range that is not finite. - The large-grid memory warning points at your own call, not at a line inside
neurospatial, and no longer suggests
infer_active_bins=True, which cannot shrink the full grid the estimate counts. - Layout engines built directly (
create_layout,RegularGridLayout,HexagonalLayout) treat a position row with an infinite coordinate as a missing sample, asEnvironment.from_samplesdoes. Before, a regular grid refused it with a message about a non-finite range and a hexagonal layout raisedOverflowError. ShapelyPolygonLayoutrefuses an empty or flat polygon by name; it failed with a message about a(nan, nan)dimension range.- A layout engine's
build()called without a required option (for examplecreate_layout("RegularGrid", positions=...)withoutbin_size) names the engine and the missing option, with a fix, instead of Python's bare "missing a required argument". - A track
edge_orderentry whose two nodes no edge of the graph joins is refused with a message naming it; the build failed later with a bareKeyError. - A misspelled layout option, passed to
create_layoutor directly to an engine'sbuild(), is named in one message pointing toget_layout_parameters(). Before,create_layoutnamed it, while a directbuild()reported a missing required option instead of the misspelling. HexagonalLayoutrefuses a non-finitedimension_rangesend with the same message as the grid layouts; it raised "cannot convert float NaN to integer".- Building an environment from a triangular mesh is about twice as fast:
face normals are computed for all neighbor pairs at once (0.40 s to
0.02 s for a 64k-triangle disk, computed twice per build), with identical
results. Hexagonal
plot_fielddraws one collection instead of a patch per hexagon (about 5x faster at 13k hexagons; the collection is now aPolyCollection), and connections are drawn from the neighbor pairs. plot_field(nan_color=None)leaves NaN bins undrawn on grids and meshes too, as documented, instead of using a custom colormap's bad color; anan_colorwith its own alpha keeps it there as on hexagons and polygons.ImageMaskLayoutcell centers use the edge-midpoint rule of the other grid engines, which can move them by a few ULPs.- A track edge between two nodes at the same position is refused by name
(
Track edge 1-2 joins coincident nodes: ...) inEnvironment.maze,linear_track,from_graphandGraphLayout. Only an all-coincident track was refused clearly; one repeated position failed later with "segments must have positive representable lengths" or "TrackLinearization edges[0] must be finite and strictly increasing", and a maze first warned about an invalid divide. - A negative, infinite, NaN, boolean or text
edge_spacingis refused by the track layout before binning, with the gap count it needs. It was refused after binning byTrackLinearization, which the caller never named; a string raised a bareIndexErrorand an infinite gap warned first. Environment.plotdraws the arena outline with its holes, so an arena with an obstacle no longer looks solid, and draws it for every layout that has one: a rotated or clipped polygon environment drew no outline.- One check of grid edges, by the regular, polygon and masked grids. A grid
far from the origin is refused only when float rounding actually makes its
cell widths differ by more than 1 part in 10^4, with a fix to subtract an
origin offset. Exact edges, such as unit cells at 1e12, are accepted by
from_grid_maskandrebin(bin_size=...), which refused them. Cells of 0.3 at 1e12, whose widths are up to 4e-4 uneven, are refused bycreate_layoutandfrom_samplesas byfrom_grid_mask; they were built with wrong cell volumes. - Hexagonal environments recorded away from the origin keep their cells
after
apply_transformmoves them, for example to center the arena at (0, 0). The cells (used byplot_field, boundary bins, physical-border scores, clipping and combining) were refused with "Canonical hexagon centers disagree with published bin centers": a second center check scaled its rounding allowance by the new, small coordinates. The publication check, which carries rounding through each transform, is the only one now.
Spatial geometry review¶
- Walls that end on a slanted or curved arena outline now separate the bins
on either side. Before, the default
wall_fit="snap"left pairs across the wall connected wherever the outline cut the shared bin edge, so geodesic distance, connectivity and smoothing passed through the wall. Points beyond a sharp wall bend (under 90°) are now sided by the bend's bisecting normal, which closes the gaps V-shaped walls left.wall_fit="split"now works on slanted and curved outlines: bins that stick out past the outline are clipped to it.from_binsrefuses a wall that divides the part of a bin inside the arena, and snap refuses a wall that cuts off an outline corner with no bin center behind it. Every pair a wall closes, including pairs meeting at a corner, records that wall inclosed_by. - Rotating, reflecting or subsetting a grid no longer drops positions in the
partial cells along a curved or slanted wall; before, a mirrored circular
arena lost 3% of in-arena samples near its wall. A point on a face shared by
two bins now goes to the same bin before and after any affine map, for
grid, hexagon, mesh, polygon and track bins, and in combined environments
whatever order the components are listed in. A track position tied between
two legs goes to the first leg in
edge_order, inbin_atandto_linear. - Triangular-mesh positions exactly on the polygon outline now get a bin, instead of -1.
Environment.boundary_binsis computed from cell geometry (grid, hexagon, triangle and polygon cells, and each component of a combined environment, also after subsets and affine maps). It was empty or nearly empty on triangular meshes and wrong on point bins, soborder_scorewas NaN or wrong there. Area bins without cell geometry now raiseNotImplementedError; track chain ends are found for any length.- Nearest-bin lookup without a distance limit is now an explicit choice. A
NearestBinLocatorwithmax_distance=Noneon bins with no known domain refuses position lookup, also as a component of a combined environment; passmax_distance=in length units, ormath.infto accept any distance.max_distanceis validated. A failed KD-tree query now raises instead of warning and reporting every point as outside. One-dimensional tracks map positions past their ends to -1 and keep their ends after affine maps. Environment.linear_to_ndreturns NaN rows for linear positions before the start, past the end or inside a gap between segments, instead of clamping them onto track nodes.- Missing observations stay missing in visibility.
visible_cuesandcompute_view_fieldrefuse non-finite positions, headings or cue positions;compute_viewshedandcompute_viewshed_trajectorymark a missing frame's occlusion map andvisibility_fractionNaN instead of "saw nothing". - Distances between polar bins are straight-line chords, so Gaussian smoothing weights on egocentric polar environments now use physical distance. On an open (partial) angle window, bins on opposite sides of the cut are joined through the animal's position, not across the cut.
- Polygon regions on egocentric polar environments compare angles modulo a full turn, as positions and point regions do, so a wedge across the ±π seam is one polygon. Before, it silently covered only one side.
- Derived environments keep their extent and logical grid: subsets and saved
copies of combined environments keep
dimension_ranges, and translated, rotated or subset hexagon and mesh environments report their latticegrid_shapeandactive_mask. Rotated rectangular grids stay flat, since their source axes are not world-aligned. - An out-and-back track whose bins revisit the same places can be rotated.
A reflected
MaskArenawith a declared physical footprint keepswall_hit_bins(..., "physical")working. - Geometry records check their own invariants when built:
Binningrefuses pairs naming missing bins, inconsistent faces or face normals, and stores onegrid(RectilinearGrid,FramedGridor None) in place of therectilinearandgrid_framearguments, which remain read-only properties.RectilinearGrid,RectilinearLocator,TrackLinearization,HexLocator,HexagonShapes,TriangleShapesandGridIndexvalidate their arrays;EgocentricSurround(circular=True)requires a full turn, and saved polar environments must store seam pairs exactly when the angle wraps. Native face recipes build on their binning's own pairs (Binning.build_faces();KnownFaces.buildandMissingFaces.buildtakebinsandconnections), and a transformed locator's grid frame lives only on its binning. - Breaking record changes:
RectilinearLocator(edges, shape, active_mask)requires the mask and no longer takesflat_to_active;TransformedLocator(base, forward)stores the forward map (inverseis computed). Saved environments use Binning codec 6, rectilinear and transformed locator codec 2, nearest locator codec 2 and pickle state format 6; older records are refused, so rebuild them from their source data. - A physical wall closer to a bin clipped after a transform than that bin's rounding bound is refused whichever way the transform rounds. Before, the same wall was refused or silently counted inside the bin depending on the last bits of the map. Transformed bins, arenas and rotated grids now map points back through one shared inverse with one rounding bound.
- Coordinates too far from the origin to resolve the bins now always get the
"recenter or rescale coordinates" advice. A decoder's own
TypeErroris no longer reported as a corrupt file. Hexagon lookup treats infinite coordinates as missing and refuses a non-positive radius. Environment.combinevalidates each published component once; combining two 10,000-bin grids takes about half the time it did, and nested combinations less than a quarter.
Animation workflows¶
- Render uniform 3-D fastplotlib animation fields as native volumes, with
field_rendering="mip","iso","slice"and explicit"points"fallback. Preserve affine environment coordinates, fixed scales and missing voxels; thresholds use field units and slice planes use world XYZ coordinates. Keep volume thresholding and maximum selection independent of colormap saturation, so clipped colors cannot erase a surface or change its depth. Correct translated isosurface depth in the affected pygfx shader without changing other applications' materials. - Keep overlay marker diameters in logical screen pixels across napari, fastplotlib, HTML and Matplotlib exports, independent of coordinate units and zoom. This corrects oversized napari 3-D markers and inconsistent HTML radii.
- Prepare napari scene reads off the GUI thread with a bounded pending request, elapsed-time playback, held-row and overlapping-window caches, and persistent scientific-panel artists. Serialize shared reader access across native views, view preparation and scene exports; drain workers before closing readers.
- Snapshot sampled rows and decoded frames before retaining them or handing them to a renderer, so lazy readers that reuse scratch buffers cannot change already-sampled observations.
- Reduce redundant field copies and statistics scans, reuse prepared event clocks during rendering, and export notebook RGB frames without decoding their display PNGs. Share field-support, event-alignment and heading-color rules across consumers.
- Isolate native compatibility hooks, use napari's current canvas/scene APIs, and carry physical units on owned layers. Check fastplotlib's supported widget capabilities before allocation. Add a reproducible real-backend benchmark separating GUI playback responsiveness from synchronous GPU capture latency.
- Preserve NWB position reference frames in eager and lazy reads. Position and pose overlay adapters share validation that prevents keyword overrides from erasing or relabeling declared coordinate units and frames.
- Correct export timing roundoff against prepared overlay clocks as well as field clocks, retaining isolated observations beside recording gaps in scene exports and native-view capture.
- Preserve event-table dtypes, categorical levels, nested metadata and original IDs when taking independent snapshots. JSON export verifies that IDs survive pandas' table-format round trip before creating or replacing a file; composite IDs remain supported for in-memory inspection and CSV export.
- Validate every NWB pose bodypart's clock and spatial metadata before sharing a time axis. Pose overlays retain declared units and reference frames and reject mismatches with the environment without silently relabeling coordinates.
- Normalize custom overlay support during preparation so invalid observations remain missing in markers, trails and track panels. Partial bodypart mask dictionaries now follow the same rules during sampling and realignment.
- Share heading geometry across renderers: zero vectors, including interpolated opposite directions, no longer produce an arbitrary eastward arrow.
- Unit spellings are matched and converted through one table, so
"um","µm"and"micrometers"match each other in overlay, track and NWB metadata checks as they already did in centimeter-based defaults. - A
HeadDirectionOverlaywith more than onePositionOverlaynow raisesValueErrorin every backend. Its arrow was previously drawn at the arena center, because no single position identifies where it starts. Pass the one tracked point whose heading you measured. - Apply observation masks and gap rules to overlays that inherit
frame_times, including fastplotlib sampling between frames. Preserve NaNs when normalizing constant signals and share finite, bounded object-vector rate opacity across scene renderers without changing its scale for selected windows. - Close scene-owned views, playback and inspection controls before releasing video readers, while retaining caller ownership of borrowed resources. Native window closure also unregisters its scene view and runs close callbacks, allowing a reusable scene to release closed viewers.
- Correct the internal coordinate guide to distinguish prepared environment units, pixel centers and each backend's display transform.
- Preserve decoding recording windows through
Animation.from_decodingand expose half-openAnimationField(epochs=...)support. Unsupported exact samples and intervals remain missing in spatial and track views. - Preserve mixed raster unit identifiers, custom video frame mappings and frozen overlay adapters. Prepare adapters once and snapshot their internal data while retaining explicit video-reader ownership.
- Convert centimeter video calibrations into environment metric units, including cropped and downsampled video. Landmark calibration accepts environment units; calibrated video rejects unknown units and pixel units.
- Share scene sampling and scientific-panel preparation independently of Matplotlib canvases. Napari consumes the same prepared data directly. One-dimensional scene views draw positions, pose and object vectors on the spatial axis and reject headings and video before rendering.
- Preserve each field's native clock and declared gaps in fastplotlib track panels and exports, including when the scene uses a coarser output clock. Track projection now rejects incompatible units or coordinate frames.
- Apply the shared observation-support rules to trajectory-based event
locations. Events inside tracking gaps retain their timestamps with unknown
positions;
max_gap=Noneexplicitly permits sparse continuous tracking. - Freeze field metadata while retaining lazy, caller-owned values. Scene overlay conversions borrow prepared video readers, and failed preparation closes acquired readers deterministically.
- Use a typed viewer adapter and check scene capabilities before allocating a viewer, including dimensions, overlay combinations and requested options.
Animationand namedAnimationFieldstreams retain physical clocks, lazy sources and fixed color scales across notebook viewing and MP4/GIF export.Animation.from_decodingretains the result's environment and times.AnimationViewadds recording-time seeking, interval selection and export, with native viewers available throughnative. A view exports the whole recording through its last time untilselect_time(start, stop)selects a half-open window, even one spanning the whole recording.- Extend scenes to 3-D and napari, preserving independent field clocks and scales. Napari combines native layers with synchronized signal, raster, epoch and projected-track panels; fastplotlib now supports a separate colormap, range and label for each field.
AnimationView.export()captures the current native view, including camera edits and custom graphics, and restores time and playback after success or failure. Userender="scene"for the standard scene layout. Both paths use the same half-open export clock and publish output files only after encoding succeeds.- Move shared video reading out of animation so annotation can load frames without importing rendering modules. Extract styling and track geometry into lightweight shared helpers.
EventInspectorpreserves overlapping event IDs and analysis metadata during filtering, selection and annotation, and exports selected event clips and typed review tables. Scene exports include multiple fields, spike rasters, epochs, signals, object vectors and calibrated video.- Observed animation overlays now preserve missing NaN samples and do not
interpolate across gaps over 0.5 seconds or
break_beforeboundaries. Passmax_gap=Noneexplicitly for continuous observations sampled more sparsely. Native GPU views use the same interpolation and support rules. Position metadata and NWB units are validated against the environment; raster rate-result adapters align maps by unit identity. - Fix cropped/downsampled video calibration and napari physical-scale video placement, missing-value colors, grouped trace limits and duplicate labels. Capability checks name any overlay a view cannot draw before it opens.
- Every view draws overlays and panels from one preparation layer, so they
follow the same rules. Colorbars mark an end with a triangle when sampled
values lie beyond it, for computed, shared and explicit limits and for
projected track panels. Over a video below the field, opacity rises from
vminto the field's typical peak (scene and napari used to fade from 0, hiding negative values). Trails fade with each segment's age; track paths break where the position changes maze segment; skeletons use theiredge_colorandedge_width; body parts take their given color, then the skeleton's node color, then red; signal panels use rounded limits, so a near-zero floor reads as 0; events of one type draw in time order. In fastplotlib, time series draw every sample in the window (no 100,000-point cap) and a signal with no values draws an empty panel instead of being skipped. - Faster views and exports: the field along a track is one mesh (a 1-D track frame went from 196 ms to 17 ms), trails are vectorized (2.9 ms to 9 µs at a million frames), poses are sampled in one pass, napari prepares overlay layers on its worker thread, and fastplotlib reads each lazy field row once instead of 6 to 10 times during slow playback. Event tables copy in 14 ms instead of 230 ms for 10,000 events.
Animation.exportwrites.mp4,.webm,.mov,.avi,.gifand self-contained.htmlplayers. Video takescodec("h264","h265","vp9","mpeg4"),crf(default 18),presetandbitrate; HTML takesimage_format,max_html_frames,embedandframes_dir. An option the chosen file type does not use raisesTypeError. Without imageio-ffmpeg (thevideoextra), a video export raisesImportErrorsaying what to install before anything is rendered. Exports to filesystems without hard links (exFAT, FAT32, some network drives) still refuse to replace a file created during encoding, and files get the usual permissions.Animation.export(n_workers=...)renders frames in worker processes, with output identical to one process;Noneuses every CPU. Workers render the observations the scene prepared, so changing an overlay's arrays or an adapter's source afterward changes neither serial nor parallel output, and adapters need not be picklable. A field or prepared overlay that cannot be sent to a worker raises before rendering and names it. File-backed memory maps are reopened in each worker rather than copied. Scripts using workers need anif __name__ == "__main__":guard on macOS and Windows.- Napari scene views draw every overlay and region in each field's panel of a
layout="horizontal","vertical"or"grid"view, in 2-D and 3-D, and view capture includes all panels. With a layout, overlay layer names carry their field's name ("cellA: Position 1"). - Export and render frames faster: each event type is drawn as one marker
collection (a frame with 10,000 persistent events went from 8.6 s to
86 ms), in-memory video is downsampled as frames are read rather than
copied on every conversion, and views share the scene's prepared overlay
arrays instead of copying them. Arrays returned by
Animation.overlay_data()are read-only.
Breaking¶
Environment.animate_fieldsnow builds anAnimationand returns anAnimationView(napari, widget, fastplotlib; the viewer isview.native) or the writtenPath(video, HTML), instead of a napariViewer,FastplotlibAnimationorNone. Closing the view releases the readers it opened. Every backend draws every overlay type and several fields side by side (a mapping of name to field, or a list named bylayer_names). Fields are shown on their recording clock: each row is held until the next for at mostmax_gap(new, default 0.5 s;Noneholds every row), and longer gaps show as missing instead of a stale field. The frame rate is the sampling rate offrame_timestimesspeed, capped atmax_playback_fps; a capped file keepsspeedand skips rows, with a warning, instead of playing slower than requested. An export of the whole recording ends on its last row; selectedtime_rangewindows are half-open. Fields shown side by side share one cmap, colorbar label and, withoutvmin/vmax, one robust color scale from up to 100 rows of each, so a color means the same value in every panel; build anAnimationwith separateAnimationFields for independent scales. Lazy sources that are not arrays (h5py datasets) are read by row, and an iterator of rows raisesTypeError. HTML players label each frame with its recording time ("t = 12.345 s").trail_lengthspans that many animation frames in every view, drawing every tracked sample in that time with older segments fading, and no trail is drawn while the current position is missing.TimeSeriesOverlay'sshow_cursorandcursor_colorapply in every view.FastplotlibAnimationis the fastplotlib view'sview.native; build it throughAnimation.show(backend="fastplotlib")oranimate_fields.Animation.show(backend="fastplotlib")no longer takescmap,vmin,vmax,colorbar_label,title,show_regions,region_alpha,window_secondsortrack: set them on theAnimationor itsAnimationFields, which every view reads.animate_fieldsstill takes them.- Custom overlay adapters:
OverlayProtocolneeds onlyconvert_to_data, which is called once with the scene clock; sampled data must carry itstimes, and its support masks and shapes are checked when it is built. Readers an adapter returns stay the adapter's to close (VideoData._owns_readeris removed).Animation.overlay_data()has noown_samplesargument and returns sampled overlays withtimesset to the requested clock. - Requests no view can draw are refused when the
Animationis built, for built-in overlays and custom adapters alike: angle-only headings in 3-D, headings or video in 1-D, and misspelledshow_regionsnames.speed,max_playback_fpsand napari'sfpsare checked before any viewer opens.animate_fields(speed="fast")raisesValueError, andSpikeRasterOverlayrates on a different environment raiseIncompatibleEnvironmentError. TimeSeriesOverlayno longer takeswindow_seconds,interp,update_mode,playback_throttle_hzorscrub_throttle_hz: every signal panel spans the scene'swindow_seconds, and no view throttled or paused its panels by them.- Removed animation options: napari
cache_size,chunk_sizeandmax_chunks, widgetinitial_cache_size,cache_limit,image_formatandreturn_container,dry_run, andframe_labelsoutside fastplotlib.large_session_napari_configis removed: napari scene views read on a worker thread with bounded caches. The legacy napari, video, HTML and widget backend modules,neurospatial.animation.transformsand theneurospatial.animation._video_ioimport path are removed. annotation.TrackGraphResulthas a trailingunitsfield. Existing manual constructors still work because it defaults toNone, but positional tuple unpacking must now include this ninth item. Prefer named attributes such asresult.node_positionsandresult.units.heading_direction_labels,instantaneous_goal_alignment,goal_biasandcompute_goal_directed_metricsuse the movement rule ofheading_from_velocity: a direction exists only where the smoothed speed is above zero and at leastmin_speed. A missing position (NaN row) is a gap that ends a run instead of an error.heading_direction_labelslabels an observed animal that is not moving"stationary"and a sample with no observed movement (missing position, no usable interval)None, whichlabels_to_epochsleaves out; with precomputedspeedandheading, a NaNspeednow givesNone(was a direction label or"stationary") and a moving sample with a NaNheadinggivesNone(was"stationary"). A still animal atmin_speed=0was labeled"0–45°"(East).goal_biasof an animal that never moves isnan; it was1.0when the goal lay East.compute_goal_directed_metricswarns once when every interval is excluded (was twice) and reports distances over observed samples. Empty input raises in trajectory mode.
import numpy as np
from neurospatial.behavior import goal_bias, heading_direction_labels
times, still = np.arange(10) / 10, np.zeros((10, 2))
goal = np.array([100.0, 0.0])
heading_direction_labels(times, still, min_speed=0.0)
# before: ['0–45°', ...]; after: ['stationary', ...]
goal_bias(times, still, goal, min_speed=0.0) # before: 1.0; after: nan
positions = np.column_stack([2.0 * np.arange(10), np.zeros(10)]) # 20 cm/s East
positions[5] = np.nan
goal_bias(times, positions, goal, min_speed=5.0) # before: ValueError; after: 1.0
heading_from_body_orientation(times, nose, tail, *, max_gap=0.5, epochs=None, break_before=None)takes timestamps and fills a missing keypoint only inside one recording, when the gap lasts at mostmax_gapseconds; the fill is linear in time along the shorter arc. Longer gaps, samples before the first or after the last keypoint, and samples outsideepochsare NaN. Frames whose nose and tail coincide count as missing frames: they are filled if the gap is short. Before, every missing frame was filled by sample index across any gap, including a recording break, the ends took the nearest heading, and coincident keypoints read as East.
heading_from_body_orientation(nose, tail) # before
heading_from_body_orientation(times, nose, tail) # after
heading_from_body_orientation(times, nose, tail, break_before=new_recording)
- Options of public free functions are keyword-only, as
Environmentmethod options already were; data stays positional, and a test rejects any new positional option. This covers defaulted options (levels, kinds, calibration scales, NWB names and descriptions, seeds, plot settings) and required ones (bin widthsdtandbin_size, sampling rates, durations, thresholds such asmin_speed, counts, radii and NWB table names). An NWB reader or writer given a former positional option raises aTypeErrorthat shows the corrected call.
# before
decode_position(env, counts, models, 0.025)
bin_spikes_in_time(spike_times, 0.025)
segment_by_velocity(times, positions, 5.0)
credible_region(env, posterior, 0.9)
read_position(nwbfile, "behavior", "xy")
open_field_session(60.0)
# after
decode_position(env, counts, models, dt=0.025)
bin_spikes_in_time(spike_times, dt=0.025)
segment_by_velocity(times, positions, min_speed=5.0)
credible_region(env, posterior, level=0.9)
read_position(nwbfile, processing_module="behavior", position_name="xy")
open_field_session(duration=60.0)
- Every time window is half-open,
[start, stop), the convention analysisepochs=andlabels_to_epochsalready used, so adjacent windows never share a spike or event: in_epochs,restrictandrestrict_spike_trainsdefault toclosed="left"(was"both"); passclosed="both"to match pynapple's inclusiverestrict.align_spikes_to_eventsandalign_eventsleave out a spike or event exactly atevent + stop, so the raster agrees with the peri-event histogram, which already did.event_count_in_window,event_indicatorandfilter_by_intervalsdo the same at the window or interval stop.- A window with
stop <= startis refused, since it holds no time.event_indicatortherefore needs an explicitwindow=(its default(0.0, 0.0)matched only exact float equality);window=(-bin_size / 2, bin_size / 2)marks each event at exactly one sample. laps_to_direction_labels,runs_to_direction_labels,running_direction_labelsandgoal_pair_direction_labelslabel[start_time, end_time): a lap's last sample starts the interval that leaves the lap, so it is nowNone, andlabels_to_epochsgives windows that match the laps exactly.
# before: a spike at t = 1.0 was in both halves
first, second = restrict(spikes, epochs=(0, 1)), restrict(spikes, epochs=(1, 2))
# after: it is in `second` only; for the old behavior pass closed="both"
event_indicator(times, rewards) # before
event_indicator(times, rewards, window=(-dt / 2, dt / 2)) # after
- Direction-label functions mark samples outside every lap, run or trial
with
Noneinstead of"other", and"other"is an ordinary label everywhere.laps_to_direction_labelsno longer takesunknown_label: laps whose direction is unknown areNone.compute_directional_place_fieldsleaves outNone,NaNand a newmissing_label=value, likelabels_to_epochs.
labels = laps_to_direction_labels(laps, times) # None outside laps (was "other")
conditions = labels_to_epochs(times, labels) # was: missing_label="other"
compute_directional_place_fields(env, spikes, times, positions, my_labels,
missing_label="other") # if your labels use "other"
ops.visibility_occupancycounts viewing time only over usable sampling intervals. It takesmax_gap(default 0.5 s),epochsandbreak_before, so a tracking pause or the join between two recordings no longer counts as viewing time. An interval adds time only when both its samples have a finite position and heading; NaN positions or headings mark missing samples instead of raising. The last sample used to repeat the previous interval's duration and now adds no time: for gap-free data each bin visible from the last sample loses exactlytimes[-1] - times[-2].fov,n_raysandboundary_sourceare checked even when no interval is usable, a non-integern_raysraisesValueError(wasTypeError), and headings need one entry per timestamp.-
behavior.distance_to_rewardtakesmax_gap(default 0.5 s),epochsandbreak_before. NaN positions give NaN distance instead of raising. Withoutreward_positions, a reward location is read from the sample at the reward time or interpolated only within an observed interval that passesmax_gapandbreak_before; epochs do not restrict this, because rewards often come while the animal is still. A reward in a gap, across a break, next to a missing sample or outside the recording now has an unknown location, so samples that select it get NaN with a warning; before, it was interpolated across the gap or clipped to the session edge. Withbreak_before, a sample selects only rewards from its own recording, and samples outsideepochsare NaN. Gap-free tracking with rewards inside the recording gives identical results. -
Per-sample behavioral covariates have one home,
neurospatial.behavior.heading_from_velocityandheading_from_body_orientationmove fromneurospatial.opsnext tocompute_speed, anddistance_to_rewardanddistance_to_boundarymove fromneurospatial.eventsnext todistance_to_region. There are no aliases, so the old imports raiseImportError. Both heading functions also change in this release (see theheading_from_velocityandheading_from_body_orientationentries).opskeeps the egocentric frame conversions, andeventskeeps the time-based regressors (time_to_nearest_event,event_count_in_window,event_indicator) and its event-table utilities.
# before
from neurospatial.ops import heading_from_body_orientation, heading_from_velocity
from neurospatial.events import distance_to_boundary, distance_to_reward
# after
from neurospatial.behavior import (
distance_to_boundary,
distance_to_reward,
heading_from_body_orientation,
heading_from_velocity,
)
- The simulated mazes in
neurospatial.simulation.mazesnow match the real apparatus. Their layouts follow Figure 1 of Wijnen et al. 2024 and their sizes come from the papers that describe each maze, cited in each factory's docstring; sizes no paper gives are marked as estimates there. Positions, bins, regions and track graphs of the default mazes all change. - Rebuilt layouts. The rat HexMaze is four 24-node HexMaze modules joined by five bridges (96 junctions, about 9 x 5 m); it was three open hexagonal fields. The small hex maze is the Berke-lab 49-hex triangle with 1.30 m sides and a valid set of 9 barriers. The crossword maze has the 8 tracks, 14 crossings, 5 reward arms and 4 start boxes of McNamara et al. 2014. The honeycomb's 37 platforms are tiled side to side (1 cm gaps) and no longer fill the gaps around them. The Hamlet maze is a pentagonal ring whose five alleys split toward the five houses, with no spokes to the centre. Hampton Court follows Small's 1901 plan (8 x 6 ft, 4 in galleries) and the repeated T-maze Tolman & Nyswander's six-unit maze.
- New sizes: W-maze 76 x 76 cm with 7 cm track (Frank lab); Barnes 122 cm with 9.5 cm holes; cheeseboard 120 cm with 177 wells 7.5 cm apart; radial arm maze with a 35.5 cm centre and 76 x 9 cm arms; watermaze platform 15 cm across, halfway between the pool centre and the wall; repeated Y-maze 10.16 cm corridors.
- Tracks stay on the floor: the Hampton Court and Sungod tracks crossed walls, and the W-maze track had two dead-end stubs at its base. Arm-end, well and reward regions sit inside their corridors instead of on the wall, and the Y-maze's single arm points down.
- Changed dimension fields:
RatHexmazeDims(node_spacing, corridor_width, bridge_length, n_modules);SmallHexDims(hex_side, barriers);HoneycombDims(platform_side, gap, n_rings);HamletDims(ring_radius, split_radius, house_radius, corridor_width, house_size, n_arms);HamptonCourtDims(width, height, corridor_width);RepeatedTDims(width, arm_length, long_arm_length, wall_thickness, start_stem_length, start_box_width, start_box_depth); newBarnesDims.hole_insetandCheeseboardDims.rim_margin. The small hex maze's wells are nowport_A,port_Bandport_C, and an invalid barrier set raisesValueErrornaming the cut-off hexes. -
Reward ports, holes, platforms and start and goal boxes are now landmarks (
env.landmarks), so encoding functions take them by name as object positions. Each landmark'smetadata["role"]is one ofMAZE_LANDMARK_ROLES, and a region of the same name gives its real extent where the source publishes one (Barnes holes, watermaze platform, radial-arm food cups, crossword and Hamlet boxes); otherwise the region stays a point. The watermaze quadrants are quarter-disc regions. Mazes whose source fixes no port or box (Y, plus, honeycomb, rat HexMaze) declare none and say why. Renamed regions and track nodes:Maze Before Now linear track reward_left,reward_rightwell_left,well_rightW-maze (track nodes) well_1_top..well_3_topwell_1..well_3T-maze left_end,right_endwell_left,well_right(food wells)radial arm arm_0..arm_7food_cup_0..food_cup_7Barnes hole_kandescape_holeat the same holeescape_holeonly; newstart_boxcheeseboard reward_i_jwell_i_jSungod reward_well_0..10,centerhome_port,center_port_left/_right,arm_port_1..8,boxcrossword box_*,reward_*start_box_*,cap_*Hamlet house_0..4hide_house,run_house,interact_house,eat_house,drink_house; newagoraHampton Court start,goalentrance,central_enclosurerepeated T start,goalstart_box,food_boxrepeated Y start,goalentrance_box,food_box
# before
make_rat_hexmaze(RatHexmazeDims(module_width=90.0, n_modules=3))
# after: n_modules=1 is the mouse HexMaze of Alonso et al. 2021
make_rat_hexmaze(RatHexmazeDims(n_modules=1, node_spacing=36.3))
population_vector_correlationis nowrate_map_correlations. It always returned the correlation between every pair of rate maps (rows), not the per-location population-vector correlation of remapping studies. The argument isfiring_rates, and a map with constant rates now has a NaN diagonal entry (before, 1.0), because its correlation is undefined. A map with a NaN bin likewise gets a NaN row, column and diagonal entry (its diagonal used to be forced to 1.0), and input with no bins returns all NaN. A map counts as constant when all its rates are exactly equal; equal rates such as 0.1 everywhere, whose standard deviation rounds to about 1e-17, used to give a meaningless correlation near 0 instead of NaN.
# before
corr = population_vector_correlation(population_matrix)
# after
corr = rate_map_correlations(firing_rates) # same (n_maps, n_maps) matrix
population_vector_correlation now returns one correlation per location
between two conditions; the old matrix is rate_map_correlations. A call
with one argument raises a TypeError.
- field_overlap returns NaN, not 0.0, when neither field has a bin: with
no field in either condition there is nothing to compare, which is not the
same as fields that do not overlap. One empty field still gives 0.0.
# before
field_overlap(np.array([], dtype=int), np.array([], dtype=int)) # 0.0
# after
field_overlap(np.array([], dtype=int), np.array([], dtype=int)) # nan
-
field_stabilityreturns NaN when fewer than 3 bins are finite in both maps (the correlation is undefined, not "uncorrelated"): fewer than 2 paired bins was0.0and exactly 2 was always+-1; both are nownan. Usenp.nanmeanornp.isfinitewhen averaging across cells. It is alsonanwhen either map is flat up to rounding (highest and lowest rates within1e-12of its largest rate), the rulecompare_spatial_ratesuses. Before, only an exactly constant map gavenan, so a flat map from a smoothing estimator, which differs from constant by rounding, was correlated with the other map. -
SpatialRateResultandSpatialRatesResultraiseValueErrorwhen built with firing rates or occupancy that do not have one value per bin ofenv(env.n_bins). Before, a result built by hand with the wrong environment was accepted, and each rate was then plotted, exported and decoded at the wrong place. Results fromcompute_spatial_rate(s)andread_place_fieldare unaffected.
# before: accepted, and rate j was labeled with bin j of other_env
SpatialRatesResult(firing_rates=rates, occupancy=occupancy, env=other_env, ...)
# after: pass the environment the rates were estimated on
SpatialRatesResult(firing_rates=rates, occupancy=occupancy, env=env, ...)
-
read_place_field(nwbfile, env=env)checksenvagainst the environment saved with the rates, as the decoder checks rates from another environment (see Fixed), and raisesIncompatibleEnvironmentErrornaming what differs: units or coordinate frame, bin coordinates or order, bin measures, cell shapes (including polygon cells and which bin owns each boundary), connectivity, smoothing conductances between neighboring bins, or the arena and its walls. Before, it checked only the number of bins, so a same-size environment in other units or with bins in other places silently relabeled the maps. Omitenv=to use the saved environment. A file whose rates have no saved environment cannot verifyenv=and raisesValueError; rewrite it withwrite_spatial_rates. -
AnnotationResultfromannotate_videohas four fields,(environment, regions, walls, landmarks); unpack it asenv, regions, walls, landmarks = resultor use the attributes.RegionTypeadds"wall"and"landmark". -
count_place_cellscounts a cell at exactlythresholdand takesthresholdby keyword, matching the inclusive>=cutoff ofclassify()and the other threshold screens. Before, a cell had to exceed the threshold, andthresholdcould be passed positionally:count_place_cells(info, 0.5)becomescount_place_cells(info, threshold=0.5). -
traveled_path_lengthtakestimesfirst and counts only observed travel:traveled_path_length(times, positions, *, metric="euclidean", env=None, max_gap=0.5, epochs=None, break_before=None).
# before
traveled_path_length(positions)
# after
traveled_path_length(times, positions)
traveled_path_length(times, positions, break_before=new_recording) # joined recordings
Distance is no longer counted across a time gap longer than max_gap
(0.5 s by default; pass max_gap=None for tracking slower than 2 Hz), a
frame marked in break_before, or an interval outside epochs, and a NaN
row splits the path instead of making the total NaN. With
metric="geodesic", an interval with an endpoint outside the environment
is now left out of the total; it used to make the total NaN. With no
observed travel, including a single sample, it returns nan instead of
0.0; 0.0 now means observed and stationary. times must be strictly
increasing, and a 1-D positions is one coordinate, such as a linearized
track.
compute_path_efficiencyfollows the same observation rule astraveled_path_length, and takesbreak_beforefor joined recordings.
# before (v0.6.0): distance summed across pauses and joins; a lost frame made it NaN
result = compute_path_efficiency(env, positions, times, goal)
# after
result = compute_path_efficiency(env, times, positions, goal, break_before=new_recording)
traveled_length counts only usable intervals (as traveled_path_length
does) and is nan with no observed travel; a single sample used to give
0.0. efficiency and angular_efficiency are nan unless the whole path
was observed; a single sample used to give angular_efficiency = 1.0.
times must be strictly increasing, and empty input raises.
extract_pre_decision_windowreturns(times, positions), times first like every other behavior function.
# before
window_positions, window_times = extract_pre_decision_window(times, positions, entry, 1.0)
# after
window_times, window_positions = extract_pre_decision_window(times, positions, entry, 1.0)
A 1-D positions now comes back as a column: window_positions has
shape (n_window, 1), not (n_window,).
The decision analyses (extract_pre_decision_window,
pre_decision_speed_stats, pre_decision_heading_stats,
compute_pre_decision_metrics, detect_boundary_crossings and
compute_decision_analysis) now follow the same observation rule as
traveled_path_length and take break_before for joined recordings: the
pre-decision window never reaches back across a declared break, and no
speed, heading or boundary crossing spans one. A NaN row is a missing
sample: it stays in the pre-decision window as a missing row instead of
cutting the window short, and the statistics use only the observed samples.
pre_decision_heading_stats and compute_pre_decision_metrics no longer
raise on a NaN row. PreDecisionMetrics.n_samples counts the observed
samples in the window, and window_duration runs from the first to the
last of them. Repeated timestamps and empty input raise in all six
functions. Recordings without missing rows give the same values as before.
- VTE metrics are
NaN, not0.0, when nothing was observed, and a trial without an observed heading is unclassified instead of "no head sweep".
# before: a pause with no movement heading scored IdPhi = 0.0 and was
# z-scored and classified like a straight run
result = compute_vte_session(env, times, positions, decision_region="center", trials=trials)
result.trial_results[k].head_sweep_magnitude # 0.0
result.trial_results[k].is_vte # True or False
# after
result.trial_results[k].head_sweep_magnitude # nan
result.trial_results[k].is_vte # None ("unclassified")
head_sweep_magnitude returns nan with fewer than two finite headings,
and head_sweep_from_positions returns nan when the animal never moved
faster than min_speed (a still animal, a single sample), without the
all-NaN heading warning. compute_vte_trial reports mean_speed and
min_speed as nan when the window has no observed interval (fewer than
two samples); a measured stop is still 0.0. normalize_vte_scores takes
the mean and std over the finite entries and gives a missing entry a nan
z-score. In compute_vte_session, a trial with a nan z-score has a nan
vte_index and is_vte=None; n_vte_trials counts is_vte is True,
vte_fraction is over the classified trials only, the session means and
stds use the trials where each quantity was observed, and a session
without any analyzed trial reports nan statistics and vte_fraction
(they used to be 0.0). A NaN row is a missing sample: no heading or
speed spans it, and a trial is skipped when its window has fewer than
three observed samples. head_sweep_from_positions, compute_vte_trial
and compute_vte_session take break_before for joined recordings, and
repeated timestamps and empty input raise. Recordings without missing rows
give the same values as before wherever something was observed.
head_sweep_from_positions, compute_vte_trial, compute_vte_session,
pre_decision_heading_stats, compute_pre_decision_metrics and
compute_decision_analysis raise on positions with fewer than two
columns, whatever the number of samples, because a heading needs both x
and y. A 1-D input of one sample used to return 0.0 (head sweep and
trial speeds) or nan (heading statistics), and a 1-D session whose
trials were all skipped used to return an empty result.
VTESessionResult has n_classified, the number of trials with a VTE
verdict, and summary() reports it next to n_trials. str(result) reads
VTE session: 1/3 classified trials VTE (33.3%), 1 unclassified, or
VTE session: no trial could be classified; it used to read
VTE session: 1/4 VTE trials (25.0%), counting every analyzed trial.
heading_direction_labelstakestimesbeforepositions, like every other behavior function. The old order raises an error that names the swap.
# before
heading_direction_labels(positions, times, min_speed=5.0)
# after
heading_direction_labels(times, positions, min_speed=5.0)
Calls that pass positions= and times= by keyword, or precomputed
speed= and heading=, are unchanged.
approach_ratefollows the same observation rule astraveled_path_length, and takesbreak_beforefor joined recordings.
# before: a join on one continuous clock read as a fast approach
rates = approach_rate(times, positions, goal)
# after
rates = approach_rate(times, positions, goal, break_before=new_recording)
The rate at the first frame of a new recording is nan. Empty input now
raises instead of returning an empty array; a single sample gives [nan].
compute_trajectory_curvaturetakestimesfirst, andtimesis required:compute_trajectory_curvature(times, positions, *, smooth_window=0.2, max_gap=0.5, epochs=None, break_before=None).
# before
compute_trajectory_curvature(positions) # no times: no smoothing
compute_trajectory_curvature(positions, times)
# after
compute_trajectory_curvature(times, positions, smooth_window=None)
compute_trajectory_curvature(times, positions, break_before=new_recording)
A NaN row splits the path instead of turning nearby values NaN: the
missing sample is NaN, and the turns on each side are computed and
smoothed as two separate runs. Before, one lost frame in a 20-sample run
made 19 of the 20 values NaN. break_before keeps joined recordings
from being turned or smoothed across the join, and repeated timestamps
raise. Recordings without gaps give the same values as before.
mean_square_displacementfollows the same observation rule and takesepochsandbreak_before:mean_square_displacement(times, positions, *, metric="euclidean", env=None, max_tau=None, epochs=None, break_before=None).
# before: displacement pairs spanned the join between recordings
msd = mean_square_displacement(times, positions, max_tau=10.0)
# after
msd = mean_square_displacement(times, positions, max_tau=10.0, break_before=new_recording)
No displacement pair spans a declared break or an interval outside
epochs. There is no max_gap: MSD compares two observed positions a lag
apart and does not integrate along the path, so samples missing between them
do not change it. Repeated timestamps and empty input raise (a repeated
timestamp used to be accepted), and a 1-D positions is one coordinate. A
single sample returns an empty result. Recordings without breaks give the
same values as before.
heading_from_velocityno longer invents a heading where it has no velocity direction. Movement heading is the direction of the velocity vector, so it is not observed belowmin_speed, or where speed is exactly zero (where it used to return 0, East). Those samples are nowNaN; they used to be filled by circular interpolation between the headings around them, which could give a direction the animal never faced, such as North while it stood at a port between arriving heading East and leaving heading West. The velocity is smoothed, so at the defaultmin_speed=0most of a short stop still has a heading: setmin_speed(for example 2 cm/s) to mark stops.
# before: slow samples filled by circular interpolation; a NaN row raised
heading = heading_from_velocity(times, positions, min_speed=2.0)
observed = compute_speed(times, positions) >= 2.0 # mask needed to undo the fill
# after: NaN where the animal is slower than min_speed, still, or not tracked
heading = heading_from_velocity(times, positions, min_speed=2.0)
observed = np.isfinite(heading) # no separate compute_speed mask needed
A NaN row (a lost frame) is now a missing sample with NaN heading, and
no velocity is computed across it; it used to raise. A single sample gives
[nan] instead of raising, and empty input or a repeated timestamp raises.
A perfectly still recording now raises unless allow_all_nan=True; it
used to return 0 (East) everywhere. For a heading while the animal is
still, use heading_from_body_orientation with pose keypoints. For the
same reason head_sweep_from_positions(..., min_speed=0) on a window where
the animal never moves returns nan, not 0.0: no heading was observed.
Headings computed with min_speed > 0 contain NaN at slow samples, and a
simulated cell given them has NaN rates that generate_poisson_spikes
refuses: simulate firing from an unthresholded heading, or let the
simulator derive it. generate_population_spikes with a
HeadDirectionCellModel and no headings now holds the last direction of
movement through stops (samples before the first movement take the first
direction), and raises ValueError asking for headings= when the
trajectory never moves; it used to fire as if the animal faced East
wherever it stood exactly still.
These Environment changes come with before-and-after code on the environment
architecture migration
page.
-
A binning keeps its bins and their neighbor pairs in separate records.
SpatialBinsholds centers, measures andmeasure_dim; the newBinConnectionsholds the neighbor pairs, their navigation distances,is_open, andclosed_by(the index inarena.wallsof the wall closing each closed pair, recorded when walls are applied), andBinningtakes it after the bins:Binning(kind, bins, connections, locator, faces). ReplaceSpatialBins.from_arrays(centers, sizes, dim, pairs, distances)withSpatialBins.from_arrays(centers, sizes, dim)andBinConnections.from_arrays(pairs, distances), and readbinning.connections.pairswhere you readbinning.bins.pairs. Walls now close the pairs across them instead of deleting them, so an environment with a wall on bin edges lists the same pairs, in the same order, as one without it;connections.pairsandenv.edge_pairsare still the open pairs, and every analysis gives the same numbers as before. Environments saved by earlier versions (Binning codec versions 1 to 3) no longer load; rebuild them from their recording and save them again. -
Each bin records its own measure dimension.
SpatialBins.measure_dimshas one entry per bin (an integer passed toSpatialBins.from_arraysapplies to all), andSpatialBins.measure_dimis the shared value, or None when bins mix lengths and areas.Environment.bin_sizesraises on such an environment, since a sum or density over lengths and areas has no unit; the error says how to analyze one component at a time. -
Environment.rebinkeeps a subset's or clip's selection. A new bin is kept only where it covers the environment's own bins (and lies in the arena); before, rebinning re-covered the whole arena, so an 8-bin subset became 16 bins afterrebin(factor=1). Environments that were never subset or clipped rebin exactly as before. -
Environment.bin_center_tree()returns an independent copy of the cached KD-tree (about a millisecond for 200,000 bins), sotree1 is tree2is now False, and it refuses polar environments: a tree over(distance, angle)rows adds centimeters to radians and treats angles a turn apart as far apart, disagreeing withnearest_bin. Useenv.nearest_binthere.heat_kernel_wavelet_basison a polar environment now measures bin spacing physically instead of in those rows. Before, it returned the cached tree itself, and changing itsindicesor reshaping itsdatain place changed the environment's own lookups and bin centers. -
Binningno longer has aclip_mappingfield. The clipped locator already holds the mapping, and the stored copy had to agree with it, soEnvironment.clip_mappingnow reads it from the locator. Dropclip_mapping=when building aBinningforfrom_bins. -
Environment.from_samples(andopen_field) tile the arena's bounds when given anarena=with a known outline and nodimension_ranges, so cell edges start at the arena's edge. Walls on that lattice now fall on cell faces: a 100 cm box with a wall at x = 50 cm and 5 cm bins builds and blocks paths across the wall. Before, the grid followed the samples' extent, put the wall through the middle of cells, and refused to build unless you setdimension_rangesby hand. -
A wall that runs through bins no longer stops an environment from being built.
from_samples,open_field,from_polygon,from_grid_mask,from_pixel_mask,from_layoutandio.nwb.environment_from_positiontakewall_fit=. The default"snap"keeps every bin and moves the wall onto the bin edges between the bins it divides, warning how far it moved (at most about half a bin), so every analysis works as on any grid."split"keeps the measured wall and cuts each bin it crosses into one exact polygon bin per side; smoothing refuses the bins next to a wall's end or a diagonal wall, so use it with binned rates or wall-distance measures. A wall that covers part of a bin edge now leaves the open part connected with a proportionally smaller conductance, instead of raising.from_bins,rebinand saved environments still refuse a wall through a bin, with an error that names the wall and the bin. -
Environment.apply_transformtakesunits=, and requires it when the transform scales or shears lengths and the environment has units. Before, the units were always kept, so a pixel environment calibrated to centimeters withapply_transform(scale_2d(0.1))still said"px"while its bin areas shrank 100-fold. Pass the new unit (units="cm"), orunits=env.unitsfor an arena stretched in the same units. Translations, rotations and reflections keep the units as before. -
Environment.smooth,Environment.diffuseandEnvironment.compute_kernelrequiremode=.smoothdefaulted tomode="average"and the other two tomode="density", so the same call gave different numbers on the same field depending on the method. Passmode="average"for a rate map or other value per bin,mode="transition"for counts, occupancy or probability mass, andmode="density"to turn counts into a density. Leavingmodeout raises aTypeErrorthat lists the three choices. -
Environment.maze(kind=..., node_positions=...)refuses a layout whose track segments cross or run along each other, naming the two segments and the node order the kind expects. The order ofnode_positionssets which nodes are joined, so a W maze listed as base/arm pairs used to build arms on top of each other (25 duplicate bin centers in one example) without an error. Whennode_positionsholds bare numbers, the error now suggests a valid layout for the kind instead of points along one line, which no W, plus or T maze can be. -
ops.geodesic_distance_between_pointsandops.pairwise_distancesraisenetworkx.NodeNotFoundfor a bin that is not a node of the graph, such as the outside marker-1. Before, they returnedinf(ordefault), the value they also return for two bins with no path between them, so a missing sample looked like an unreachable place.defaultnow applies only when no path exists. Drop-1samples before calling (bins[bins >= 0]). -
Environment.occupancy(with the defaulttime_allocation="start") now counts an interval only when both of its samples are inside the environment, the rulecompute_spatial_ratealready used for its spike counts and occupancy. Before, an interval that ended at a tracking dropout (NaN) or outside the environment kept its time in its start bin, so spike counts divided byenv.occupancyused a different denominator than the rate maps. Fill short dropouts during tracking preprocessing to keep that time. -
Environment.animate_fieldsraisesTypeErrorfor an option that no backend accepts, naming the closest option, instead of ignoring it: a misspelledframe_label=used to change nothing.fps=raises too (usespeed=). The unusedcontrast_limits,show_colorbarandcolorbar_labeloptions are removed. -
Environment.from_polar_egocentricrefuses anangle_rangewider than one turn (2π) with aValueErrorthat shows a one-turn range. Angles a whole turn apart name one direction, so after angle wrapping the bins past the first turn would never hold a sample; such a range used to be accepted. -
A track built without
track_width(Environment.linear_track,Environment.maze,Environment.from_graph) no longer assigns 2-D positions to bins.bin_at,contains,occupancy,bin_sequence,bin_sequence_with_runs,transitions(times=..., positions=...),points=queries, point regions, and every analysis that maps positions with them (rate maps, decoding, behavior metrics) raiseNotImplementedError. Before, a position any distance from the track, such as a reflection 5 m away, was put in its nearest bin and counted as occupancy and spikes there;distance_to(points=[[500, 500]])measured from the nearest bin. Pass the measured full width when you build the track, for exampleEnvironment.linear_track(endpoints=[(0, 0), (200, 0)], bin_size=2.0, track_width=10.0); positions farther than half the width from the centerline then get bin-1and add no occupancy. A track you already have, including one saved by an earlier version and read withEnvironment.from_file,from_dictorfrom_nwb, takes its width with the newenv.with_track_width(10.0), which keeps its bins; the error message shows that call.linear_to_ndand graph queries on bin indices do not need the width.to_lineardoes not either, because it projects every position onto the nearest point of the centerline, however far off the track: drop off-track samples (bin-1frombin_aton a track with a width) before using linear positions. The simulated mazes inneurospatial.simulation.mazeswith a corridor width (linear track, T, Y, W, plus, radial arm, repeated T and Y, crossword, Hamlet, Hampton Court and rat hexmaze) now pass it to their track, andtmaze_alternation_sessionbuilds 10 cm corridors. The Sungod, honeycomb and small hex mazes have no single corridor width; their tracks take the widest one (the widest arm, one platform, the widest corridor between barriers), so a sample within half that width of the centerline counts as on the track and a narrower corridor can admit a few samples just outside its walls. -
Pickle files of an
Environmentwritten by an earlier version can no longer be loaded. Pickle still works for passing an environment between processes of the same version (for example withmultiprocessing), but it is not a storage format. An environment state from another version raises aTypeErrorthat says to use files instead. Save withenv.to_file(...)or NWB export and reload withEnvironment.from_file(...). -
Old import paths are removed. The compatibility modules
neurospatial.environment.transforms,._coordinates,._metadata,._ownershipand._from_bins, the former private geometry modules underneurospatial.layout, the private names re-exported byneurospatial.arenaandneurospatial.environment._HAS_SHAPELYare gone (shapely is a required dependency). The modules that implementEnvironmentmethods are private:neurospatial.environment.fields,.queries,.metrics,.trajectory,.regions,.visualization,.polar,.cartesianand.factoriesare now_fields,_queriesand so on, andneurospatial.environment.serializationis removed. Call theEnvironmentmethods (for exampleenv.smooth,env.occupancy,env.to_file) instead, and importBinSequenceWithRunsfromneurospatial.environment. The public arena classes inneurospatial.arenaare unchanged. -
neurospatial.ops.binningis renamedneurospatial.ops.mapping, so it is no longer confused withneurospatial.binning, which holds theBinningrecords. It maps points to bins, regions to bin masks and fields between environments. Importregions_to_mask,resample_fieldandTieBreakStrategyfromneurospatial.opsorneurospatial.ops.mapping. -
Environmentis an immutable value. Two environments are equal only when they are the same object.env == "name"used to compare the environment's name; it now returnsFalsewithout an error, so a check such asif env == "session1":silently takes the other branch. Writeenv.name == "session1"instead. Environments can be used in sets and as dictionary keys.Environment.copy()is removed because an environment cannot change after it is built;copy.copy(env)andcopy.deepcopy(env)return the same object. Usewith_metadata,with_regions,subsetorrebinto get a changed environment. -
EnvironmentNotFittedError(error code E1004), thecheck_fitteddecorator and theneurospatial.environment.decoratorsmodule are removed. An environment always has its bins and connectivity; callingEnvironment()directly still raises E1006. Passing something that is not an environment to an analysis function raisesTypeErrornaming the corrected call. -
Environment.clear_cache()takes no arguments and releases every cached analysis (KD-tree, smoothing kernels, eigenbases, operators, boundary bins and attribute tables), including the search structures behindbin_aton tracks, triangular meshes and clipped environments. On a track,bin_atandnearest_binshare one KD-tree. Thekdtree=,kernels=andcached_properties=options andneurospatial.ops.binning.clear_kdtree_cacheare removed; callenv.clear_cache()instead. -
Environment.to_linear()andEnvironment.linear_to_nd()raiseTypeErrorinstead ofAttributeErrorwhen the environment is not a linearized track. The message is unchanged. -
Egocentric polar environments (
Environment.from_polar_egocentric) reportenv.layout_type == "Polar"instead of"MaskedGrid", and their printed summary saysPolar. Their bins are distance-by-angle sectors, not Cartesian grid cells. Files saved before this change load as"Polar"too. To detect a polar environment, testenv.coordinate_kind == "polar"instead of the layout name. -
A polar environment takes the choice of whether its angle wraps around only from its stored
EgocentricSurroundarena. Old files (Environment-v2files and NWB environment schema 2.0) stored no arena, so a polar environment in one of them is refused with a message to re-save it. Cartesian environments in those files still load. -
Custom layout engines must implement
binning(), which returns theBinningthe environment keeps: the bins, their neighbor pairs, point lookup, the faces between cells (or why they are missing), any grid, track, shape and outline records and, when the cells do not show it, thedimension_rangesthe bins cover. An engine needs onlybuild(...)andbinning(): the environment derives its connectivity graph,grid_edges,grid_shape,active_maskanddimension_rangesfrom theBinningand no longer reads an engine'sconnectivity,dimension_rangesor grid attributes, so attributes an engine adds to its own graph do not reachenv.connectivity. Lookup and faces come only frombinning(), so to change them, change whatbinning()returns. Two labels are read when an engine has them: itslayout_typelabel, and the build arguments recorded withneurospatial.layout.base.capture_build_params(env.layout_parameters). All built-in engines implementbinning(), a subclass of a built-in engine inherits a working one, andLayoutEnginelists the method. Objects withoutbinning()still need an installed snapshot adapter. The privateneurospatial.layout._capabilitiesmodule (RectilinearGridProvider) is removed; readlayout.binning().rectilinear. -
env.connectivityis rebuilt from the bins every time an environment is built, derived or read back, so it holds only the standard node and edge attributes, as numbers and tuples, andenv.connectivity.graphis an empty read-only mapping. Reading an attribute returns the stored value without a copy; editing any of them is refused.env.to_networkx()andenv.connectivity.copy()return independent, editable graphs. A file, dict or NWB record still stores the graph for other tools, but loading rebuilds it from the bins: attributes added to a saved graph are not read back. -
Derived environments keep the labels of the environment they came from.
subset()andclip()keep the named regions (they used to drop them), andsubset()keeps the name.rebin()keeps the name (no_rebinnedsuffix).apply_transform()keeps the name (no_transformedsuffix) and no longer invents a frame: the new frame is theframe=you pass, orNone. -
env.layout_parameters(and thelayout_parametersrecord in files, dicts and NWB) holds only the scalar build choices, such asbin_size,connect_diagonal_neighborsorcircular_angle. Input data is no longer kept: the positions given tofrom_samples, the masks offrom_grid_mask,from_pixel_maskand polar environments, polygons, the track graph and edge order offrom_graph, and nested values such asdimension_ranges(readenv.dimension_ranges). A saved environment no longer grows with the length of the trajectory it was built from (a 100,000-samplefrom_samplesfile went from 7.5 MB to 0.46 MB, the same size as a 10-sample one). The track graph is still owned by the environment: read it fromenv.binning.linearization.graph(and.edge_order). -
Factories take their data positionally and every option by keyword:
Environment.from_samples(positions, bin_size=2.0)andEnvironment.open_field(positions, bin_size=2.0), notfrom_samples(positions, 2.0). Their signatures list every option, so a misspelled option raisesTypeErrorinstead of reaching the layout engine.Environment.from_graph(graph, edge_order, bin_size=2.0).edge_spacingwas a required positional argument beforebin_size; it is now a keyword that defaults to0.0(edges end to end), as inlinear_trackandmaze.Environment.maze(kind="plus", node_positions=..., bin_size=...).Environment.from_polygon(polygon, bin_size=2.0)andEnvironment.from_pixel_mask(mask, pixel_size=0.5).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). Four positional numbers were easy to swap.
An option passed by position raises a TypeError that names the keyword
and shows the corrected call: Environment.from_samples(positions, 4.0)
says to pass bin_size=4.0 by keyword, and
Environment.from_graph(graph, edge_order, 0.0, 2.0) says to pass
edge_spacing=0.0, bin_size=2.0. Other argument errors, such as a
misspelled option, keep Python's own message.
-
Environment.from_samples,Environment.open_fieldandneurospatial.io.nwb.environment_from_positionrefuse option values that the call would ignore, instead of ignoring them without a word. A regular-grid-only option (dilate,fill_holes,close_gaps,add_boundary_bins,connect_diagonal_neighbors) set to anything but its default raisesValueErrorwithlayout="Hexagonal". Withinfer_active_bins=Falseevery bin in the extent is active, so changingbin_count_threshold,dilate,fill_holesorclose_gapsfrom its default raisesValueError. The defaults are accepted with any layout, and the signatures show them (dilate: bool = False,bin_count_threshold: int = 0,connect_diagonal_neighbors: bool = True;fill_holesdefaults toTrueinopen_field).open_fieldalso acceptsfill_holes=Falsewithlayout="Hexagonal"orinfer_active_bins=False, because neither fills holes. Alayout=other than"RegularGrid"or"Hexagonal"raisesValueErrorlisting the supported layouts and the factory to use instead; it used to raiseNotImplementedError. Errors fromenvironment_from_positionname that function. -
Environment.from_layouttakes a built layout engine instead of a layout name and a dictionary of build parameters:Environment.from_layout(create_layout("TriangularMesh", boundary_polygon=arena, point_spacing=2.5)). A layout name or an engine class raisesTypeErrorwith the corrected call.layout_typeandlayout_parameterscome from the engine's own record, which includes the defaults it resolved but never its input data: a built-in engine's positions, masks, polygons, graphs and boundary coordinates are left out, whether or not they were given. Grids, polygons, masks and tracks keep their dedicated factories. -
Environment.from_nwbonly loads an environment saved in the file:Environment.from_nwb(nwbfile, name="linear_track"). The record name isname=(it wasscratch_name=), matchingenv.to_nwb(nwbfile, name)andread_environment, and defaults to"spatial_environment". To build an environment from the file's Position data, callneurospatial.io.nwb.environment_from_position(nwbfile, bin_size=...)instead offrom_nwb(nwbfile, bin_size=...). It takesbin_sizeas a keyword and lists everyfrom_samplesoption (name=,regions=,arena=,layout=,fill_holes=and so on) instead of forwarding**kwargs.from_nwb(nwbfile, bin_size=...)raises aTypeErrorthat shows that call. When the file has no saved environment of that name,from_nwbandread_environmentraise aKeyErrorthat lists the saved environments and, if the file has Position data, names it and points toenvironment_from_position. -
Every region query now uses one rule for which bins a region covers. A point region covers the bin that contains the point. A polygon region covers the bins whose centers lie inside it or on its edge; pass
include_boundary=Falseto count only centers strictly inside. The rule is shared bybins_in_region,region_mask,region_membership,distance_towith a region name,subset(region_names=...),subset(polygon=...)andneurospatial.ops.mapping.regions_to_mask, so they agree on every layout. Before, they disagreed: region_membership()andregions_to_mask()gave a point region no bins (regions_to_maskalso warned). They now give the bin that contains the point. Time spent at a point region, and visits, crossings and laps found throughregions_to_mask, now count that bin.bins_in_region(),subset(region_names=...)andsubset(polygon=...)left out bins whose centers lie exactly on a polygon's edge, whileregion_maskincluded them. All of them now include those bins.bins_in_regionandsubsettakeinclude_boundary=Falsefor the old interior-only selection (subsetrefuses it together withmask=). Analyses built onbins_in_regioncan change when a polygon edge passes through bin centers: the decision-region entry time indecision_region_entry_time,compute_decision_analysisandcompute_vte_session, the goal bins and decision boundary ofcompute_decision_analysis, the start and goal bins oftrials_to_region_arrays, and the region boundary ofneurospatial.events.distance_to_boundary(region_name=...).subset(region_names=[...])keeps the bin that contains a point region instead of raisingValueError.distance_to("reward")with a point region measures the distance to the bin that contains the point. It used to warn that the region had no bins and then raise. A region that covers no bin, such as a point outside the environment, raises aValueErrorthat says so.- On egocentric polar environments a point region is read in the same
(distance, angle) coordinates as
bin_centers;bins_in_regionandregion_maskused to refuse point regions there. -
A polygon region on an environment whose bins are not 2-D raises
ValueErrorwith a fix inregion_membershipandregions_to_mask; it used to raiseNotImplementedError. -
Graph and distance queries take their target by keyword:
bins=for bin indices,points=for coordinates and, fordistance_to,region=for a region name. Exactly one is required. A bin index and a coordinate look the same as numbers, and the old positional argument guessed between them:env.distance_to([50.7, 12.3])measured the distance to bins 50 and 12, andenv.neighbors([0, 1])treated bins 0 and 1 as the point (0, 1). env.distance_to(targets)isenv.distance_to(bins=...),env.distance_to(points=...)orenv.distance_to(region="reward").env.neighbors(i),env.reachable_from(i, ...)andenv.rings(center_bin=i, ...)takebins=ior one point,points=[[x, y]].env.path_between(a, b)isenv.path_between(bins=[a, b])orenv.path_between(points=[[x0, y0], [x1, y1]]).env.distance_between(p1, p2)isenv.distance_between(points=[p1, p2])orenv.distance_between(bins=[a, b]);edge_weight=is keyword-only.
bins= takes an int or an integer array. Floats and booleans raise
TypeError and point to points=; env.bin_center_of refuses them too and
points to env.bin_at, with (distance, angle) rows on polar environments.
BinIndexOutOfRangeError names the same lookup. bin_center_of also refuses a 2-D
array of indices, for which it used to return an array of shape
(a, b, n_dims); pass a 1-D array. points= has shape (n_points, n_dims), one row per
point, in the same coordinates as bin_centers (distance and angle on a
polar environment). Points with NaN or inf coordinates, or outside the
environment, raise ValueError that names the first such row.
distance_between used to return inf for a point outside the
environment; it now raises. It also works on egocentric polar environments,
where it used to raise CartesianOnlyError: the path length follows the
graph, and points are (distance, angle). A target passed by position or
under its old name (targets=, bin_index=, center_bin=, ...) raises a
TypeError that says which keyword applies and shows the corrected call:
env.neighbors(5) suggests env.neighbors(bins=5), and
env.distance_to("reward") suggests env.distance_to(region="reward").
-
Behavior functions that take bin targets check them the way the queries check
bins=:distance_to_region(target_bins=),cost_to_goal(goal_bins=),path_progress(start_bins=, goal_bins=),geodesic_voronoi_labels,distance_to_decision_boundaryandgoal_reward_field. Floats and booleans raiseTypeErrorinstead of being truncated to bin indices, and out-of-range bins raiseBinIndexOutOfRangeError(aValueError).-1, the valuebin_atgives a position outside the environment, is never accepted as a single target, whether it is an int or a one-element array such asenv.bin_at([[x, y]]):target_bins=-1andgoal_bins=-1used to return all-NaN distances and now raise. Only an array with one entry per sample (shape(len(position_bins),), as built bytrials_to_region_arrays) is per sample, and there-1still marks a sample with no target and gives NaN; an array of any other length raisesValueError.path_progressalso accepts one start and one goal bin for every sample.approach_rate(..., metric="geodesic")raises for a goal outside the environment; it used to return all-NaN rates.shortest_path_length(..., metric="geodesic")measures withenv.distance_between: a start outside the environment used to return the distance from the last bin without a warning, and a goal outside raised an unrelated graph error; both now raiseValueErrornaming the point. Socompute_path_efficiencyandsubgoal_efficiencywithmetric="geodesic"raise thatValueErrorwhen the trajectory starts outside the environment.geodesic_voronoi_labelsanddistance_to_decision_boundaryraiseValueErrorfor an empty goal list;distance_to_decision_boundaryused to returninffor every sample. -
Environment.subset(bins=mask)is nowenv.subset(mask=mask), sobins=means integer bin indices everywhere.mask=is still a boolean array with one entry per bin. Integer values inmask=raiseValueErrorthat shows how to build a mask from bin indices. -
Cartesian-only methods stay visible on egocentric polar environments and refuse when called.
hasattr(polar_env, "subset")is nowTrueanddir(polar_env)listssubset,rebin,interpolateand the other Cartesian-only methods; calling one raisesCartesianOnlyError.CartesianOnlyErroris now aTypeError(the operation does not apply to this kind of environment) and no longer anAttributeErrororNotImplementedError; import it asneurospatial.CartesianOnlyError. Replacehasattrchecks withenv.coordinate_kind == "polar", andexcept (AttributeError, NotImplementedError)withexcept CartesianOnlyError. Every Cartesian-only refusal now raises this error with one message layout: what was refused, why it needs Cartesian positions, and what works on a polar environment. That includes the Cartesian modes of shared methods (distance_to(metric="euclidean"),occupancy(time_allocation="linear")), analysis functions such asspatial_autocorrelationandfield_shape_metrics,compute_egocentric_distance(metric="geodesic")(it reportedEnvironment.bin_at()), andEnvironment.combine(it raised a plainTypeError). A function that takes two environments names the polar one, for exampleresample_field(dst_env=...). Where a polar environment has an equivalent, the message ends with the code to run instead, also available aserror.example. The suggested code keeps containment: rows outside the environment get bin-1(or NaN frominterpolate), never the nearest edge bin. -
bin_at,contains,occupancy,bin_sequence,bin_sequence_with_runsandtransitions(times=..., positions=...)work on egocentric polar environments, where they used to raiseCartesianOnlyError. Each row is(distance, angle)and gets the bin whose cell holds it, or-1outside the distance and angle ranges, the same lookup thepoints=queries use.occupancygives each interval to the bin of its first sample, as on any environment, so egocentric occupancy (seconds with the object at each distance and direction) comes straight from per-sample(distance, angle)rows;time_allocation="linear"still refuses polar environments.CARTESIAN_OPERATIONSno longer lists these methods. Analysis functions that read positions as the animal's allocentric positions and relied onbin_atto refuse polar environments now refuse them with their own name: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 ofcompute_step_lengths,mean_square_displacementanddecoding_error, thedistance_to_rewardanddistance_to_boundaryevent regressors,simulate_trajectory_ou(and sosimulate_session), and the boundary, object-vector, spatial-view and geodesic place-cell models. -
Three
neurospatial.opsfunctions that only renamed anEnvironmentmethod are removed; call the method instead.map_points_to_bins(env, points, ...)isenv.nearest_bin(points, ...),compute_differential_operator(env)isenv.get_differential_operator(), andapply_transform_to_environment(env, transform, ...)isenv.apply_transform(transform, ...). The arguments and results are the same, with one difference:map_points_to_binsrefused polar environments, andenv.nearest_binaccepts them. -
neurospatial.layout.validation.validate_environmentis removed. Every environment is checked when it is built, so a built environment always passes it. Set units and frame when you build the environment, for exampleEnvironment.from_samples(positions, bin_size=2.0, units="cm", frame="room"), or later withenv.with_metadata(units=..., frame=...). -
The built-in layout engines no longer have
point_to_bin_index,finite_volume_geometryoris_grid_compatible, and theLayoutEngineprotocol no longer lists the first and last. An environment never used them: it locates points and builds cell faces from the engine'sbinning(). Useenv.bin_at(points), orengine.binning().locator.locate(points)on a built engine;engine.binning().faces.build()for the cell faces; andbinning.rectilinear is not Noneto ask whether the bins form a rectangular grid. -
A custom layout engine that records its build arguments with
neurospatial.layout.base.capture_build_paramsdeclares its input-data arguments in the decorator:@capture_build_params(data=("positions",)). They are left out ofenv.layout_parameters. Any other argument that is not a scalar or a flat sequence of scalars now raises aTypeErrornaming it when the engine is built; it used to be dropped without notice. The built-in engines declare their data the same way, replacing their_data_parametersattribute, and their recorded choices are unchanged. When a saved environment'slayout_parametershold input data, as files from earlier versions do, the data is dropped on reading and the scalar choices are kept; the bins are stored in the file. -
One layout label.
LayoutType(now also withPOLARandCOMBINED) is the only vocabulary: a built-in engine'slayout_typeis itsLayoutTypevalue (RegularGridLayout().layout_type == "RegularGrid", it was the category"grid"),env.binning.kindholds the same label, andenv.layout_typereports it. Engine categories such as"grid"or"mask", the private_layout_type_tagand thelayout_categoryfield of saved files are gone. User spellings are reported canonically:from_samples(..., layout="regular-grid")givesenv.layout_type == "RegularGrid"(it gave"regular-grid"), andcreate_layoutalso accepts"grid","polygon"and"mesh". The newneurospatial.layout.find_layout_type(name)returns theLayoutTypea spelling names, orNone.from_binsspells aBinning.kindthat names a layout canonically. It refuses"Polar", which is reserved forfrom_polar_egocentric, and"Combined"on bins that are not joined components ofcombine. Derived environments keep their parent's label instead of"MaskedGrid"or"subset"(subset),"PolygonCells"(clip),"RegularGrid"(rebin of any grid) or"<label>_transformed"(apply_transform);combinereports"Combined"(it was"combined"). The newenv.derivationslists the derivations applied, oldest first, for example("subset", "rebin").layout_parameterskeeps the parent's build choices throughsubset,clipandapply_transform(they used to be replaced by{"source": ..., "original_n_bins": ...}or gain"transformed": True), andrebin(bin_size=...)recordsbin_sizeandconnect_diagonal_neighborsasrebin(factor)does. Files from earlier versions load with the canonical label and their derivation markers asderivations. A subclass of a built-in engine that sets its ownlayout_typereports it everywhere; it used to report the parent's label asenv.layout_type. -
Assigning or deleting a public attribute of an
Environmentraises anAttributeErrorthat says what to call instead. That includes new names:env.session_id = "s1"used to attach a value silently and now says to keep your own labels next to the environment, for example in a dict keyed byenv.name. -
Building from any layout engine, including a custom one, and
rebinnow check that each bin center is located in its own bin, asfrom_bins,subsetand the readers already did. The built-in engines pass; a custom engine whose binning fails the check is refused with aValueError. -
GridCellModel(grid_orientation=θ)(andsimulate_session(..., orientation=θ)) now means the angle of the field lattice: the direction from a firing field to its neighbors, in radians from +x counterclockwise, modulo π/3. This is the orientationgrid_propertiesreports, so simulating at θ and analysing the rate map returns θ. It used to set the angle of the first of the three plane waves, which puts the lattice π/6 (30°) further counterclockwise, so simulated rate maps and spikes for a givengrid_orientationare rotated by π/6. To reproduce an earlier simulation, passgrid_orientation=old + np.pi / 6. -
Grid orientation values change units and frame.
grid_properties().orientation(andSpatialRateResult.grid_properties()) read the autocorrelogram as an image, with its first axis as y, butspatial_autocorrelationputs the environment's x on the first axis, so the reported angle was the lattice angle reflected about 45°. It was also in degrees. Orientation is now the angle of the field lattice in the environment's frame, in radians from +x (East) counterclockwise toward +y (North), in [0, π/3): a hexagonal lattice repeats every π/3.orientation_stdis in radians too.peak_coordscolumns are now offsets along x then y, as documented; they used to come out y first, so code that readpeak_coords[:, 0]as the x offset was reading y. Convert old orientation values withnp.radians((90 - old) % 60); this is approximate, because old values also averaged in a spare peak that could lie on the second ring of fields (see Fixed), so they were off by up to about 5°. The migration page has before-and-after values. A grid cell simulated withGridCellModel(grid_orientation=θ)is reported with orientation θ. -
Environmentmethod options are keyword-only, as factory options already were.bandwidthis keyword-only insmooth,diffuse,compute_kernel,compute_gaussian_kernelanddiffusion_accuracy: writeenv.smooth(rate, bandwidth=5.0), notenv.smooth(rate, 5.0). Inplot,plot_1dandplot_field, everything after the field andaxis keyword-only (env.plot_field(rate, ax, cmap="magma")), andto_nwbtakesname=by keyword, asfrom_nwbdoes. A positional call raises aTypeErrorthat names the keyword and shows the corrected call, as the factories do:env.smooth(rate, 5.0)suggestsenv.smooth(field, bandwidth=5.0).env.subset(bins=mask)says to passmask=. The migration page has before-and-after calls. -
env.animate_fields(overlay_trajectory=...)is removed. No backend drew it (napari dropped it and the others ignored it), although the animation guide said napari showed it as a track. Passoverlays=[PositionOverlay(positions=..., times=..., trail_length=...)]instead. -
The coordinate argument of
bin_at,contains,nearest_bin,to_linearandinterpolateis namedpositions, the name analysis functions use (it waspoints_nd,pointsornd_position). Only a call that passed it by keyword changes:env.bin_at(points_nd=xy)becomesenv.bin_at(xy)orenv.bin_at(positions=xy). Thepoints=query target ofdistance_to,neighborsand the other graph queries is unchanged. -
A nonzero
edge_spacingon a track with one edge (for exampleEnvironment.linear_track(endpoints=..., edge_spacing=5.0)) raisesValueError. A gap goes between consecutive edges, so the value used to be recorded inlayout_parametersand ignored. Remove it, or usenode_positionswith more than two points for a track with segments. -
A misspelled region name in
animate_fields(show_regions=[...])(every backend),regions_containing_points(region_names=...)orplot_regions(region_names=...)raisesRegionNotFoundErrorwith the closest name, before anything is drawn. These names used to be skipped,plot_regionswith a warning, so a region silently went missing from the animation, the table or the plot. Fix the name. A single string, such asshow_regions="goal", now means that one region; it used to be read letter by letter. -
graph_turn_sequence(env, position_bins)no longer takesstart_binandend_bin. They were documented as orienting the path but were never used: the order ofposition_binsalready sets the direction of travel, and the result did not depend on them. Drop the two arguments from the call. -
A built-in layout engine's
connectivitygraph (for examplecreate_layout("RegularGrid", ...).connectivity) is now made from the engine'sbinning()when you first read it, so it is the same graph asenv.connectivity. It can no longer be assigned, and editing it no longer changes the engine's bins or an environment built from it; return other neighbor pairs from your own engine'sbinning()instead. Reading it beforebuild()raisesLayoutNotBuiltErrorinstead of returningNone. Its attributes now matchenv.connectivity: on a track, edgevectorandangle_2dpoint from the lower to the higher bin (along a segment they pointed the other way),source_grid_flat_indexcounts the gap cells of a track withedge_spacing > 0, a 3-D track has noangle_2d, the angular-seam edges of a polar grid number theiredge_idby pair row, and values are Python numbers instead of NumPy scalars. Neighbor pairs, their order and their distances are unchanged. -
neurospatial.GraphValidationErrorandneurospatial.layout.validation.validate_connectivity_graphare removed. Layout engines no longer build graphs, and an environment's graph is made from bins that are checked when they are created: a non-finite or negative pair distance, a bin paired with itself or a repeated pair raisesValueError. Code that caughtGraphValidationErrorcan catchValueError, its parent class. -
Decoding error is measured along the environment by default.
DecodingResult.error_against,decoding_error,median_decoding_errorandBayesianDecoder.scoredefault tometric="geodesic"(distance=forscore): the shortest path between the decoded and actual bins on the environment's connectivity graph, so a decode in the wrong arm of a maze counts the path between the arms. Before, the default was the straight line: on a simulated W-track the mean error read 39 cm instead of 59 cm along the track. Passmetric="euclidean"for the straight-line error.decoding_errorandmedian_decoding_errornow take the environment first, likeconfusion_matrix:
# before
decoding_error(decoded, actual) # straight line
# after
decoding_error(env, decoded, actual) # along the environment
decoding_error(env, decoded, actual, metric="euclidean") # straight line
A geodesic position outside every bin, such as a posterior mean between two arms, is measured from its nearest bin instead of falling back to the straight line. Distances come from shortest paths run from the decoded bins in blocks instead of a dense all-pairs matrix: on a 10,000-bin arena the peak memory of 72,000 errors drops from 808 MB to 327 MB, at the same speed and with identical values.
-
Speed thresholds no longer assume centimeters.
min_speedis in position units per second, so its 5 cm/s default was 5 m/s for positions in meters (every sample excluded;goal_biasreturned NaN). The seven behavior functions that take no environment (goal_bias,instantaneous_goal_alignment,heading_direction_labels,head_sweep_from_positions,pre_decision_heading_stats,compute_pre_decision_metrics,compute_vte_trial) now requiremin_speed; passmin_speed=5.0for the old behavior with positions in cm.compute_decision_analysis,compute_goal_directed_metricsandcompute_vte_sessiontakemin_speed=None, meaning 5 cm/s inenv.units(0.05 for meters, 50 for millimeters), and ask for an explicit value when the environment's units are not a metric length. -
Environment.nearest_binreturns the nearest bin for every finite point, however far away, unless you passmax_distanceormax_distance_factor. Before, a point more than 10 typical bin spacings from every center got-1with no limit given; code that fed that-1back as an index read the last bin (geodesic decoding error did), and nearest interpolation dropped points deep inside a long cell. Usebin_at()orcontains()to ask whether a point is inside the environment, or passmax_distance_factor=10.0for the old cutoff.resample_fieldkeeps its outside-the-source rule by passing that factor itself. -
A position inside a known arena outline that falls in no bin -- a sliver of a curved or diagonal wall, whose grid cell was dropped because its center lies outside the outline -- belongs to the neighboring bin whose center lies inside, less than one cell away. Before,
bin_atgave-1and occupancy and spike counts dropped it. For an animal running along the wall of a 50 cm circular arena (mean 4 cm from it) with a border cell, 4 cm bins dropped 10% of the time and 17% of the spikes (14% of the time within 5 cm of the wall); now none, with rate maps and border scores nearly unchanged. Cells removed bysubset(), holes and positions beyond the grid stay outside. Applies to rectangular grids (from_polygon, andfrom_samples/open_fieldwith anarena=);time_allocation="linear"still splits paths by the exact cells.rebintherefore no longer reports fine bins in perimeter coarse cells as lost. -
simulate_trajectory_lapsandsimulate_session(trajectory_method="laps")refuse four inputs they used to accept (see Fixed): a customoutbound_pathorinbound_pathwhose consecutive bins are not neighbors inenv.connectivity; the defaultspeed_meanon an environment whose units are unset or not a metric length (simulate_trajectory_lapsonly; passspeed_mean=); an environment whose bins at the smallest and largest x are not connected, where there is no default path (passoutbound_path=); and a path that steps between neighboring bins whose cells do not touch inside the environment, such as across a bridge between two rooms of anEnvironment.combine()environment with no bins between them, where every route would leave the environment.
# before: jumped straight from bin 0 to bin 2
simulate_trajectory_laps(env, n_laps=2, outbound_path=[0, 2])
# after: ValueError; pass a chain of neighboring bins
simulate_trajectory_laps(env, n_laps=2, outbound_path=[0, 1, 2])
Changed¶
-
Assisted outlines now start with 1.4 px polygon simplification; set Polygon detail to 0 to restore exact mask boundaries. Setup instructions and launcher help describe the SAM 2.1 Large checkpoint and current prompt controls. The guide and source comments credit CVAT's interaction design and link to pinned upstream references for the workflow and defaults.
-
Assisted annotation previews now show the exact polygons that Accept outline commits. Polygon detail adjusts pixel tolerance and reports vertex count without rerunning inference; holes and components survive simplification, painting, undo/redo, and project saves.
- Mark points supports left-click inside, right-click outside, Alt-click removal, Undo last point, and Delete selected. Native selection and painting tools remain available.
-
An optional Region of interest crops inference inputs and maps masks back to original image coordinates. Editing the crop resets SAM refinement and rejects stale predictions; prompts outside it require correction.
-
SAM 2 assisted annotation now refines new clicks using the last displayed prediction's raw logits. Moving/removing prompts or clearing/discarding an outline resets refinement; stale background results cannot seed later requests. Painted previews remain separate from model state, and accepted annotations record the number of refinement steps.
-
Assisted annotation now removes unprompted disconnected fragments from the preview by default. Remove stray fragments can be turned off to compare the original prediction without another model call. Cleanup preserves holes and every region containing an inside click, and records removed fragments in accepted annotation metadata.
-
Assisted annotation previews update automatically as inside/outside points or CPU color tolerance change. Outlines keep the point tool active, stale results cannot be accepted, and paint/erase correction remains available explicitly. Optional calibration and output settings start collapsed above the outlining controls, with a one-line summary of the units and bin size.
-
Polygon annotation uses Done to validate and return the result;
Escapekeeps napari's normal finish/cancel behavior. Save Project andCtrl/Cmd+Ssave editable work separately, while Undo and Redo include accepted assisted outlines and calibration changes. -
More results own their arrays as read-only copies that survive pickle and
copy.deepcopy, like the other analysis results:ShuffleTestResult(itsnull_scoresbecame writable again after copying),CircularBasisResult(which kept the caller'sanglesarray, so editing it changed the stored angles but not the sine and cosine; it is now also frozen),ViewshedResult,PlaceFieldsResultandPopulationCoverageResult.PlaceFieldsResult.fieldsandPopulationCoverageResult.place_fieldsare now tuples, so fields cannot be appended to or replaced; copy withlist(...)to edit. -
compute_directional_place_fieldsleaves out frames labeledNone,NaNormissing_label, and recordsmissing_labelon the result and insummary(). Before,NoneandNaNlabels raisedTypeError. animate_fieldscolor limits default to robust ones in every backend: the 1st percentile of the frames' minima and the 99th of their maxima, instead of the minimum and maximum over all frames. One extreme frame no longer dims the whole animation; about 1% of frames reach past each limit and show its color, and a bottom below a thousandth of the top is zero. Passvminandvmaxto keep the old range or to compare animations on one scale.estimate_colormap_range_from_subsetuses the same rule on its sample of frames (itspercentileis now of the frames' minima and maxima, not of all values, which put a sharp posterior's peak past the top in every frame). The unusedrendering.compute_global_colormap_rangeis removed.env.plot()draws everything the environment holds by default: declared walls (labeled with their names), landmarks (labeled, with the axes widened to show any outside the arena) and regions.show_regionsnow defaults to True;show_walls=Falseandshow_landmarks=Falsehide the new layers.env.plot()fills active grid cells with one light color whether or not some bins are inactive (it was white on a full grid and dark gray otherwise), leaves inactive cells unfilled, and outlines a polygon grid's polygon instead of shading over the bins.-
The
animationextra allows napari 0.5 through 0.9 (napari[all]>=0.5,<0.10); it was capped below 0.6. The napari tests pass on 0.9.2. The floor rises from 0.4.18 to 0.5, whoseborder_colornames the animation and annotation code already uses. -
Speed is one definition everywhere: the velocity vector smoothed with a 0.1 s Gaussian (spyglass's default, as in non_local_detector's
get_velocity), then its magnitude. Newneurospatial.behavior.compute_speedreturns it. It takesbreak_beforeto declare recording joins on one clock, never smooths across a declared break, a lost frame, a gap longer thanmax_gapor anepochsedge, and needs strictly increasing, non-emptytimes. Everymin_speedgate uses it: rate maps and their significance tests,decode_session,segment_by_velocity,heading_from_velocity,heading_direction_labels, and the decision, VTE and goal metrics. Before, speed came from differencing raw positions, so tracking jitter read as running: an animal sitting still with 0.5 cm of jitter passed a 4 cm/smin_speed98-100% of the time at 30-500 Hz (now under 1%), and a 20 cm/s run read 210 cm/s at 500 Hz.segment_by_velocityhad averaged the speed's magnitude, which does not cancel jitter. Speed-filtered results change, most near movement onsets and stops, which the smoothing widens by about 0.1 s.heading_from_velocity(bandwidth=)(Gaussian sigma in samples, default 0) is replaced bysmooth_window=(seconds, default 0.1), andsegment_by_velocity(smooth_window=)is now that Gaussian's standard deviation (default 0.1 s) instead of a 0.2 s moving-average width. Velocity is now the centered difference, so a run's first sample has a heading from its forward difference instead of being undefined. -
A position exactly on the outline of a grid environment's bins -- the arena's edge, the rim of a hole or the tip of an arm -- now belongs to the bin it bounds, as the last bin of
np.histogramincludes its right edge. Before, bins were half-open on every face, sobin_atgave-1there and occupancy, spike counts and point regions dropped it: every reward well at an arm tip of the built-in W, plus, T and radial-arm mazes covered no bin, and an animal sitting against the wall was not counted. A face shared by two bins still has one owner,time_allocation="linear"follows the same rule, and inEnvironment.combinea face shared by two components keeps its half-open owner. -
segment_trialsstarts a trial when the animal leaves the start region, at the last sample inside it, instead of when it enters. Waiting at the start well no longer counts toward the trial, so tracking jitter across the well's edge after a wait is no longer recorded as an aborted (failed) trial. On a simulated W-track with 1 cm jitter, 4 of 16 trials came out as false failures before this change. A real return to the start region is still a failed trial when it lasts at leastmin_duration. Samples outside every bin (-1, such as lost tracking at a well) keep the last located region, so a dropout is neither a departure nor a return.Trial.start_timeand trial durations change accordingly. -
The
neurospatial.layout.LayoutEngineprotocol lists onlybuild()andbinning(), the two methods an environment uses. A custom engine needs nothing else, and type checkers no longer ask it forbin_centers,grid_edges,plot()and the other members that the built-in engines offer for working with an engine directly. -
to_linear(),linear_to_nd()andwith_track_width()on a polar environment now say what is actually wrong: it is not a track (for example "to_linear() needs a linearized track ..., but this is a Polar environment, which has no track"), with the same advice every other non-track environment gets, such asenv.bin_at(positions)instead ofto_linear. They used to answer with the Cartesian-coordinates refusal. The error is still aTypeError, but no longer aCartesianOnlyError, and these three are no longer listed among the Cartesian-only members. -
Positions or points with the wrong number of columns get the same error from every operation that maps them to bins (
bin_at,contains,occupancy,bin_sequence,bin_sequence_with_runs,transitions,interpolate,points=queries such asdistance_to, geodesiccompute_egocentric_distance). It names the operation, the shape given and the shape needed, for exampleEnvironment.occupancy got positions with shape (500, 3), but this environment's bins are 2-D, so positions needs shape (n_positions, 2)., and says how to fix it, such as reshaping a 1-D array on a 1-D environment or using the original coordinates instead of linearized ones. Rate maps and other encoding functions that report several input problems together describe this one in the same words. It is still aValueError; only the wording changed. -
Environment.from_bins(binning)is annotated as taking aBinning. An object from another package that an installed snapshot adapter converts still works at runtime, but a static type checker now flags the call; wrap it astyping.cast(Binning, obj)there. -
nearest_binon an egocentric polar environment 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, so its distances,max_distanceand the default far-point limit are in the environment's length units and angles a whole turn apart name one direction. It used to compare the stored columns as if centimeters and radians were one unit, and could return a bin on the other side of the animal. Results change for polar environments only.bin_center_tree()is still the tree over the stored columns. -
Every
Environmentmethod that returns bin indices returnsnp.intp, the dtype NumPy uses for indexing, asbin_atalready did.bin_sequence,bin_sequence_with_runs(...).bins,componentsandringsreturnedint32, andnearest_binwas declaredint64; all are nownp.intp(64-bit on 64-bit systems). The values are unchanged.neighborsandpath_betweenstill return lists of Python ints. -
env.smoothtakes the same field layout asenv.diffuse: one field of shape(n_bins,)or a batch of shape(n_bins, n_fields), one column per field. It used to refuse any 2-D array. An array with its bins on the second axis, such as population rates(n_units, n_bins), is refused by both withFix: pass field.T(orfields.T) instead of a bare shape error, and passing aSpatialRateResultorSpatialRatesResultraisesTypeErrornaming the array to smooth:env.smooth(result.firing_rates.T, bandwidth=...).T. -
Common construction mistakes now say what failed, why and how to fix it, instead of surfacing an internal error:
- a transposed
positionsarray of shape(n_dims, n_samples)infrom_samplesoropen_fieldraisedExceeds the limit (4300 digits) for integer string conversion; it now says the grid would need too many bins and to passpositions.T; - non-numeric positions name the conversion to apply
(
df[['x', 'y']].to_numpy(dtype=float)); - a shapely polygon passed as
arena=asked to install an adapter package; it now says to passarena=OpenArena(polygon); Environment.from_bins(env.binning)shows the two lines that build the faces first;env.rebin(1.5)raisedTypeError: 'float' object is not iterable; a factor that is not a whole number now raisesValueErrorsuggestingrebin(2)orrebin(bin_size=...)with the matching size;env.apply_transform(np.eye(3))raisedAttributeError; a matrix now raisesTypeErrorsaying to wrap it,AffineND(matrix);linear_track(endpoints=[0, 200])raised'int' object is not iterable; it now shows the (x, y) points to pass;- a track graph whose nodes lack
pos(from_graph,maze(track_graph=)) raisedKeyError: 'pos'; it now names the node and how to setpos; - a
bin_sizesequence whose length differs from the number of position columns, or any sequence withlayout="Hexagonal", names the expected length instead of an internal argument; dimension_rangesthat is not one finite(min, max)pair per position column raisesValueErrornaming the rule it breaks (the number of pairs, a value that is not finite, or a minimum that is not below its maximum) and showing the pairs for your positions. A reversed pair such as(100, 0), a zero-width pair such as(50, 50)or a non-finite pair used to build a grid silently;rebinon bins that are not a rectangular grid gives a call that rebuilds those bins at the new size:from_samples(..., layout="Hexagonal")for hexagons,from_graph(env.binning.linearization.graph, ...)for a track,from_layout(create_layout("TriangularMesh", ...))for a mesh,env.rebin(bin_size=...)for a clipped grid, orenv.smooth(field, bandwidth=...)for a coarser scale. A grid with unequal cells says so;-
rebinfactors that are not integers (including2.0), are 0 or larger than the grid, or have the wrong length; a 3-D transform passed toapply_transformon 2-D bins; threeendpointsforlinear_track(usenode_positions); bare numbers inmaze(node_positions=...); anedge_spacingsequence with the wrong number of gaps;bin_sizeas text; a list of points passed tofrom_polygon;grid_edgesthat do not fit thefrom_grid_maskmask; and a list of names passed toregion_membershipall name the call that works. The suggested code in every refusal is run by the test suite on the input that was refused. -
An infinite coordinate in the positions given to
from_samples,open_fieldorenvironment_from_positionis treated as a missing sample, like NaN, which these factories already leave out. It used to raiseRequested binning overflows(grids) orOverflowError(hexagons). -
Every lookup of a region by name raises
RegionNotFoundErrorwith the closest existing name, for exampleFix: pass region_name='reward'forenv.bins_in_region("rewrd"). This coversenv.regions[name],bins_in_region,subset(region_names=...), theRegionsediting methods (remove,update_region,region_center),region_reward_field,compute_region_coverage,decision_region_entry_timeandcompute_decision_analysis, which used to raise a bareKeyErroror aValueErrorwithout a suggestion.RegionNotFoundErroris both aKeyErrorand aValueError, so existingexceptblocks still catch it. -
spatial_autocorrelationraisesNotImplementedErrorinstead ofValueErroron tracks, meshes, 3-D grids and grids with fewer than half of their cells active, the type used for every environment whose geometry cannot run an analysis. Code that caughtValueErroraround it should catchNotImplementedError; see the migration page. Rate-mapgrid_score()andsummary_table()still report NaN there. -
env.occupancy(..., time_allocation="linear")on bins without cell shapes (bins passed toEnvironment.from_binswith only centers and sizes) raisesNotImplementedErrorinstead of aValueErrorabout locators, and its message, like the one for tracks, says why linear allocation needs each bin's outline and points totime_allocation="start". -
On a track,
bin_atandnearest_bin(tie_break="closest_center")search one KD-tree, so a position exactly equidistant from two or more bin centers goes to the same bin in both.bin_atused a tree of its own and could pick another of the equidistant bins; for such positions its answer can differ from earlier versions. Every other position gets the same bin as before. -
A track graph attribute that cannot be saved at all, such as a function or a lock, is refused when the track environment is built, with a
TypeErrornaming the node or edge and the attribute. It used to fail later, when the environment was pickled or written. Attributes that pickle can store but JSON cannot (sets, dates, NaN) still build;to_file,to_dictand NWB export refuse them with aValueErrorthat now names the attribute. -
On a polar environment, wall geometry (
border_score, view-cell rates with walls,boundary_source=...) andtime_allocation="linear"rate maps raiseCartesianOnlyErrorinstead ofNotImplementedError, like every other operation that reads polar bins as Cartesian positions. Wall geometry on a 3-D environment still raisesNotImplementedError. The error reference page lists the two error types for environments that cannot run an analysis. -
The linear track of
simulation.mazes.make_plus_mazenumbers its arms in the order they are laid out along the linear position, clockwise from north (north 0, east 1, south 2, west 3), asEnvironment.maze(kind="plus")does. East and south used to have each other's number in thesource_edge_idof each bin and theedge_idof the track graph. Bin positions, linear positions and every analysis are unchanged. The simulated T and plus mazes are now built withEnvironment.maze. -
env.plot()names the axes of a 2-D environment by what the coordinates mean, asenv.plot_field()does: "X Position (cm)" and "Y Position (cm)" on a Cartesian environment, and "Distance (cm)" and "Angle (rad)" with a free aspect ratio on a polar one. It used to label every environment "Dimension 0" and "Dimension 1" (or "X coordinate" and "Y coordinate"). -
On an egocentric polar environment,
env.component_keys,env.bin_sourcesandenv.get_bridge_attributes()give the answers of any environment that was not combined: an empty tuple,Noneand an empty table. They used to raiseCartesianOnlyError, although they read only the records thatEnvironment.combinekeeps and never the coordinates. -
env.occupancy,env.bin_sequence,env.bin_sequence_with_runsandenv.transitions(times=..., positions=...)warn when themax_gapgate and the other interval gates exclude every interval, and when the median sampling interval is longer thanmax_gap, which usually meanstimesare not in seconds (milliseconds turn every frame into a recording gap).occupancyalso warns when no interval keeps any time because every sample is NaN or outside, with eithertime_allocation. They used to return an empty or mostly empty result silently. The same warning comes fromcompute_spatial_rate(s)and the behavior analyses that share the gate, which now also warn about a median interval longer thanmax_gapwhen some intervals survive. -
env.smoothandenv.diffusewithmode="average"treat NaN bins as missing observations instead of zeros. They smooth the field with NaN read as 0 and the observed mask with the same operator, then divide, so holes are filled from their observed neighbors and a constant field with holes stays constant. A bin stays NaN when the share of its smoothing weight on observed bins is at most 0.1% of the largest share in its connected part of the environment, which includes every bin of a part with no observed bin. Binned rate maps andresample_field(method="diffuse")use the same masked-average code, each with its own far-field floor (a bandwidth-dependent one for binned maps, none for resampling), so their NaN bins far from observations can differ fromsmooth's.mode="transition"andmode="density"refuse a field with NaN, because missing bins have no defensible mass under a conserving operator: fill them explicitly, or usemode="average"for an intensive field.diffuse(backend="jax")refuses a concrete field with NaN and points tobackend="numpy". Before,smoothrefused any NaN anddiffusesilently turned every bin NaN. Fields without NaN give exactly the same values as before. -
compute_decision_analysisrepresents each goal region by the region bin nearest the centroid of the region's bins, the same bintrials_to_region_arraysuses. It used the lowest-index bin, which depends on how bins are numbered and sits at the region's corner; the new choice depends on numbering only for ties, where the lowest index among the equally near bins is chosen. The goal labels anddistance_to_boundarychange for goal regions that cover more than one bin. -
env.connectivityis built from the environment's bins for every layout, so it carries the same node and edge attributes everywhere (pos,source_grid_flat_index,original_grid_nd_index;distance,vector,edge_idand, in 2-D,angle_2d), pluspos_1Dandsource_edge_idon tracks. Graph, node and edge attributes that a layout engine adds to its own graph are no longer published; keep such values in your own arrays indexed by bin ID, or annotate the graph returned byenv.to_networkx(). -
Environment.from_binson a binning with a triangular-mesh locator numbers each node'ssource_grid_flat_indexandoriginal_grid_nd_indexby its triangle, as built mesh environments do and asenv.grid_shapeandenv.active_maskdescribe; it used to give the bin ID. On a binning with a track linearization,from_binsnow adds each node'spos_1D(linear position) andsource_edge_id(track-graph edge), as track factories do. -
env.with_metadata(...)andenv.with_regions(...)share their parent's analysis cache instead of starting with an empty one, because the bins are the same, so renaming an environment keeps the kernels and eigenbases already computed. Clearing the cache of one clears it for all of them. Derived environments (subset,clip,rebin,apply_transform,combine) and environments loaded from files, dicts, NWB or pickles start with their own empty cache. Each cached analysis is computed once even when several threads request it at the same time.smooth,diffuseandget_mrf_basiscalls 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; use worker processes for parallel smoothing. -
A factory given an
arena=that leaves out some bins (for examplefrom_grid_mask(..., arena=OpenArena(polygon))) selects the bins before building the environment, instead of building it, subsetting it and building it again. The bins, graph and ranges are unchanged, butlayout_typeandlayout_parametersnow describe the factory's layout (for example"MaskedGrid"withconnect_diagonal_neighbors) whether or not the arena dropped a bin; they used to switch to a subset record. -
dimension_rangesis the extent of the bins' cells for every layout, andEnvironment.subset()reports the extent of the cells it keeps, with no added margin, so a subset never reports a larger range than its parent. Hexagonal environments report their hexagons' extent, corners included, instead of the range the tiling was asked to cover (a tiling requested over(0, 30)with 4 cm hexagons reports(-2, 36)); triangular meshes report their boundary's bounds instead of the bounds of the triangle centroids; tracks report the centerline their bins cover, end to end, instead of the bin-center bounds (a 200 cm linear track reports(0, 200), not(1, 199)). Subsets of hexagonal, triangular-mesh and clipped polygon-cell environments (env.clip(...)) used to add 5% on each side; a mesh subset now bounds only its kept triangles, so a subset of one corner of a mesh no longer reports the extent of the whole mesh, and a track subset bounds the centerline its kept bins cover.apply_transformreports the extent of the transformed cells rather than the box around the transformed ranges, which was wider after a rotation. Grids report the extent of their active cells: a grid subset reports the cells it keeps rather than the whole lattice it keeps ingrid_edges, and a grid whose border rows or columns are all inactive reports its active cells. Napari draws a grid field over the full lattice, so its overlay and scale-bar mapping now readgrid_edgesandgrid_shape, with each pixel centered on its cell (overlays used to be stretched by up to half a pixel). Readers ofdimension_rangeschange with it on hexagonal, mesh, track and grid-subset environments: plot limits (a hexagonalenv.plot()is framed by its cells' extent, the same limits asenv.plot_field(), with no extra margin),env.info(), the automatic scale ofops.spatial_basis, the "bandwidth spans the environment" warning, the uniform field placement ofsimulate_session, and the extent animation overlays and an uncalibrated video are drawn over. The same applies to the bins anarena=removes inEnvironment.from_layout,from_grid_maskandfrom_pixel_mask. ForEnvironment.from_bins, the ranges of triangular-mesh bins bound the triangles' vertices rather than every stored mesh point. The migration page has a table for each layout. -
A
CartesianOnlyErrorfrom a Cartesian-only method or property, such assubset,rebinorinterpolate, names the class of the environment that refused the call. On a subclass ofEnvironment,polar_env.interpolate(field, points)reportsMyEnvironment.interpolate() does not work on a polar environment., for the bound call and forEnvironment.interpolate(polar_env, field, points)alike. -
Derived environments no longer repeat the warning about unrecognized units.
-
Breaking: Cartesian and egocentric polar factories now return one public
Environmentclass. Useenv.coordinate_kindfor coordinate meaning andEnvironment.from_polar_egocentric(...)instead of the removedEgocentricPolarEnvironment.create(...). Radius remains in length units and angle in radians; polar Cartesian-only calls still refuse, including unbound calls. Factory-inferred static types preserve those restrictions. Supported dictionary, file and NWB records retain their coordinate semantics. -
Points in discarded subset bins map to
-1rather than a nearby retained bin. Non-uniform scaling of hexagonal or mesh environments and general affine maps disable finite-volume smoothing with an explanation. Tracks refuse non-similarity transforms; rebuild them from transformed graph nodes instead. -
Environment files and dictionaries now use
Environment-v3; NWB environment records use schema3.0. They preserve Arena, walls, exact cells and clip mappings without rebuilding layouts. Version 2/2.0remains readable with an explicitly unknown Arena. Version 1/1.xmust be recreated with a factory. - Polygon-aware lookup excludes points outside the declared domain and in its holes. Linear occupancy subdivides entry, exit and re-entry intervals on exact polygon support, and refuses unresolved physical-wall crossings. Default encoding and occupancy retain their existing interval-allocation conventions.
-
Rebinning preserves the original arena and selects coarse bins by center, including inferred mask holes. Physical walls remain closed; coarse cells that cannot represent them are refused.
-
Point lookup consistently rejects incorrect coordinate widths, including hexagonal inputs with extra columns and empty or non-finite grid queries.
-
Environment.bin_sizesnow returns an owned, read-only array, likebin_centers. -
Rewrite all nine tutorials around spatial neuroscience questions, using simple technical English, explanations of analysis choices, figure interpretation, and instructions for using recorded data. Update the shared catalog and keep example-data setup separate from the main analysis.
-
Consolidate the 27 feature-oriented notebooks into nine standalone task tutorials covering environments, rate maps, behavior, real data, simulation, held-out decoding, events, visualization and NWB. Combine the J16 neural and maze analyses, add executable specialized recipes in the user guide, and update catalog, links, mirrored outputs and full-collection CI.
-
Breaking:
env.smoothnow defaults tomode="average", preserving the units and level of a rate map. Passmode="density"explicitly to retain the previous count-to-density default; kernel constructors are unchanged. -
Breaking: field detection and population coverage now default to no map-mean exclusion (
max_mean_rate=np.inf). Passmax_mean_rate=10.0to retain the previous filter. Its unweighted spatial-bin mean is not a recording-time mean or a cell-type verdict. Coverage displays report maps with segmented fields rather than asserting cell identity. -
Review all 27 tutorials for coherent workflows, current APIs, valid geometry, observed support, independent simulation randomness and calibrated scientific language. Correct rate-map and direction labels, add held-out decoding, preserve real-data transition endpoints/gaps, and make desktop viewers and long video exports opt-in. Refresh worked outputs and align prerequisite links.
-
Directional place fields, decode summaries, batch scores, behavior metrics and run bins, assembly results and trajectory-fit results now own read-only copies of their array fields, including after pickle/deepcopy. Directional mappings also prevent replacing entries. Behavior change: copy arrays before editing values. Decode-summary xarray exports remain editable copies.
- Rate and peri-event results store read-only copies of their arrays. This
covers spatial, directional, view and object-vector results (single-unit
and population) and both peri-event results. Editing an array a result was
built from no longer changes it, and editing a result's arrays in place, for
example
rates.firing_rates /= peak, raises instead of silently changing what the result reports. Breaking: copy before modifying values:normalized = np.array(rates.firing_rates) / peak.to_xarray()andto_dataframe()return editable copies; editing ato_xarray()Dataset used to edit the result. interpolate(mode="linear")andoccupancy(time_allocation="linear")work on environments built withfrom_polygon,from_grid_maskandfrom_pixel_mask, and on grid environments read from NWB. They previously required aRegularGridlayout. Inactive cells behave as on maskedfrom_samplesenvironments.- Polar environments no longer define the twelve Cartesian methods. Accessing
one raises
CartesianOnlyError, both aNotImplementedErrorand anAttributeError, with the same message.hasattrreports them as absent and type checkers flag the call. Refusals inside shared methods name the call that was made. - Behavior change: an environment's geometry is read-only once built:
its geometry attributes cannot be reassigned,
bin_centers,active_maskandgrid_edgesreject writes, andconnectivityis frozen. Cached kernels and operators cannot see in-place edits, so such edits used to return stale results silently. Build a new environment (a factory,subset,rebinorapply_transform) instead. Factories copy array inputs, so the caller's own arrays stay writable and later edits to them do not reach the environment. - Layout setup synchronizes restored geometry and invalidates all environment
caches. The polar layout builds physical distances and circular seams before
environment initialization, and records its
circular_anglebuild option. - Layout engines, including a polar layout with annular-sector cells, supply their own finite-volume cell faces. Dense kernels, spectral diffusion, smoothness bases, gradients and divergence all use that one capability, and the diffusion consumers share one validated operator snapshot. Numerical schemes are unchanged.
-
Behavior change:
compute_diffusion_kernelsrejects directed graphs and multigraphs, whose reciprocal or parallel edges were summed into doubled conductances. Collapse them first withnx.Graph(graph). -
Extend rate-result
analysis_settingsandspatial_supportto directional, view and object-vector analyses, preserving exact retained intervals and family settings through indexing, tables and NetCDF-compatible xarray exports. Shared binning preparation keeps counts, occupancy and provenance aligned. -
Behavior change: view and smoothed object-vector encoders now use the same unit-aware physical bandwidth default as spatial encoding. Polar results inherit the supplied environment's distance units; angular smoothing retains its existing units and defaults.
-
Behavior change: spatial encoding defaults to a 5 cm smoothing bandwidth in declared metric units (0.05 m / 50 mm), or 2.5 median graph-edge lengths for unspecified/custom units. Explicit bandwidths retain their coordinate units; bandwidths spanning the environment warn about lost spatial tuning.
-
Align unit-indexed metadata by identity in spike containers and population results. Reject missing, duplicate or conflicting metadata identities; an unnamed default RangeIndex retains positional interpretation.
-
Breaking: video and HTML animation exports default to
overwrite=False, refusing existing outputs before rendering or dry-run estimates. HTML checks its default filename and nonempty frames directory before creating directories; ffmpeg uses-nunless overwrite is explicit. Environment saves share the same paired-target check and corrected-call wording. -
Breaking: rate summary tables lead with rate/information, place classification last, and carry estimator, physical units, direction frame and resolved classification thresholds in
DataFrame.attrs. Singular/population columns match; constantmethodis no longer a summary column, and labels are documented as heuristic screens. -
Breaking: population rate summaries name
n_unitsandmax_peak_firing_rateexplicitly. Shared occupancy remains seconds observed once; singular summaries add cheap family metrics and stay safe for all-NaN maps without computing grid/border scores. -
Breaking: NWB position, head-direction and units readers return frozen, non-iterable
NWBPosition,NWBHeadDirectionandNWBUnitsholders. Position holders expose normalized declared physical units without a second scaling step or an assumed unit; eager/lazy reads retain their existing conversion and lifetime contracts. -
Breaking: simulation validation and summary plotting each take a required
SimulationSessionwith keyword-onlyunit_idsselection by label. The raw keyword validation form and row-index selection keywords are removed; unknown labels explain the available labels and corrected call. -
Breaking: align
SimulationSessionattributes with analysis inputs:spike_times, integerunit_ids, and ground truth keyed by unit label. The frozen holder checks that spikes, labels and models align one-to-one; seeded trajectory, spike and duration behavior is unchanged. -
Breaking: require tracking positions in spatial rate computation, decoder
fit/score, anddecode_session(_summary). Session decoding always encodes tracking and no longer acceptsencoding_models=; estimator predictions take only spikes and array timestamps and reuse the extracted gap-aware full/streamed model decoders. -
Breaking: standardize required trajectory pairs as
(times, positions)inapproach_rate,compute_decision_analysis,compute_goal_directed_metrics,compute_path_efficiency,compute_pre_decision_metrics,compute_vte_session,compute_vte_trial,extract_pre_decision_window,goal_bias,head_sweep_from_positions,instantaneous_goal_alignment,mean_square_displacement,pre_decision_heading_stats,pre_decision_speed_stats,segment_by_velocity,time_efficiency,heading_from_velocity,visibility_occupancyandgenerate_population_spikes. Placeenvfirst incompute_region_coverage,field_shape_metrics,rate_map_coherence,map_points_to_bins,shuffle_place_fields_circular_2dandcalibrate_video. Rename the spike parameters onbin_spikes_in_time,population_peri_event_histogram,restrict_spike_trainsandvalidate_simulationtospike_times. Analysis entry points take explicit arrays rather than aPositionLikeadapter. -
Breaking: rename the phase-precession callable to
compute_phase_precession, preserving the siblingencoding.phase_precessionmodule for normal imports. -
Breaking: rename place-field detection to
has_place_field;is_place_cellnow requires a classification criterion. Align free predicates, result methods and batch screens on read-only threshold defaults and inclusive cutoffs, make method thresholds keyword-only, rename spatialclassify(min_spatial_info=)tomin_info, and propagate invalid-input errors. -
Breaking: default
ObjectVectorCellModelto allocentric direction tuning without headings, which changes simulated spikes, without an error, for code that relied on heading-relative tuning. Setdirection_frame="egocentric"to retain heading-relative behavior; model ground truth records the frame and missing-heading errors explain both choices. -
Plot object-vector tuning in its recorded frame: allocentric zero points East and positive angles turn toward North; the corrected egocentric ahead/left orientation is retained. Frame-specific literature and direction conventions are explicit.
-
Rename object-vector results to
ObjectVectorRateResultandObjectVectorRatesResult, and their information accessor tospatial_information(). Removed names have no aliases; indexed population results preserve their frame. -
Environment.combine, andsubsetand loading of a combined environment, check component overlap only for cells whose bounding boxes intersect instead of every pair of cells. Combining two 1 m square arenas at 2 cm bins takes about 2 s instead of 21 s; the overlap check for two 1 m arenas at 1 cm bins takes 0.9 s instead of 141 s. The same overlaps, contacts and error messages are reported.
Added¶
-
examples/try_annotation.pylaunches CPU or optional SAM 2 assisted annotation on a sample arena, image, or video frame. A--checkmode runs sample inference without opening a viewer. -
Resumable polygon annotation projects:
AnnotationProjectandAnnotationShapesave editable pixel geometry, calibration, output settings and the RGB reference frame in one portable file. Shapes compare by value, and undo history shares unchanged shapes between steps.annotate_videoacceptsproject_path=andautosave_interval=(30 seconds by default,Noneto disable);annotate_projectresumes without the original video.AnnotationResult.calibrationreturns the calibration the outputs use, including one applied in the editor, to map pixel tracking into the environment. Both entry points andAnnotationProject.saverefuse to replace an existing file other than the one being resumed unlessoverwrite=True, and refuse aproject_pathwhose folder does not exist.annotate_videochecks the calibration and settings against the video frame before the editor opens. If the final draft save fails, the validated result is still returned with a warning. Draft saves retain unfinished geometry, including shapes copied and pasted in napari; Done validates the exported result. - The annotation dock can calibrate a known distance from two marked endpoints,
converting a pixel output bin size and export simplification so the grid
keeps its size in the image (replacing a calibration keeps centimeter
values), and propose outlines from foreground/background clicks or a box
drawn around the area (with SAM 2 a box alone is enough; region growing
stays within the pixels the box overlaps). The default
RegionGrowProvideruses CPU color segmentation, and its preview can be painted or erased before acceptance; while painting, Ctrl/Cmd+Z undoes brush strokes.SegmentationPrompt,SegmentationProposal,SegmentationProviderandmask_to_polygonsalso work without a viewer, preserving disconnected areas and holes. OptionalSegmentAnythingProvider.from_sam2(checkpoint_path)loads separately installed Meta SAM 2.1 Large, the only supported size because smaller models flood dark arenas, on a CUDA GPU, then Apple silicon, then the CPU. Its errors give the install command, the checkpoint link, and the arm64 check for an Intel Mac Python; model libraries and weights are not bundled or downloaded automatically. The editor computes SAM 2's image features as soon as it opens, and asks for candidate masks only for a single click, as Meta recommends. -
The annotation editor opens with the whole video frame in view. Its assistance panel shows Accept as (the role and name an outline will get, mirroring Annotation Controls), puts Units and bin size first with a live estimate of the environment's bin count, offers Left-click adds: Inside / Outside for mice and trackpads without a right button, and draws outside points as an orange × rather than a red dot. Warnings about a result, such as a region outside the environment, appear in a Check Annotations dialog before the editor closes, with Keep Editing or Return Results.
-
neurospatial.encoding.population_vector_correlation(firing_rates_1, firing_rates_2, *, spatial_support, unit_selection, bin_mapping, topology)returns the population-vector correlation at each location between two conditions, the remapping measure of Leutgeb et al. (2005): one value per bin, the Pearson correlation across cells of the rates at that bin. Pass the twoSpatialRatesResult: units are paired byunit_ids(unit_selection="intersection"or"exact", as incompare_spatial_rates), generated row-position ids are refused, bins are paired by place (different environments needbin_mapping=, andtopology=is checked), and only bins observed in both conditions are compared. It warns, likecompare_spatial_rates, when the conditions were estimated with differentmethod,bandwidthormin_occupancy. Plain(n_units, n_bins)arrays are also accepted; then the rows must be the same units in the same order, andspatial_supportmust be passed, because smoothed maps have finite rates in bins the animal never visited. A location is NaN, never 0, when a rate is NaN in either map, the bin is not supported, or every cell has exactly the same rate there. Summarize with the median over defined locations and report how many were undefined.
pv = population_vector_correlation(before, after) # two SpatialRatesResult
defined = np.isfinite(pv)
print(np.median(pv[defined]), (~defined).sum())
break_before(True at the first sample of each later recording) is accepted wherevermax_gapis: rate maps (compute_spatial_rate(s),compute_directional_rate(s),compute_view_rate(s),compute_object_vector_rate(s),compute_egocentric_rate(s),compute_boundary_vector_rate(s),compute_directional_place_fields),has_place_field, theis_*_cellpredicates and*_cell_significancetests,decode_session,decode_session_summary,BayesianDecoder.fit/predict/predict_summary/score,DecodingResult.error_against/plot, the behavior segmentation, visit and goal functions (detect_region_crossings,detect_region_visits,detect_runs_between_regions,detect_laps,segment_trials,segment_by_velocity,running_direction_labels,heading_direction_labels,detect_goal_directed_runs,goal_bias,instantaneous_goal_alignment,compute_goal_directed_metrics,compute_home_range),Environment.occupancy,bin_sequence,bin_sequence_with_runsandtransitions,neurospatial.events.add_positionsandneurospatial.simulation.generate_population_spikes. A declared break is treated exactly like a gap longer thanmax_gap, even when the clock does not pause: the interval across it adds no occupancy or spikes, speed and heading are not smoothed across it, shuffle nulls respect it, no trial, run, lap, visit, transition or decode bin spans it, and positions are not interpolated across it. Recordings joined on one clock no longer need to be converted toepochsby hand (touching windows merge, so that never worked anyway). Rate results and fitted decoders record the break times inanalysis_settings["break_times"](Nonewhen not given). A head-direction cell's held direction does not carry over a break: each recording starts with its own first direction of movement.
new_recording = np.diff(recording_ids, prepend=recording_ids[0]) != 0
rates = compute_spatial_rates(
env, spike_times, times, positions, break_before=new_recording
)
detect_assemblies(..., unit_ids=)stores unit labels on the result and on eachAssemblyPattern, andpattern.member_unit_idsnames the core members.assembly_activation(..., unit_ids=)andreactivation_strength(..., template_unit_ids=, match_unit_ids=)match the columns of new spike counts to the pattern by label, so each period may list its units in its own order.explained_variance_reactivation(..., template_unit_ids=, match_unit_ids=, control_unit_ids=)takes the column labels of the counts each period'spairwise_correlationscame from, and compares the correlations of the same two cells in every period. A label missing on either side raises and is named, and labels given for only some periods raise. Without labels, pairing stays positional, as before.Environment.bin_mapping_from(parent)returns aBinMapping(neurospatial.environment) of exact bin pairs between an environment and the one it was derived from: identical cells (subsets, cells a clip did not cut, identical rebuilds) and whole fine cells inside coarser cells on the same lattice (rebin). Cut cells, and grid cells whose boundary points the coarser grid gives to another cell, are reported as split instead of paired, and the child bins they touch get NaN in transfers; refinements, offset or rotated lattices and different units or frames are refused.sumcarries counts, occupancy and probability mass with NaN kept as missing,ratespools rates from summed counts and occupancy (rates are never averaged), andcategorykeeps a label only where all merged bins share it.mapping.pairsis thebin_mapping=thatcompare_spatial_ratesandpopulation_vector_correlationtake. These functions andbin_mapping_fromnow share one definition of the same cell: polygon cells (for example the cells a clip kept whole) are the same cell when their polygons are equal and their centers and areas agree to rounding. Before, clip cells whose centroids differed in the last digits were refused as different places; this also applies wherever two environments are checked for the same bins, such as decoding with rates estimated on another environment and reading fields from NWB.
part = env.subset(mask=in_center)
mapping = part.bin_mapping_from(env)
compare_spatial_rates(full_rates, part_rates, bin_mapping=mapping.pairs)
coarse = env.rebin(2)
coarse_counts = coarse.bin_mapping_from(env).sum(fine_counts)
neurospatial.behavior.labels_to_epochs(times, labels, *, valid, break_before, max_gap, epochs, missing_label)turns one label per frame (running direction, trial type, task phase, door state) into half-open[start, stop)windows for each label, returned as aLabelEpochsResultmapping each label to its windows. Passconditions[label]asepochs=to a rate map or decoder together with the original arrays. The time between two frames takes the label of the frame at its start and is kept only when both frames arevalid, no more thanmax_gapapart, inside oneepochswindow and not across abreak_before; a dropout or gap ends an epoch, so the same label afterwards starts a new one.None,NaNandmissing_labelmark unlabeled frames.durationsgives the time per label andepochs_table()one row per epoch;input_epochsrecords theepochs=windows that were applied.neurospatial.encoding.compare_spatial_rates(rates_a, rates_b, *, method, unit_selection, bin_mapping, bins, topology, min_common_bins)correlates each unit's rate map between two conditions and returns aRateComparisonResult. Units are paired byunit_ids, never by row order; population maps computed withoutunit_ids=are refused. Each unit is scored only over its own common support: bins occupied for at least each map'smin_occupancyin both conditions where both rates are finite, so a smoothed rate in an unvisited bin never counts. A unit with fewer thanmin_common_bins(default 3) such bins, or a map that is flat over them, getsNaNand astatussaying why, never0.0. Maps estimated on different environments raiseIncompatibleEnvironmentError; equal bin counts are not enough. Abin_mappingmust pair bins one to one at the same places, since rates cannot be averaged onto coarser bins, and a change in connectivity or walls on the same bins (a door or barrier) needstopology="may_differ", which the result records. Different estimator settings between the maps raise a warning. The result givescorrelation,n_common_bins,status,rate_difference(B - A, Hz, NaN outside common support),summary_table(),to_dataframe(),to_xarray(),summary()andplot(); it gives no p-values or remapping verdicts.RateComparisonResult.plot()draws the difference map of a single-unit comparison and a correlation scatter of several units;plot(unit_id=...)maps one unit, including a unit labeledNone. The new outbound and inbound runs workflow goes from direction labels to per-direction maps, a cell-by-cell comparison and decoding on held-out laps.neurospatial.events.align_continuous_to_events(times, signals, event_times, window, *, dt, ...)samples per-frame signals such as speed, heading or the occupied zone at fixed times around each event and returns aContinuousEventResult. A value is taken only where the signal was observed: never across a gap longer thanmax_gap, a frame marked inbreak_before, anepochsedge or an invalid sample, and never beyond the recording. Each feature is declared"linear","circular"(radians, averaged as unit vectors with a resultant length, so headings near ±π average to West rather than East) or"categorical"(integer codes with labels, counted per category; integer codes beyond ±2**53, which float64 cannot hold exactly, are refused).window_coverage="complete"(the default) keeps only events whose whole window was observed for every feature;"partial"keeps partly observed events and reports the number of events behind every mean. Every input event gets a status,"retained"or the reason it was excluded ("outside_recording","outside_epochs","incomplete_support","no_support"), and anoverlappingflag.retain_aligned=Falsekeeps only the summaries, computed in bounded memory. The result exportsevents_table(),aligned_table(),time_course_table()andcategory_counts_table(), plussummary()andplot(); it gives no confidence intervals or significance tests. The new Behavior Around Events guide asks what speed, heading and distance to a wall do around reward.neurospatial.ops.sample_spatial_features(env, fields, positions)reads bin-valued maps (floor texture, zones, heights;fieldsis(n_features, n_bins)) at the animal's positions, andsample_egocentric_features(env, fields, positions, headings, ego_points)reads them at points fixed in the animal's frame (x ahead, y left). Both return aSpatialFeatureResultwithvalues, per-samplesupportand the containingbins. Lookup is by containment only: a position outside the environment, a NaN position or heading, or a NaN map value gives NaN withsupportFalse, and nothing is smoothed or interpolated. Environments whose layout cannot tell whether a position is inside a bin (a track built withouttrack_width, a linearized 1-D track) raiseNotImplementedErrorinstead of assigning the nearest bin.heading_from_velocitytakesbreak_before, likecompute_speed: for recordings joined on one clock, mark the first frame of each later recording, and no velocity or smoothing spans the join, so a jump in position between recordings is not read as running. Touchingepochsrows merge into one window and do not separate recordings.compute_cumulative_distance(times, positions, *, max_gap, epochs, break_before)returns aCumulativeDistanceResult: the distance at every frame since the start of its run of continuous observation (run_distance) and since the first frame (cumulative_distance, which adds across runs but never across a break), each run's length and time bounds, andtotal_distance, which isNaNwhen nothing was observed. Lost frames (NaNrows), time gaps longer thanmax_gap, intervals outsideepochsand frames marked inbreak_before(for example the first frame of each joined recording) end a run.resample_by_distance(times, positions, *, spacing, include_endpoint, max_gap, epochs, break_before)places samples everyspacingalong each run, so routes from sessions with different running speeds and frame rates can be compared sample by sample. TheResampledPathResultkeeps each sample's time on the original clock and the two original frames around it (source_left,source_right,weights);interpolate_signal(values)carries a continuous per-frame signal such as speed onto the samples,samples_table()gives one row per sample, andbreak_beforemarks run starts for passing the resampled path on withmax_gap=None. The trajectory guide compares two sessions filmed at 30 and 120 Hz.-
compute_planar_turn_angles,compute_planar_curvatureandcompute_movement_angular_velocity, each(times, positions, *, max_gap, epochs, break_before), return one value per frame in the x-y plane, positive for left (counterclockwise) turns andNaNnext to a lost frame, time gap or break and where the animal did not move. The turn angle is in radians per frame; the curvature is in inverse position units (1/r on a circle of radius r) and does not depend on frame rate for a path of constant curvature; the angular velocity of the movement direction is in rad/s and is not head angular velocity. -
backend="fastplotlib"forenv.plot,env.plot_fieldandenv.animate_fieldsdraws on the GPU (pip install "neurospatial[fastplotlib]", Python 3.11 or newer). Each bin is drawn as its own cell (grid, hexagon, triangle or clipped polygon) or, for tracks and 3-D environments, a point the size of a bin; 3-D fields fade low values so the high ones show through the volume. Missing bins keep their own color rather than the colormap's top color. Inside Jupyter the figure renders on the machine running the kernel and streams to the browser, so it works on a remote GPU server through an SSH tunnel. Animations draw position, pose, head direction, events, regions, walls and landmarks and update a frame in about 5 ms.animate_fieldsreturns aFastplotlibAnimation, a fastplotlibNDWidgetwith a time slider in seconds and play, step and loop controls; hovering over a bin shows its position and value. Time-series overlays are drawn beside the field on the same clock, every sample in their window at their own sampling rate, with event times marked. Video overlays are placed in the environment through their calibration (rotation and mirroring included), above or below the field, with walls, landmarks and the moving overlays on top. Object vectors are drawn as lines from the animal to each object, fading with the firing rate. On a track (linear_track,from_graphor a 1-D environment) the fields are also drawn as position along the track against time, overwindow_secondsaround the current time, with position overlays as lines: the usual view of a decoded posterior; on a maze, lines mark where segments meet and each is named by its nodes.SpikeRasterOverlaydraws many units' spikes as a raster beside the field on the same clock, rows in a chosen order (by place-field peak, say); hovering a row names its unit, and withrates=, clicking a unit's row or pressing Up and Down shows its rate map and stripes its row. Other backends skip it with a warning. Position, pose, head-direction, object-vector and video overlays with their owntimesmove at their own sampling rate, so slow fields (a rate map per lap) do not freeze the animal or the camera between frames.EpochOverlaymarks intervals such as ripples on every time panel and names the current one in a line above the panels, with the time. Keyboard: Space plays, the arrows step one frame, N and Shift+N jump between epochs and events, and[and]halve or double the shared time window: every panel drawn against time shows the animation'swindow_seconds, so they line up. Several fields can be shown side by side on one color scale (layout=,layer_names=), as with napari. The panels drawn against time share one time axis, at the bottom; a 1-D environment is drawn as position against time without a field panel; the colorbar marks clipped ends and the missing-value color (colorbar_label=names it). Opacities (alpha,region_alpha, trails, events, epochs) look as in Matplotlib and napari, though pygfx blends in linear light; walls, the track cursor and segment joints carry an edge that keeps them visible on any colormap color, labels are outlined, and regions are drawn sky blue, apart from orange landmarks in color-blind vision. A video drawn below the field (z_order="below") shows through where the field's values are low, so a 2-D decoded posterior sits over the camera's view, andtrack=(a linearized track of the same maze) also draws a 2-D posterior against position along the maze and time. The decoding recipe draws true position magenta and decoded position green, only where the posterior is not nearly flat. Frame times with long gaps or very uneven spacing are warned about, since playback moves by a fixed time.plotandplot_fieldkeep Matplotlib as the default and return anAxesfor it; withbackend="fastplotlib"they return the fastplotlib figure, and Matplotlib-only options raise aTypeError. -
annotate_videodraws walls and landmarks as well as the boundary, holes and regions. Press M to reach wall mode, which switches to the path tool (magenta), and landmark mode (orange), where the shape sets the kind: an ellipse marks a point at its center (a reward port or a cue), a path a line (a monitor), a polygon or rectangle an area (an object). The returned environment holds the named walls, clipped to the boundary, and the landmarks;result.wallsandresult.landmarkshold them too. -
Landmarks: named cues, monitors, objects and reward ports, stored with the environment.
env.with_landmarks(Landmarks([Landmark("monitor", "line", [(10, 100), (90, 100)]), ...]))attaches them; each is a point, a line or a polygon in the environment's coordinates, may lie outside the arena, and keeps its name across sessions.env.landmarks.nearest_points(positions)gives each landmark's nearest point at every sample andenv.landmarks.near(gaze, "monitor", tolerance=2)tells whether projected gaze lands on it.subset,clipandrebinkeep them,apply_transformmoves them,combineprefixes their names, andto_dict,to_fileand NWB save them. The object-vector and egocentric encoding functions accept landmark names asobject_positions(and per-sample positions,(n_time, n_objects, 2)): a monitor is measured from its nearest edge rather than its center, and results record the names inanalysis_settings["objects"]. -
Environment.combinejoins components whose bins measure different things, such as a corridor built withlinear_trackand the arena it leads into. Each bin keeps its own measure; occupancy, rate maps, smoothing within each component, decoding and storage work on the combined environment, and a transform scales each bin by its own dimension. Bridges between bins of different measure are navigation-only: abridge_conductancesentry for one, or a positive face between such bins passed tofrom_bins, is refused, because diffusion between a density per length and one per area would depend on the units. -
Environment.from_bins(env.binning, arena=env.arena)rebuilds an equivalent environment directly:from_binsbuilds the lazy face recipe thatenv.binningcarries and checks it against the bins, instead of refusing it. -
Environment.get_blocked_edges()lists the neighbor pairs that walls close, with each pair's navigation distance and the name of the wall that closes it, to check that a wall separates the bins you meant. -
Walls have names.
OpenArena(outline, walls={"divider": line})names each wall; walls given as a sequence are named"wall_0","wall_1", ... (or passwall_names=).arena.wall_nameslists them, snapped walls keep their wall's name, files store them (saved arenas now require them), and the errors and warnings about a wall through a bin or a snapped wall name it. -
compute_boundary_vector_rateandcompute_boundary_vector_ratesestimate firing by distance and direction to the walls, for boundary-vector cells (direction_frame="allocentric") and egocentric boundary cells (direction_frame="egocentric"withheadings). One ray per direction bin finds the nearest wall from each sample, following Hinman et al. (2019); each interval and spike counts toward the bin of every ray. Walls are the arena's declared walls by default, so unvisited space is not a wall. The results,BoundaryVectorRateResultandBoundaryVectorRatesResult, havepreferred_distance(s),preferred_direction(s),spatial_information,plotand the sharedsummary_table,to_dataframeandto_xarrayexports. They share their polar-map code with the object-vector results. -
ops.compute_boundary_distances(env, positions[, headings], direction_frame=...)casts rays in evenly spaced directions from each sample and returns the distance to the first wall along each, in the allocentric frame for boundary-vector cells or the egocentric frame for egocentric boundary cells.ops.nearest_boundary(env, positions)returns the distance and allocentric direction to the closest wall point. Both measure to the arena's declared walls by default (boundary_source="physical"), so an unvisited corner is not a wall, and mark a missing sample NaN.ops.boundary_directions(n)gives the ray directions. -
env.coordinate_systemsays what it is:kindis"cartesian"or"polar",circularsays whether the angle axis wraps around (alwaysFalsefor Cartesian coordinates), andperiodic_axeslists the axes whose two ends are neighbors ((1,)for a full-circle polar environment, otherwise empty). A custom analysis can ask the descriptor directly instead of checking its class. A polar environment's wrap is recorded once, by itsEgocentricSurroundarena (env.arena.circular), and the descriptor is derived from it, so the two can never disagree. -
Environment.with_track_width(width)gives a track its measured full width and keeps its bins, linear coordinates, name, units, frame and regions. Use it on a track built withouttrack_width, such as one saved by an earlier version, which refuses to look up 2-D positions: afterEnvironment.from_file(path).with_track_width(10.0), positions more than 5 cm from the centerline get bin-1and rate maps, occupancy and decoding work again. It adds"with_track_width"toenv.derivationsand refuses an environment that is not a track. -
The cell-shape records in
neurospatial.binning(HexagonShapes,TriangleShapes,PolygonCells) haveextent(), the(low, high)bounds of their cells per axis, andsubset(keep), the cells selected by a boolean mask of one entry per bin, in their original order. These are the same rulesenv.dimension_rangesandenv.subset()use, so explicit bins built forEnvironment.from_bins()can be bounded and subset the same way. -
neurospatial.ops.KernelModenames the valuesmode=accepts inEnvironment.compute_kernel,smoothanddiffuse("transition","density","average"), for type annotations in your own analysis code. -
neurospatial.layout.ScaleBarConfigis the scale-bar settings class thatEnvironment.plot(scale_bar=...)accepts. It is the same class asneurospatial.animation.ScaleBarConfig, now importable without the animation package. -
neurospatial.encoding.autocorrelation_pixel_size(env)gives the side, in the environment's units, of the square pixels ofspatial_autocorrelation. Pass it asbin_sizetogrid_scaleandgrid_properties. -
Environment.bin_center_tree()returns the KD-tree overbin_centersthatnearest_binsearches, built once and kept in the analysis cache. Use it for theknearest bins with their distances.map_probabilities,select_basis_centers(method="kmeans"),heat_kernel_wavelet_basisand the simulated place cells now search this tree instead of building their own, with identical results; repeatedmap_probabilitiescalls onto a 160,000-bin grid take 28 ms instead of 38 ms. -
neurospatial.environment.BinSequenceWithRuns, the result type ofEnvironment.bin_sequence_with_runs(), is importable from a public path for type annotations andisinstancechecks. -
Environment.from_layoutaccepts your own layout engine after itsbuild(...), with no registration. The engine can be a plain class with onlybuild(...)andbinning(). Subclasses of built-in engines, which used to be refused, now build environments normally. -
Binninghas adimension_rangesfield: the(low, high)extent of each axis the bins' cells cover.env.binning.dimension_rangesequalsenv.dimension_ranges, soEnvironment.from_bins(env.binning)keeps the ranges. An environment computes the ranges from the cells (grid, outline, cell shapes or a track's centerline); only bins without cells, such as point bins, use the value set on theBinning. -
subset()andrebin()acceptname=likeclip()andapply_transform(). -
from_polygon,from_grid_mask,from_pixel_mask,from_graph,linear_trackandmazeacceptregions=, so every Cartesian factory takes the samename=,units=,frame=andregions=keywords.open_fieldacceptsregions=andarena=, andfrom_samplesandopen_fieldacceptdimension_ranges=as a named option. -
Every arena type has
domain_bounds(), the lower and upper corners of a box that encloses its known Cartesian domain (Nonefor an unknown domain, a track without a width or a polar surround).Environment.rebin(bin_size=...)uses it to lay out the new grid. -
neurospatial.ops.geodesic_distance_between_pointsacceptsweight=, the edge attribute that measures length ("distance"by default;Nonecounts edges).Environment.distance_betweennow calls it, so the two always agree. -
Expose detached, read-only
env.binningsnapshots and public coordinate, diffusion-geometry, diffusion-accuracy, MRF-basis and Gaussian-kernel operations. Environment owns analysis caches; numerical consumers use public operations or explicit geometry. Existing public geometry imports remain stable while owned data and codecs move into an independentgeometry/package with enforced dependency rules. -
Preserve linear interpolation and movement time fractions after rotating, reflecting or shearing Cartesian 2-D/3-D grids. Planar grids use exact QuadMesh plots/animation updates. Planar linear spike counts and dwell share bounded source-plane ownership, including paths along inactive-cell boundaries. Napari raster helpers explicitly refuse framed grids.
-
Certify new affine numerical transforms from stored physical normals and flux distances. Unequal scaling along retained orthogonal grid axes can preserve diffusion, smoothing, gradients and divergence; skew faces refuse with a geometric reason. Existing approximate-mesh similarity behavior is preserved.
-
Retain 2-D/3-D source grid frames through affine transforms and whole-bin subsets, without exposing source axes as world-aligned grid edges. Reflected and deformed hexagons retain exact polygon cells. New binning codec version 3 stores the frame and reads earlier versions 1 and 2; partial clipping uses exact polygon cells rather than a tensor grid.
-
Add optional owned
FaceNormalsdata toBinning, in authoritative neighbor pair order. Native Cartesian grids and planar hexagon/triangle cells capture physical directions; subsets, clipping, physical walls and affine transforms preserve their alignment. Normals survive copying and JSON/NWB storage, but enable new numerical transforms only with the source/target face certificate. Codec version 2 introduced normals; current version 3 also stores grid frames and reads legacy versions 1 and 2. Older readers reject the new nested version. -
Ray casting and cue line of sight now intersect declared physical walls, including holes and internal barriers. Analysis crops no longer occlude physical rays. Breaking: use
boundary_source="analysis"explicitly for analysis-cell boundaries when physical walls are unknown. No wall hit within the search distance returns NaN. View encoding, shuffle tests and simulation forward and record the choice; wall hits use observer-side cell ownership. Stored frames retain bounded boundary-contact behavior after transforms. Viewshed bin coverage remains sampled; unsupported wall/owner geometry refuses. -
Border scores now use declared physical footprint rings and internal walls by default, with wall-length coverage and distances to actual walls. Breaking: explicitly choose
boundary_source="analysis"for the legacy boundary-bin approximation when only analysis geometry is available. Result, population and classification methods propagate the choice. Summary tables explain unavailable wall metrics without dropping other metrics. Physical coverage requires exact planar cells and polygonal wall geometry. -
Add
Environment.rebin(bin_size=...)for rectangular grids, rebuilding over the retained arena with an ownedGridBinningSpecseparate from provenance. Grid axes and anchor survive crops, clipping, affine transforms and JSON/NWB storage; new sizes are physical lengths along those axes. Other families and records without a specification refuse this mode. Integer-factor coarsening keeps its truncation warning and now also accepts masked and polygon grids with uniform rectangular cells. Fields and fitted models are not transferred. -
Add
Environment.combinefor owned disjoint components, with stable keyed bin sources, qualified regions and deterministic mutual-nearest-neighbor bridges that respect known physical walls. Path-only bridges do not diffuse; explicit geometric conductance couples certified child blocks. Exact lookup, component geometry and bridge data survive copy, derivation and JSON/NWB storage without live provider objects or bridge re-inference. Breaking: removeCompositeEnvironment; useEnvironment.combine({"key": env, ...})and the ordinary environment APIs. Overlapping or uncertifiable support and incompatible declarations raise. -
Add
Environment.with_metadata(...)andwith_regions(...)for immutable environment values. Metadata replacements share geometry and start fresh analysis caches. Breaking: assign units/frame/name at construction or use a replacement; edit a standaloneRegionsbuilder, then publish it withwith_regions. Environment snapshots own caller data and protect geometry, provenance, regions and cached numerical exports through copy and persistence. - Add
Environment.from_bins(...)with publicneurospatial.binningrecords, explicit lookup and explicit face availability.env.edge_pairspreserves supplied neighbor order and defines gradient/flux order.to_networkx()returns an independent editable graph. Declared navigation path lengths are preserved by rigid transforms and scaled by uniform scaling; other affine maps require known paths or rebuilt distances. - Add installed
SnapshotAdapterobjects in the versionedneurospatial.snapshot_adapters.v1entry-point group, plusneurospatial.testing.assert_environment_contract. Custom providers must convert into supported built-in data snapshots. Original custom saved records require their adapter package; canonical resaves use built-in codecs. -
Preserve hexagonal grid shape and active mask across explicit-bin construction and persistence, keeping grid-based spatial autocorrelation available.
-
Add continuous Arena domains alongside spatial bins. Tracked positions, grid masks and image masks remain direct inputs and infer computational support without asserting physical walls. Optional polygons, explicit internal walls and measured track widths describe known physical geometry.
- Add
Environment.clip(polygon)for exact planar cell intersections, including holes and split bins, with a read-only immediate-parentclip_mapping. Plotting and containment work without numerical certification; smoothing and spatial operators require certified finite-volume geometry. Clipping does not transfer fields or fitted models automatically. -
Add explicit
time_allocation="linear"to single-unit and population spatial rate estimation. Spike counts and dwell time share exact retained entry, exit and re-entry intervals; the default remains start allocation. -
Plot actual positions directly on decoded posteriors with
DecodingResult.plot(true_times=..., true_positions=..., max_gap=...). Ground-truth alignment is shared witherror_against, preserving tracking gaps and missing observations without manual coordinate conversions. - Add
plot_peri_event_responsefor a matching raster and PSTH. Both panels reuse the result's original event cohort and complete half-open bins, including exact boundary handling on the absolute recording clock. -
Add
detect_region_visitsandregion_transitions, with immutable visit and transition records preserving region identity, original sample indices and continuous observation runs. Partial visits retain observed-boundary flags; entry-to-entry transitions include source dwell time and never cross missing tracking, recording gaps or excluded epochs. -
Peri-event results expose read-only
event_indicesidentifying retained events in the original input. Indexing a population preserves this cohort, so rasters, PSTHs and event tables can share exposure filtering without duplicating it. Hand-built results useNonefor unavailable provenance. -
Check the complete notebook collection in CI, including paired source and documentation parity, local links and an acyclic prerequisite graph. Generate one shared catalog from notebook metadata for the repository and docs site.
-
Download and cache the real J16 bandit recording from Zenodo (10.5281/zenodo.23020757), verifying its pinned SHA-256 before loading numeric NPZ arrays without pickle. The real-data tutorial fetches this archive explicitly and reuses it offline; data instructions include the source, license and citation.
-
Select units by label with
sel(unit_id=...)onSpikeTrainsand every population result (spatial, directional, view, object-vector and peri-event).[]selects by position on all of them, sospikes[i]andrates[i]are the same neuron. A label passed to[], or a position out of range, raises an error that names theselcall to use. Breaking:SpikeTrains[...]used to look up a label; replacest[unit_id]withst.sel(unit_id=unit_id). - Build a fitted decoder from hand-built rate maps, such as a simulation's
ground truth, with
BayesianDecoder.from_models(env, encoding_models, unit_ids=..., spatial_support=..., dt=...). Withoutunit_ids, spike trains pair with models by position. Breaking: theBayesianDecoderconstructor now takes configuration only.encoding_models,unit_ids,spatial_support,spike_windowandanalysis_settingsare read-only attributes, set byfit,from_ratesorfrom_models; replaceBayesianDecoder(env, encoding_models=m, unit_ids=ids)withBayesianDecoder.from_models(env, m, unit_ids=ids).dataclasses.replace(decoder, dt=...)keeps the fitted models. Changing fitted encoding settings (method,bandwidth,min_occupancy,penalty,rank) withreplacenow raises; construct and fit a new decoder. Prediction defaults remain replaceable.from_ratesuses the rate result's method, bandwidth and recorded encoding settings. - Add
Environment.get_divergence_operator(), the cached finite-volume divergence operator thatdivergence(env, edge_field)applies, alongsideget_differential_operator(). - Preserve spatial
analysis_settingsthrough population indexing, tables, xarray/NetCDF and NWB schema 2.4, including selected/observed windows, gap, speed and occupancy gates. Exposespatial_supporton rate results and fitted decoders. Legacy results identify unavailable settings asNone. -
Add runnable condition-comparison and held-out decoding workflows and a scientific-task index linking recording import through analysis and export.
-
Add scientific invariants across layout transforms, coordinate-unit conversions, single/population estimation, labelled unit permutations and disk NWB round trips, covering grid, masked, hexagonal and graph layouts.
-
Give singular rate results the same metric tables and labeled xarray Dataset export as population results, including GAM slices, unit identity, frame and recording-window metadata. Shared table builders/classifiers retain the established statistics and thresholds without wrapping singular GAM results in artificial populations.
-
Preserve NWB units' per-unit
obs_intervalsand their shared acquisitionspike_window, including subset selection and lazy-spike reads. Population analyses can use this intersection explicitly; absent coverage stays None and disjoint coverage stays an empty window. -
Build fitted spatial decoders from
SpatialRatesResultwithBayesianDecoder.from_rates, carrying the environment, precision, unit identity, training support and analysis settings. Generated labels remain positional; caller-supplied labels align by identity, including direct constructors, indexing and dataclass replacement. Prediction excludes bins without supported training observations. -
Accept
BayesianDecoder.fit(unit_ids=)and preserve caller-supplied unit identity for prediction alignment. Labels supplied alongside a spike group must match its labels and order; the shared resolver rejects duplicates. -
Snapshot all public namespace names and function signatures in one sorted, reviewable file, with a unified diff and explicit regeneration command for intentional API changes.
-
Give lazy root exports concrete static types for IDEs and type checkers, and verify each headline class/function with a CI typing contract without eager runtime imports.
-
Expose spatial rate computation/results, position decoding/results and peri-event histogram/results lazily at the root, alongside core spatial types and domain namespaces.
-
Add opt-in
criterion="shuffle"to all five raw-array cell predicates, with explicit stream labels and p-value levels. Mode-specific keywords raise together when they would be ignored; result methods and batch classifiers remain threshold screens without retained inputs. -
Add five raw-array circular-shift significance functions for place, head-direction, spatial-view and both object-vector frames. Shared valid-window masks, copied inputs and label-keyed random streams preserve recording coverage and seeded single/population agreement.
-
Add seeded circular spike-time shifts over joined valid recording windows. Shifts preserve retained spike counts and compressed-clock circular spacings without placing spikes in gaps.
-
Add
is_egocentric_object_vector_cellfor heading-relative tuning. Breaking:is_object_vector_cellnow measures allocentric direction and no longer acceptsheadings; callis_egocentric_object_vector_cellfor the previous heading-relative verdict; each frame predicate forwards its encoder options and retains the existing information threshold and error behavior. -
Add
compute_object_vector_rate(s)for allocentric animal-to-object direction alongside the egocentric encoders. Both use the shared polar binning, smoothing and recording-window rules, and record a requireddirection_frameon singular and population results.
Performance¶
- Dense diffusion kernels (
env.compute_kernel,compute_diffusion_kernelsand the"gaussian_kde"method) use the dense matrix exponential, since the heat kernel has no zero entries: a 2,601-bin kernel takes 1.0 s instead of 29 s. Values agree to within 1e-15. - Graph distances run one multi-source shortest-path search and build the
bin adjacency in one pass over the edges.
distance_fieldfrom 1,000 sources on a 10,404-bin grid takes 14 ms instead of 0.9 s; geodesiccompute_egocentric_distanceto moving targets (20,000 samples) takes 1.5 s instead of 55 s; geodesicneighbors_withinfor 463 centers takes 25 ms instead of 0.2 s and returns each neighborhood sorted. Distances are bit-identical, and geodesicdecoding_errorshares the same search. graph_convolve(normalize=True)normalizes with two matrix-vector products instead of copying the kernel matrix twice: on 4,615 bins, peak memory drops from 341 MB to 0.2 MB and time from 19 ms to 2.4 ms.- Linear time allocation (
time_allocation="linear") locates every trajectory piece in one call instead of one call per sample: 20,000 samples on a 2-D grid, hexagonal or polygon environment take about half the time, with bit-identical occupancy. - Every bin-graph operation (geodesic basis functions,
neighbor_reduceand random-walk transitions included) reads the graph through one adjacency builder, so all of them refuse a graph whose nodes are not the bins0 .. n_bins - 1or whose edges lack the distance attribute, instead of silently counting edges. They also refuse a directed graph or a multigraph:distance_fieldignored edge direction whileneighbors_withinfollowed it, and parallel edges summed their lengths. - Position lookup in a polygon arena (
from_polygon) or a masked triangular mesh tests points against a prepared polygon: containment of a million points in a 257-vertex arena takes 0.045 s instead of 0.30 s, with identical results. Clipping to a polygon no longer rescans every piece for each source cell, andlinear_to_ndgathers segment ends by index instead of one Python lookup per position. - Reading an environment's cell polygons, which clipping, re-binning, border
scores, decoded-probability remapping and arena fitting all do, builds and
validates the cells in vectorized calls, and skips the overlap test for
neighbors whose bounding boxes meet only along an edge or corner. A
90,000-bin grid's cells take 0.13 s instead of 3.2 s and 72,000 hexagons'
0.83 s instead of 2.9 s;
border_scoreon a 40,000-bin arena takes 0.67 s instead of 2.1 s. The polygons are bit-identical and the validation errors unchanged. -
One description of each bin's cell (
geometry.cell_polygons) now serves the analyses, the Matplotlib hexagon drawing (which built its own hexagons from center, radius and orientation) and the fastplotlib backend, so the cells drawn are the cells analyzed. -
env.plot()draws the connections between bins as one collection rather than a line each, and marks grid cell edges with unlabeled minor ticks. A 10,201-bin grid draws in 0.15 s instead of 4.0 s, and a 114,352-bin grid in 1.1 s instead of 31 s. -
summary_table()andlabel_cell_types()prepare the physical wall geometry once per environment instead of once per cell. For a 2,500-bin arena, 100 cells take 0.35 s instead of 14.6 s and 1,000 cells 1.1 s instead of about 150 s, with identical border scores.border_scorereuses the same cached geometry across calls on one environment. -
Shuffle tests and other repeated
diffusion_kdemaps no longer repeat the environment's eigendecomposition on every call when the bandwidth is small for the arena.env.diffusenow solves that full basis once and keeps it for later calls at any bandwidth, with identical results. On a 2,500-bin open field atbandwidth=5, each extra shuffle inplace_cell_significancefor 10 cells takes about 35 ms instead of 2 s. The basis takes8 * n_bins**2bytes (50 MB for 2,500 bins) untilenv.clear_cache().method="glm"fits that need the full smoothness basis (arankof half the bins or more) likewise solve it once per environment. -
rate_map_centroid(method="geodesic")computes the field bins' graph distances in one pass instead of one shortest-path search per pair of bins: a 200-bin field takes about 2 ms instead of 3 s, with identical results. -
from neurospatial import Environmentno longer imports matplotlib orpandas; they load when you first plot or ask for a table. The import takes about 0.19 s instead of 0.42 s, which helps scripts and worker processes that only bin positions. -
Renaming an environment no longer discards its cached analyses: on a 3,586-bin open field, the first
compute_spatial_ratecall afterwith_metadata(name=...)takes 0.51 s instead of 0.88 s. -
Reading graph attributes of
env.connectivityis about 5 times faster, and Dijkstra shortest paths onenv.connectivityabout 2 times faster, because attributes are frozen once instead of copied on every read. -
A 200 × 200 grid mask cropped by an
arena=builds about 2.7 times faster, because the bins are selected before the environment is built. -
Building an environment checks its connectivity graph against its bins with array operations instead of a Python loop over every bin and pair: for a 22,500-bin grid the check takes 0.08 s instead of 0.45 s.
-
Environment.occupancy(time_allocation="linear")on 1-D and 3-D rectangular grids splits every interval across the cells its straight path crosses at once, instead of one interval at a time: a 20,000-sample 3-D trajectory sampled at 5 Hz on a 21 × 21 × 21 grid takes 0.07 s instead of 1.2 s. Occupancy values are unchanged. -
Environment.distance_to(metric="euclidean")andneurospatial.ops.distance_field(metric="euclidean")find the nearest target with a KD-tree instead of a bins × targets distance array: on a 90,000-bin grid with 22,500 target bins the call takes 0.03 s instead of 56 s and about 32 GB of temporary memory. Distances can differ from before only in the last digit of floating-point rounding. -
Environment.transitions(times=..., positions=...)with the defaultallow_teleports=Falsechecks that each step joins neighboring bins with a sparse adjacency lookup instead of a Python loop: 1,000,000 steps on a 90,000-bin grid take 0.04 s instead of 0.49 s. The counts are unchanged. -
nearest_binandinterpolate(mode="nearest")resolve a point that lies equally close to several bins by measuring only the bins near that point, instead of every bin: for points on the cell corners of a 10,000-bin grid this is about 100 times faster and no longer allocates an array the size of points × bins. The chosen bin (the lowest-numbered tied bin) is unchanged. -
subsetandapply_transformreuse the parent environment's cell faces once smoothing or another diffusion analysis has built them, instead of building them again: on a 90,000-bin gridsubsettakes 0.43 s instead of 2.2 s after such an analysis. -
Building an environment is much faster. Layout engines find neighboring bins with array operations instead of building a NetworkX graph one bin at a time, which the environment then discarded, and the environment builds its own read-only graph in one pass. On a 300 × 300 grid mask
Environment.from_grid_masktakes 0.48 s instead of 4.4 s andsubset0.48 s instead of 3.2 s;Environment.from_sampleswith 100,000 positions (bin size 1, about 10,000 bins) 0.05 s instead of 0.57 s; a 20,000-bin linear track 0.04 s instead of 0.33 s. Bins, neighbor pairs (env.edge_pairs), their order and their distances are unchanged.
Fixed¶
- Closing a napari scene view while napari's perfmon traces its methods no longer fails to release the view: it disconnects the slots it connected.
-
ScaleBarConfig(background=None)draws the scale bar without a box, as documented; it drew the default white box behind it. -
The annotation editor opens on Annotation Controls, showing the current annotation type and name before switching to assisted outlines.
-
Annotation editing preserves surviving track edges, node labels and start nodes when deleting points in napari. Native additions, deletions and moves participate in undo/redo without state-to-view feedback corrupting the graph.
- Edge edits and node deletions invalidate manual linearization overrides, while adding a node keeps them, and result conversion rejects edge orders that omit, repeat or invent graph edges.
- Reopening polygon annotations preserves interior rings and their parent
polygons. Invalid initial polygons open for repair, and invalid boundary and
region polygons are rejected before the viewer closes, keeping the draft
available for correction instead of failing after editing.
Simplification no longer implicitly repairs self-intersections.
A pasted polygon gets its own identity with its own interior rings, so
deleting the original removes only the original's rings; an interior ring
pasted alone becomes a separate polygon. A paste is named at once (
zonebecomeszone_2), so Done exports it without waiting for another drawing. Annotation counts and auto-names count an environment's interior rings as holes and a region's as part of it. - Annotation environments retain
"cm"units when calibrated and"pixels"otherwise; track results carry the same units throughto_environment(). - Native track-edge deletion and node renaming now use the same undoable state commands as other graph edits. Temporary edge previews do not modify the saved graph, and unsupported direct edge reshaping restores the graph view.
- Polygon and track annotation validate their complete results before closing
the editor. Invalid landmarks, walls and incomplete polygons remain editable
instead of being dropped during interactive saving; a dialog offers to keep
editing or to discard the draft, which raises
RuntimeError. Result warnings print to the console after the editor closes, and the editors also wait for the user under IPython's%gui qt. A hole that splits the environment into separate areas raises an error that names the problem. Standalone converters retain their documented permissive behavior. - Deleting polygon annotations keeps drawing bookkeeping synchronized, so the next shape receives its own name and role, including after deleting a polygon with interior rings.
- Track preview and export resolve edge order and gaps together. Automatic gaps follow the selected order, all disconnected components are retained, and preview coordinates and manual gaps use the same output units as the result.
-
LabelMe and CVAT annotation imports preserve their known
"cm"or"pixels"coordinate metadata through subsequent environment construction. -
The warning that a triangular mesh is too skewed for an exact smoothing bandwidth appears when you smooth on that mesh (rate maps,
smooth,diffuse, kernels, MRF bases,get_diffusion_geometry), once per environment, at your call. On an environment fromEnvironment.combine(), it names the mesh component with the suffix(combined component '<key>'). Before, it appeared wherever the mesh's cell faces were first built: decoding,compare_spatial_ratesandread_place_field(env=...)emitted it while comparing environments, and a later smoothing call then did not warn;subset, gradients andEnvironment.combine()(at construction) emitted it too. -
simulate_trajectory_lapssimulates a running animal: the default speed is 20 cm/s expressed in the environment's units (speed_mean=None; environments without metric units needspeed_mean=), and positions move continuously along the path instead of jumping between bin centers. Every position stays inside the environment: on a track the lap runs along the centerline and turns at the node where two segments meet, and between planar cells (grids, hexagons, triangles, polygon arenas, clipped or wall-split cells, mazes) it takes the shortest route inside the two cells that keeps to the arena outline and off walls. Before, the defaultspeed_mean=0.1was read in environment units (0.1 cm/s), so three laps of a 1 m track took 45 minutes and 1.3 million samples, 99.99 % of them exactly zero steps. Sessions withduration, such aslinear_track_sessionandtmaze_alternation_session, snapped the same way: 96 % of the running steps oflinear_track_session(duration=60, n_laps=6)were exactly zero. Withduration, aspeed_meanyou pass now getsspeed_std=0.2 * speed_meanby default (before, a fixed 0.02). Custom paths must be chains of neighboring bins (a repeated bin is allowed), and a missing default path raises instead of jumping from end to end (see Breaking).
simulate_trajectory_laps(env, n_laps=3, seed=1) # before: 2679 s at 0.1 cm/s; after: 14.4 s, running at ~20 cm/s
# A 2 cm wide L-shaped track with 10 cm bins, one lap at 20 cm/s:
track = Environment.linear_track(
node_positions=[(-5, 0), (5, 0), (5, 10)], bin_size=10, track_width=2, units="cm"
)
positions, times = simulate_trajectory_laps(track, n_laps=1, speed_mean=20.0, seed=0)
track.bin_at(positions) >= 0 # all True: the lap turns at the corner instead of cutting across it
- A track rotated or shifted with
apply_transformkeeps the linear position (pos_1D) and track segment (source_edge_id) of every bin inget_bin_attributes()andenv.connectivity. Before, rounding in the moved segment lengths dropped both for about a third of rotation angles, andsimulate_trajectory_lapson such a track raisedKeyError: 0.
rotated = track.apply_transform(Affine2D(matrix)) # e.g. a 0.4 rad rotation
rotated.get_bin_attributes()["source_edge_id"] # before: KeyError (column missing); after: same as track
- Publishing walls onto a rotated or clipped environment refuses a wall that
lies in a rounding-sized gap between two neighboring bins. Before, whether
such a wall was refused or silently ignored depended on the last bits of
floating-point rounding, so the same call could pass on one machine and
fail on another. For example, with a short wall declared 1e-12 from a bin
edge,
env.apply_transform(Affine2D(matrix)).clip(region)far from the origin now raisesValueErroron every machine, saying that where the wall lies relative to the bins is unresolved (which check reports it, and so the exact wording, can still vary). The decision now comes from how far each face can be from where rounding put it, not from whether the rounded bins happen to touch: - A wall along the whole face between two bins closes it. Before, a wall exactly on a bin edge of a rotated-then-clipped environment was refused ("face alignment ... is unresolved") whenever rounding left the bins a hair apart, and a wall ending at a face could close two bins on the same side of it through the corner where rounding made them meet.
- A wall along part of such a face, such as a short wall exactly on a bin edge, is refused. Before, it was ignored and the bins stayed connected, or it narrowed the face, depending on rounding.
- A wall that crosses a face, at any angle, leaves it open, unless it also crosses the line between the two bin centers where rounding left the bins apart; that case is still refused.
To keep a wall that covers only part of a face, clip the environment before
transforming it. Recentering the coordinates near the origin helps only a
wall that lies off the bin edge, by shrinking the rounding below its
distance from the edge.
- When the decoder refuses rate maps estimated on a different environment, its
error names a difference in the bins themselves (polygon cell shapes or
which bin owns a boundary) even when the connectivity or the arena also
differ. Before, it named only the connectivity or arena difference.
read_place_field(env=...) reports differences the same way (see
Breaking).
- Decoding with rates from an environment with polygon cells (one cut with
clip) on an environment that records no cells, such as explicit bins from
from_bins, compares the bins by center and measure. Before, it raised
ValueError ("The locator does not publish exact planar cell geometry").
read_place_field(env=...) and compare_spatial_rates follow the same
rule.
- detect_assemblies orients each ICA and PCA pattern so its largest-magnitude
weight is positive, and flips its activation with it. Before, the sign was
arbitrary, so an assembly's members could have negative weights and
assembly_activation could drop below zero during that assembly's events.
Code that flipped patterns by hand now receives them already oriented.
- plot_population_coverage draws 1-D environments, such as the one from
linear_track_session, as a line with a vertical mark at each gap. Before,
it raised IndexError when any bin was uncovered. On a track built from a
2-D graph, gap marks are now at each gap's linearized position; before, they
were drawn at its x and y coordinates.
- Decoding with rates from another environment now compares the bins' cell
shapes, not only their centers and areas. Triangular meshes, hexagonal
layouts and grids (axis-aligned or rotated) whose cells differ with the
same centers and areas, such as a 10 x 10 cell and a 20 x 5 cell, used to
be accepted as the same bins; they now raise IncompatibleEnvironmentError naming "cell
shapes". Environments that smooth a field between the same neighbors
differently (different face areas in from_bins, or one that cannot smooth)
now differ in "smoothing conductances between neighboring bins".
read_place_field(env=...) makes the same check (see Breaking), and
compare_spatial_rates applies the same bin checks to each pair of a
bin_mapping, so an identity mapping is refused exactly where no mapping
is: paired bins must also have the same cell shape and, for polygon cells,
the same polygon and the same bin owning each boundary. Ownership is
compared without regard to bin numbering, so a renumbered copy of a
clipped or wall-split arena pairs with the original through bin_mapping;
pieces of one cut cell must keep their relative order, because a point on
the line between them belongs to the earlier piece. A subset of polygon
cells still pairs with its parent. Tracks are compared by their
segments and the linear edges of each bin: two tracks whose bins have the
same centers but run along different segments, or the same track with
different edge_spacing, now differ in "track geometry". Explicit point
bins, or a track paired with bins of another kind, are compared by center
and measure only.
- traveled_path_length no longer counts the jump between joined
recordings or turns the total into NaN at a lost frame. Two recordings,
[[0, 0], [1, 0]] and [[50, 0], [51, 0]], measured 51.0 cm, 49 cm of it
the jump from one to the other; with the second recording's first frame
marked in break_before, or a time gap longer than max_gap between them,
they measure 2.0 cm. One NaN row made the whole total NaN; now it only
splits the path.
- The method="glm" warnings (smoothing penalty near its search bound, fit did
not converge, no spikes, no occupancy) now point at your own call. Before,
they pointed at a line inside neurospatial.
- compute_boundary_vector_rates warns when every unit in a population of
five or more is silent for a minute or more of tracked time, as the other
population rate functions do. Before, it gave no warning.
- The error for an unknown method lists the valid estimators in a fixed
order. Before, it printed a Python set, so the order changed from run to run.
- The warning about a bandwidth that spans the environment now points at your
own call for population view rates, object-vector rates, egocentric rates,
decode_session and BayesianDecoder.fit. Before, for these it pointed at
a line inside neurospatial.
- A track graph edge's length is the distance between its nodes' pos, for
the bins and for everything measured along the track. A distance edge
attribute that disagreed used to lay the bins out by one length and place
bin centers, cell ends and to_linear positions by the other (every center
could land on the first segment); it is now refused, naming the edge. A
missing distance is computed, so Environment.from_graph no longer needs
it.
- wall_fit="snap" no longer fails for two kinds of walls: a wall that snaps
to a single bin edge (it raised "Cannot linemerge"), and a wall reaching a
slanted outline, whose snapped edges could leave the arena (it raised "lies
outside the arena domain"). Snapped walls are now kept to the arena;
environments that built before are unchanged.
- The napari backend showed the wrong events after any jump larger than
decay_frames, forward or backward: moving to frame 1 from napari's
starting frame 99 showed frames 0-9 instead of 0-1, and frame 10 then
showed 90 events instead of 6, staying wrong until the end. Napari opens at
the middle frame, so the first move to the start triggered it. Visibility is
now the run of frame-sorted events the frame calls for, found by binary
search and updated where it changes, so any jump shows exactly the right
events.
-
The default smoothing bandwidth for an environment with no neighbor pairs and no metric units used each bin's measure to the power
1 / n_dims, so a track bin 2 long in 2-D coordinates counted as 1.41 long. It now uses each bin's own measure dimension: a track bin's length, the side of an area bin. -
A combined environment whose components have no polygon cells, such as two tracks, reported no
dimension_ranges, andcompute_spatial_rateon it failed withTypeError: 'NoneType' object is not iterable. Its extent is now the union of its components' extents, and the default smoothing bandwidth no longer needs a known extent. -
border_score(and the border scores insummary_tableandlabel_cell_types) scores circular and other curved or diagonal arenas built withfrom_polygon. Withboundary_source="physical"(the default) it had refused them, because the wall runs through grid cells that were not kept, leaving wall pieces with no bin. Those pieces belong to the neighboring bin, the same bin that owns positions there, so a border cell in a 50 cm circle scores above 0.5 and a field at the center below 0. An axis-aligned grid on a rotated square now scores like the same square built in its own frame (within 0.05). Walls that no bin can reach still refuse. -
env.interpolate(mode="linear")reads the edge value between the grid's edge and its outermost bin centers instead of NaN. Interpolation runs over the grid of bin centers, so the outer half of every edge bin, inside the environment, had no value; it now holds the edge value along the clamped axis, asmode="nearest"does there. Points outside the environment still read NaN. -
Geodesic
decoding_error(anderror_against) measures a position far outside the environment from its own nearest bin. The nearest-bin lookup's distance cutoff returned-1for it, which indexed the last bin: a decode 1000 cm left of a row of bins was measured from its rightmost bin. -
env.nearest_binchecks the number of coordinates before skipping missing rows. An all-NaN array with the wrong number of columns returned-1for every row instead of naming the shape mistake, and a finite one failed with a SciPy error. -
env.smoothandenv.diffuserefuse an infinite bandwidth, asenv.compute_kernelalready did.bandwidth=np.infhad passed the positivity check and turned a field of ones into zeros (or NaN), losing all its mass. -
env.interpolate(mode="nearest")keeps every point the environment contains. It had inherited a hidden cutoff of 10 typical bin spacings from the nearest-bin lookup, so a point inside a tall or wide cell but far from its center read NaN (on a grid of 1 x 100 cells, the point (0.5, 1) of a field of ones gave NaN althoughenv.containswas True). -
Behavior and event analyses that take a region name (
segment_trials,detect_region_crossings,detect_region_visits,detect_runs_between_regions,running_direction_labels,detect_laps(method="region"),detect_goal_directed_runs,decision_region_entry_time,compute_decision_analysis,compute_vte_session,region_reward_fieldanddistance_to_boundary(boundary_type="region")) raise aValueErrorwhen the region covers no bins. Before, a point region outside every bin or a polygon around no bin center silently gave no trials, visits or runs (or a misleading "trajectory never enters region" error). For a point region the message gives the distance to the nearest bin center and suggests a polygon region around the point, the same refusalenv.distance_to(region=...)already gave. -
Every named region of the built-in mazes in
neurospatial.simulation.mazesnow covers a bin. Radial-arm arm ends and Hamlet outer boxes sit at the center of each arm's end square instead of on its tip, Hampton Court builds the central goal area its docstring describes (the goal point lay in empty space), and each maze's linearizedenv_trackcarries the same regions, every point moved to the nearest location on the track. Before, the track had no regions and several 2-D regions covered no bin. -
On an egocentric polar environment, the bin that stands for a start or goal region in trial and decision analyses (
trials_to_region_arrays, decision boundaries) is the region bin nearest the region's center in the animal's plane. Before, the center was an average of(distance, angle)rows, which put a wide wedge 10–40 cm ahead at 25 cm instead of about 16 cm. -
HeadDirectionOverlay,BodypartOverlay,ObjectVectorOverlayandVideoOverlayraiseCartesianOnlyErroron an egocentric polar environment. They draw arena coordinates, and a polar environment's axes are(distance, angle)around the animal, so the animation used to draw allocentric arrows, poses and camera frames on axes where they mean nothing.PositionOverlayandEventOverlaystill draw(distance, angle)rows. -
The multi-session workflow in the user guide compared rate maps with
map_probabilities(..., source_probabilities=...), which raisedTypeError(the parameter issource_probs) and, with the right keyword, summed rates as if they were probability mass. It now builds one environment for the box, computes both sessions' maps on it, and showsresample_fieldfor maps that already have different bins. The example runs in the documentation tests. The alignment guide'smap_probabilitiesexamples use the right keyword, and its docstring says to useresample_fieldfor rate maps. -
Environment.arenaandEnvironment.clip_mappingreturn detached copies, asEnvironment.binningdoes. Their arrays were read-only but shared with the environment, and reshaping one in place (env.arena.active_mask.shape = (-1,)) broke the environment's own lookups:env.containsthen raisedIndexError. -
On an egocentric polar environment, the edge
vectorandangle_2dinget_edge_attributes(),connectivityandto_networkx()are the displacement between neighboring bins in the animal's plane (x ahead, y left, in length units) and its egocentric direction. Before, they were differences of(distance, angle)coordinates, mixing a length with radians, and an edge across the angular seam showed an angle step of almost a full turn instead of one bin. -
simulate_trajectory_ou(boundary_mode="stop")holds the animal at the wall it reached. Before, the clamp usedbin_at, which returns-1for a position outside, and moved the animal to the last bin center, often the far corner, where it stayed.boundary_mode="reflect"used the same clamp after a reflection. -
PlaceCellModelandObjectVectorCellModelwithmetric="geodesic"give a NaN rate for a NaN position, asmetric="euclidean"does. Before, the missing position took the rate at bin 0. -
A sample outside the environment (bin
-1) is in no bin intrajectory_similarity,compute_home_rangeanddetect_goal_directed_runs. Before,-1indexed the last bin:trajectory_similarityscored an outside sample as the last bin (Hausdorff, DTW) or as a place both trajectories shared (Jaccard),compute_home_rangereturned-1as a home-range bin and counted outside time in the total, anddetect_goal_directed_runsfailed withNodeNotFound: Node -1. Nowtrajectory_similaritycompares only places inside the environment and refuses a trajectory with none,compute_home_rangeleaves outside samples out, anddetect_goal_directed_runsends a recording at an outside sample. -
An environment pickled by another version of neurospatial is refused when it is loaded, with the advice to save with
env.to_file(path)and read withEnvironment.from_file(path). Before, a pickle from the previous release loaded and then failed on its firstsubset,apply_transform,rebinorto_dictwith anAttributeError. Pickles now carry a format version. -
On a grid rotated or sheared by
apply_transform, a position exactly on the line (or, in 3-D, the face) between two bins now belongs to the same bin as on the grid before the transform: the bin above the line, as on any rectangular grid. Before, rounding in the rotation put such a position in either bin, or outside the environment, sobin_at,bin_sequence,occupancy(time_allocation="start")and spike counts could disagree withoccupancy(time_allocation="linear"), which already used this rule. In a test lattice of cell centers, cell edges and corners, 16–41% of the points changed bins after a rotation. Positions inside a bin are unchanged, and the same rule now applies inside environments built withEnvironment.combine. Finding bins on a rotated grid takes about 1.4 times as long (0.42 s instead of 0.31 s for a million 2-D positions). A rotated grid that is then cut withcliphas polygon cells instead of a grid, and points exactly on its cell edges can still fall outside every cell, as before. -
rebin(2)on a pixel-mask grid that was clipped to a polygon suggests the coarser cell size,env.rebin(bin_size=10)for 5 cm pixels, instead ofenv.rebin(bin_size=...). Each built-in layout engine now names the option that sets its cell size (size_parameter, such aspixel_sizeorhexagon_width), and the suggestion reads that option. Bins from a custom engine no longer get a size guessed from a recordedbin_size. -
After
apply_transformscales the bins, a refusedrebin(2)suggests twice their current size. It used to double the size they were built at, so hexagons built 4 cm wide and scaled by two were suggested a rebuild atbin_size=8, the width they already had, instead of 16; tracks, meshes and clipped grids had the same error. After a transform that stretches some directions more than others, the suggestion gives no size, since the cells no longer have one. Files, dictionaries and NWB files save the scale aslength_scale; files saved earlier give no size after a transform. -
After
apply_transform, a track with a known width still says so. Therebinrefusal on such a track suggested anEnvironment.from_graph(...)rebuild withouttrack_width=, so following it lost the width; it now passes the width in the transformed units (20 for a 10 cm track scaled by two). The refusal oftime_allocation="linear"on a transformed track also names the track instead of "this arena". -
apply_transformaccepts a rotation by an angle so small that its sine is below the normal floating-point range (about 1e-308), such as 2e-313 radians. The check that the stored grid frame matches the transform used only relative rounding errors, which vanish for such tiny values, so it refused the environment it had just built. Its error bound now also counts the absolute rounding of these tiny products. -
A grid environment that went through a chain of scalings and translations no longer refuses its own inverse transform. Each step mapped the cell edges and, separately, the stored bin centers; after a few steps the two disagreed by a few rounding units and the inverse failed with "bins.centers and locator geometry does not match the binning geometry". The centers of a scaled or translated grid are now the midpoints of its mapped edges. Values change by at most one rounding unit of the coordinates (7e-15 cm at 35 cm).
-
Environment.animate_fieldswarns once when you pass an option the chosen backend ignores, such ascrf=withbackend="html"oroverwrite=Truewith napari, naming the backend that uses it and the call that would. Withbackend="auto"the warning says which backend was chosen and why. Backend options left out use each backend's own default. -
Video export uses constant-quality encoding (
crf=18) by default, andcrf=reaches the encoder.Environment.animate_fieldsused to sendbitrate=5000on every call, which replaced constant quality, socrf=was silently ignored. For a short test animation the default output is visually the same (SSIM 0.9999 against the old encoding) and less than half the size. Passing bothcrf=andbitrate=warns that the bitrate wins andcrfis ignored. -
Environment.animate_fields(..., dry_run=True)writes nothing on every backend and returnsNoneafter reporting what it would render. The HTML backend ignoreddry_runand wroteanimation.htmlinto the working directory; napari opened a viewer and the widget displayed. The HTML backend now also drawsscale_bar=, which it ignored. -
Time-series overlays are drawn by the video and widget backends of
animate_fields. Those backends never received the frame timestamps the panels are aligned to, so the panels were silently left out. -
Static type checkers accept
Environment.from_polygon(polygon, ...)with a shapelyPolygon; it was annotated as taking thePolygonclass itself.from_file,from_dictandfrom_nwb(andneurospatial.io.from_file,from_dictandneurospatial.io.nwb.read_environment) keep the static typeEnvironment[Any], because the stored data decides whether the environment is Cartesian or polar; their docstrings now say so and point toenv.coordinate_kind. Theneurospatial.io.nwbfunctions, which load lazily, now have their declared types instead ofAny. -
The presets
Environment.open_field,Environment.linear_trackandEnvironment.mazeare easy to find: thefrom_samplesdocstring, a new Presets section in the environments guide, the quickstart and Core Concepts point to them. Every Core Concepts example now runs as written (several used undefineddata,polygonandgraphvariables), and the quickstart's example peak rate (about 12 Hz) matches what the code prints. -
The
Environmentdocstring examples run in the documentation tests. The skipped ones now execute on fixed example data, so they show real output:from_dictcompares the round trip withnp.array_equal(env.bin_centers, reconstructed.bin_centers)(it assertedenv == reconstructed, which is False now that environments compare by identity), andto_linear,subset,interpolate,smooth,transitionsandto_nwbshow their results.n_dims,from_polygon,plot_1dandlinearization_propertiesno longer document errors or keys that do not exist. Only the napari and video examples ofanimate_fieldsstay skipped, because they need a display and ffmpeg. -
simulate_trajectory_sinusoidalruns over the whole length of the track, in linear position. It used the x range of the track's coordinates, so a bent track was shortened to its horizontal extent: an L-shaped 120 + 80 cm track oscillated over 120 cm instead of 200 cm. -
PlaceCellModel(metric="euclidean")on an egocentric polar environment measures the distance between places in the animal's plane,(d cos(angle), d sin(angle)), sowidth(and its default, three bin spacings) is a length. It used to subtract(distance, angle)rows as if centimeters and radians were one unit. A per-axiswidthon a polar environment raisesCartesianOnlyErrorwith code that passes one width. -
neurospatial.testing.assert_environment_contractworks on a track built withouttrack_width: it checks that position lookups refuse with the fix that namestrack_width, instead of failing on the refusal. A track with a width gets the full lookup checks againstexpected_bins. -
ops.spatial_basissizes its basis by the track length on a linearized track. It used the geometric mean of the axis extents, which is zero for a straight track (it raised "sigma values must be positive") and unrelated to the length of a bent one: an L-shaped 200 cm track was sized as 98 cm. -
On egocentric polar environments, a
(distance, angle)row whose angle is a whole turn outside the angle range finds the bin of that direction. Before,points=queries, point regions and the polar lookups refused or skipped it as outside the environment, and on a full circle the angleπitself, the same direction as-π, was outside. A partial angle range still excludes the directions it leaves out. -
graph_turn_sequencelabelled every turn backwards. In the package's y-up frame (x East, y North), heading North and then turning West is a left turn, but it was reported as"right"; every"left"and"right"in its output is now swapped back to the animal's own side. Recompute saved turn sequences. Positions in image pixels, where y points down, must be flipped to y-up first, as the docstring now says. -
env.layout_parametersreports only options that shaped the bins.Environment.open_field(..., infer_active_bins=False)recordedfill_holes=True, its default, although every bin is active and nothing was filled; it now recordsfill_holes=False, andbin_count_threshold,dilateandclose_gapsare recorded as0/Falsewhenever active bins are not inferred. A 1-D grid (from 1-D samples or a 1-D mask) has no diagonal neighbors, and neither has any gridrebinderives from it, so it recordsconnect_diagonal_neighbors=Falsewhatever was passed; it used to record the defaultTrue. The bins are unchanged. A 2-D grid one cell wide keeps the choice, because refining it creates diagonal neighbors. -
Environment.from_graph,linear_trackandmazeaccept a NumPy scalaredge_spacing(for examplenp.float32(2.0)read from a config array) as they accept a Python number. It used to raiseIndexErrororTypeError. -
spatial_autocorrelationaccepted hexagonal environments and treated the hexagon lattice as a rectangular grid. Its rows are offset, so the autocorrelogram and the grid score computed from it were wrong. It now raisesNotImplementedErroron every environment without a regular 2-D grid of rectangular cells (hexagonal, mesh, track, 3-D, or a grid with fewer than half of its cells active), and namesspatial_autocorrelation_radial, which works on every layout. Tracks, meshes and sparse grids raisedValueErrorbefore. Rate-mapsummary_table()reports a NaNgrid_scoreon hexagonal environments. -
spatial_autocorrelationreported lags in cell steps. On a grid whose two axes have different cell widths (afrom_polygongrid of a 50 × 37 cm box withbin_size=3has 2.94 × 2.85 cm cells; small arenas can differ by tens of percent), rotating the autocorrelogram for the grid score and measuring peak distances for the grid scale mixed the two widths, so both were distorted. The autocorrelogram now has square pixels: lags along the wider axis are interpolated onto the finer width whenever the two widths differ by more than 1/100 of a cell across the grid. Grids fitted withfrom_samplesdiffer by up to a few percent between axes, and their grid scores move by about 0.004 (at most 0.04 in a sweep of 30 arenas). On a 120 cm arena with 2 × 3 cm cells, a hexagonal rate map with 30 cm spacing now scores 0.99 with scale 29.7 cm, as on 2 × 2 cm cells (1.03, 29.7 cm); it scored -0.13 with scale 26.1 cm. A grid with uneven edges along one axis (possible throughEnvironment.from_bins) has no single lag per step and raises the sameNotImplementedError, namingspatial_autocorrelation_radial. -
Grid scores change on every grid.
grid_scorerotated the autocorrelogram about the array center,(n - 1) / 2, while zero lag sits atn // 2, so on an even-sized map every rotation was half a pixel off zero lag; on an odd-sized map the annulus and the peaks used bygrid_scaleandgrid_propertieswere centered half a pixel off. Scores therefore jumped between even and odd arenas (about 0.1 for the same hexagonal cell). The metrics now drop an even axis's most negative lag, which has no positive partner, and measure everything about the zero-lag pixel. Hexagonal maps score higher, mostly on even-sized grids (median +0.09 over 30 randomfrom_samplesarenas; a perfect synthetic autocorrelogram scores 1.52 instead of 1.40); noise and shuffled maps change by a median of about 0.01-0.02 either way. Grid-cell labels from threshold screens can change for borderline cells whose score was near the threshold. -
grid_properties()averaged the orientation over every detected peak, including the spare one it detects beyondn_peaks, which can lie on the second ring of fields at another angle. Orientation was off by about 5° on synthetic hexagonal maps; it now uses the samen_peaksnearest peaks as the scale and is within about 1-2°. -
SpatialRateResult.grid_properties()converted pixels to distance with the smallest cell area, so itsscalewas in the wrong units (off by a factor of the cell width, e.g. 4× for 4 cm cells). It now usesautocorrelation_pixel_size(env), the autocorrelogram's pixel side. -
A position sample with a NaN coordinate is a missing sample in every per-sample query.
env.nearest_bingives it bin-1and distance NaN, andenv.interpolategives it NaN; both used to raise,nearest_binwith a SciPy error about finite values. A point with an infinite coordinate is outside: bin-1and distanceinf, or NaN frominterpolate.env.interpolatealso accepts a field with NaN bins, such as a rate map with unvisited bins, and returns NaN only where a point needs a NaN bin's value; it used to refuse the whole field. -
env.transitions(times=..., positions=...)counts a pair only when both of its samples are inside the environment, so a sample outside or with a NaN coordinate adds no transition. It used to raise "Invalid bin indices ... -1 (outside) values are not allowed" for any such sample, although the docstring said the trajectory input handles outside samples. A precomputedbins=sequence that holds-1is still refused. -
Track environments (
from_graph,linear_track,maze) store each connectivity edge'svectorandangle_2dfrom the lower to the higher bin ID, as every other layout does. They pointed the other way, soenv.get_edge_attributes()reported track vectors from target to source. On a track with gaps between segments (edge_spacing > 0), each node'ssource_grid_flat_indexandoriginal_grid_nd_indexnow name its cell of the linear grid described byenv.grid_shapeandenv.active_mask; they used to give the bin's position among the active bins, which pointed at gap cells after the first gap. -
The
edge_idof each connectivity edge is its row inenv.edge_pairson every layout, so it can index edge values such as gradients and fluxes. Edges across the angular seam of a polar environment, and the edges of a hexagonal, triangular-mesh or track environment aftersubset(), used to keep IDs that did not match their rows. -
env.boundary_binson a hexagonal environment checks all six neighbors of each hexagon, so every hexagon next to a hole, a concave notch or asubset()cut is a boundary bin. It used to check only four of the six (east, west and one diagonal pair), so two of the six hexagons around a removed interior hexagon were missed. Border scores withboundary_source="analysis"andBoundaryCellModelfiring on hexagonal environments with holes or irregular outlines change accordingly; full rectangular hexagonal arenas are unchanged. -
env.apply_transform(...)no longer refuses a valid environment with "polygon cell N disagrees with its locator" after a chain of coordinate changes, such as a scaled, rotated and reflected map followed by its inverse. Clipped environments (env.clip(...)) and hexagonal environments after a reflection store exact polygon cells, and the check compared them with the cells recomputed from the stored transforms at storage precision only, although recomputing them adds floating-point rounding. The check now allows that rounding, as it already did for bin centers and mesh vertices, and still caps it at a quarter of the smallest cell spacing. -
Environment.open_field(..., fill_holes=False)works; it used to fail with aTypeErrorabout multiple values forfill_holes. -
NWB reading and writing (
Environment.from_nwb,env.to_nwband theneurospatial.io.nwbfunctions) report "pynwb is required" only when pynwb itself is not installed. Any other import failure, such as a missing or broken pynwb dependency likehdmf, now reaches you unchanged with the name of the module that failed, instead of being reported as a missing pynwb. Pose and event reading and writing (read_pose,read_events,write_events,write_laps,write_region_crossingsanddataframe_to_events_table) do the same for their extensions: "ndx-pose is required" or "ndx-events is required" appears only when that extension itself is not installed. -
Environment.rebin(factor)on an environment without a stored grid specification (for example one built withEnvironment.from_binswithoutgrid_spec) records the diagonal-neighbor choice it used to connect the coarse grid. It previously guessed the choice from the coarse grid's connections, which recorded "no diagonals" when the coarse grid was one cell wide, so a laterrebin(bin_size=...)dropped diagonal neighbors. -
Smoothing on an environment transformed by an affine map fitted to noisy landmarks (
estimate_transform(..., kind="affine")) still refuses, because the fit absorbs tracking noise as a real stretch or shear. The refusal now reports the measured stretch and angle error, explains that the bandwidth would not be applied equally in every direction, and names the fix: fitkind="similarity"(or"rigid") or smooth on the source environment. It previously suggested recentering coordinates, which does not help. -
Triangular-mesh environments saved on one operating system now load on another. Loading used to rebuild the Delaunay triangulation and refuse the file when it differed from the saved triangles, which happens whenever sample points are cocircular (any square lattice), because Qhull breaks those ties differently across platforms. Point-to-bin lookup now uses the saved triangles directly, with the same edge tolerance as before. A point exactly on an edge or vertex shared by several triangles goes to the lowest-numbered saved triangle. Files whose triangles overlap or reference missing or repeated vertices are still refused, now with a message that names the triangles.
-
Environment.rebin(factor=...)keeps a coarse cell whose center lies on the arena boundary, such as the cell beside a missing corner of a grid mask or of a grid-aligned polygon. Two-dimensional arenas previously required strictly interior centers, unlike 1-D and 3-D masks, so a factor of 2 dropped coarse cells that held recorded fine bins (3 of 15 bins of a 4-by-4 mask without one corner), and positions in them mapped to-1. A factor of 2 now merges every fine bin of a grid mask inside the (possibly truncated) coarse extent. -
Both
rebinmodes warn when original bins are left without a new bin, naming their count, their share of the area and how to cover them. Positions in those bins map to-1, so their time and spikes silently dropped out of totals: about 2.5% of a circular arena's fine bins atfactor=2, and 56% of a 6-by-6 arena atbin_size=4. -
rebinwarns when new bins on both sides of a barrier in the arena outline are connected because they meet only outside the arena. In a U-maze with a 2-unit barrier, 5-unit bins shortened the path between the arm ends from 37 to 5. The connections are kept, as infrom_polygonat the same bin size; the warning names them and how to fix it: smaller bins, or the barrier's edges declared as internal walls. -
Posterior means on polar environments are the probability-weighted centroid in the animal's egocentric plane, reported as
(distance, angle)with the angle in the environment's range. A posterior split across the angle seam behind the animal now has amean_positionbehind it, not straight ahead. This coversmean_position,DecodingResult.mean_positionandto_dataframe(), anddecode_position_summary; a centroid at the animal has a NaN angle. -
Domain functions that read bin centers as Cartesian
(x, y)coordinates now raiseCartesianOnlyErroron polar environments instead of returning wrong numbers. The refusals coverfield_shape_metrics,field_shift_distance,spatial_autocorrelation,resample_field,map_probabilities,spatial_basis,graph_turn_sequenceanddetect_laps. Type checkers reject polar environments for these functions. Some functions refuse only their Cartesian mode and keep a graph-based mode for polar environments:rate_map_centroid(method="euclidean"),compute_field_emd(metric="euclidean"),select_basis_centers(method="kmeans")(also the defaultcenter_methodof the basis functions),path_progressanddistance_to_regionwithmetric="euclidean",trajectory_similaritywith"hausdorff"or"dtw", anddetect_runs_between_regionswithmin_speed. -
Environment.from_binsand environment loading now check supplied finite-volume faces for hexagonal and triangular-mesh bins against the cells: each face length must equal the shared cell edge (zero when cells share no edge) and each face distance the center-to-center distance. Inconsistent faces previously passed silently and changed smoothing and diffusion results. -
Ray-cast view rates no longer refuse when cells clipped to a curved or slanted wall reach it: a hit is owned by the cell within that cell's coordinate rounding of the hit, instead of an overlay of nearly coincident edges. Cells that stop short of the wall still refuse, and the message names the route that builds wall-reaching cells:
Environment.from_bins(grid.clip(polygon).binning, arena=OpenArena(polygon)). -
Observers tracked exactly on a wall no longer view their own position. A ray or cue sight line that leaves the wall into the room reaches the next wall; only facing out through the wall or exactly along it gives a zero-distance contact. Standing on an internal wall views the side faced. Gaze hits, viewsheds, visible cues, view-rate bins and simulated view cells agree.
-
Preserve static attribute and protocol checking for the common Environment; runtime Cartesian/polar capability filtering no longer admits missing methods.
-
Check physical view-map ownership only for samples with selected exposure. Missing wall bins raise actionable guidance; excluded/final samples and raw geometric hits retain their meanings. Slanted wall cues tolerate local cue rounding while earlier crossings and wall overlaps remain blocked.
- Physical border scores require complete represented and observed wall
coverage, distinguishing missing measurements from measured zero firing.
Unsupported scores and batch summaries provide setup guidance rather than
treating an unobserved wall as zero activity.
When the border score is unavailable,
summary_table()labels a spatially tuned non-grid cell"undetermined"instead of"place", since it may be a border cell; single-unit and population tables agree. Physical border scores refuse combined environments in which a component has no measured footprint, naming the component, instead of scoring its fields against walls in other components (euclidean returned -1 for a wall-hugging field). Declare each component's outline or useboundary_source="analysis". A single arena that declares internal walls but not its footprint (OpenArena(..., physical_footprint_known=False)) is refused the same way: a field along its outer edge was scored against the internal walls alone (-1, labeled"place"; now NaN, labeled"undetermined"). Declare the measured outline, for exampleOpenArena(outline, walls=..., physical_footprint_known=True). - The border-score examples in the neuroscience metrics guide raised on their
position-derived environments, which have no measured walls. They now
explain the physical-versus-analysis boundary choice, pass
boundary_source="analysis"and run in the documentation tests. -
Prevent automatic combined-environment bridges from crossing a third component, including tiny and nearly collinear footprints. Long affine histories retain their arena through storage without using the composite nesting budget. Clipped diffusion keeps its own certification boundary.
-
Omit transient analysis caches before copy/pickle; retain JSON-compatible protected graph metadata. Explicit bins now reject wall-crossing supplied pairs, keep drawing outlines separate from physical evidence, and validate finite-volume faces against their grid geometry across coordinate scales. Clipping preserves declared navigation paths and stores equal parent maps once.
-
Reject circular region metadata before exhausting the interpreter stack, avoiding a test-worker crash on Windows/Python 3.10.
-
Compare point regions by coordinate values, avoiding ambiguous NumPy truth values on Python 3.10–3.12 and when comparing independent region copies.
- Label place fields from actual graph connections, preserving diagonal links and physical walls after derivation and storage. Polygon clipping now retains per-parent ownership at floating-point precision and exposes analysis boundary bins. Wall-crossing checks also handle exact on-wall trajectory samples.
- Require declared track width for containment. Track and maze presets accept
track_width. -
Reject cyclic environment metadata before recursive decoding, avoiding a Windows/Python 3.10 stack crash while retaining shared acyclic records.
-
Preserve coincident track centers, native mesh indexing and reserved user graph attributes through environment storage. Malformed JSON/NWB/NPZ records now report contextual recovery guidance.
-
Preserve intended radial/angular bin counts when a polar step divides its range but floating-point division rounds above an integer. Polar circularity now defaults to automatic: full circles wrap, partial angle ranges remain open, and explicitly wrapping a partial range is rejected.
-
Preserve dotted recording names in environment file prefixes, so saving
rat1.day1andrat1.day2produces independent file pairs. Explicit.jsonand.npzpaths remain accepted. -
Preserve physical diffusion faces on grids with small coordinate units; exactly nonzero axis displacements no longer disappear below an absolute
1e-9cutoff. Diagonal navigation edges still carry no shared face area. -
Keep valid gaze hits on slanted physical walls during view encoding, using retained wall contact and parent-bin ownership to avoid crossing missing bins.
-
Preserve each component’s declared physical walls when environments are combined, including shared room boundaries and measured walls alongside unknown domains.
-
Subsets retain exact bin measures, selected finite-volume faces, cell shapes and absolute track coordinates. Derived environments can be saved and loaded.
- Translations and similarities retain smoothing, differential operators and drawing components; positive diagonal grid scaling also keeps rectilinear interpolation and updates the grid edges.
-
Triangle plots retain exact mapped vertices under every invertible affine transform, including shear and unequal scaling. Smoothing still refuses maps that lose the required finite-volume face geometry.
-
NWB environment read-back retains geometric lookup, smoothing, boundaries, plotting and track linearization, with the original graph attributes.
-
Plot graph fields against physical linearized coordinates, preserving unsupported bins and inserted display gaps in both lines and shaded areas.
-
Behavior change:
DecodingResult.error_againstnow returns NaN for truth outside observed runs instead of clamping to endpoints or interpolating across dropouts. Plot overlays use the samemax_gaprule; decoder scoring forwards the fitted decoder's gap configuration. -
Match tutorial raster spikes to the PSTH's half-open window, register video preview pixel centers with the full affine transform, and label maze endpoint proxies used by transition summaries. Check exact spike edges and affine registration across notebook sources and documentation mirrors in CI.
-
Omit threshold-based cell labels from the NWB tutorial's persistence summary.
-
Resolve heading colormaps such as the default
hsvto angle-dependent RGBA colors in Matplotlib exports and persistent artists. Preserve literal colors, update colors with heading, and wrap full turns consistently. -
Apply the full pixel-to-world affine transform to rendered video images, preserving Y-axis flips, rotation, shear and translation. Export and widget paths share the same mapping and keep axes/data limits in physical units. Pixel-position regressions check both initial and updated frames.
-
Render supported overlays consistently in embedded and external-frame HTML, including regions without other overlays. Preserve global frame clocks and overlay histories across parallel workers, and redraw video overlays when persistent artists cannot represent them. Warn and skip unsupported HTML overlays without mutating or reading them.
-
Simulation recovery validation requires finite center-error and correlation metrics for every selected unit before passing. Return aligned unit labels, spike counts, per-unit outcomes and skip reasons; report evaluable counts accurately and plot only jointly finite metric pairs.
-
Explain the generalized border score's untuned baseline: a uniform map can have a strongly positive score. Add analytic flat, boundary-only and central controls, and require geometry-specific interpretation of score thresholds.
-
Use a normalized center-surround contrast in the graph-convolution example, with a positive center and no response to a constant input.
-
Render transformed-layout field markers in display units, honor scatter size overrides and retain the requested missing-support color without changing shared colormaps.
-
Apply inclusive minimum sample-count thresholds consistently across regular and hexagonal grids; threshold zero still leaves unvisited bins inactive.
-
Clarify that
detect_goal_directed_runsevaluates one candidate per observed sample run, and that success means sufficient directed progress rather than arrival. Point completed-traversal analyses todetect_runs_between_regions. -
Landmark video calibration now includes the raw-pixel Y-axis flip in the returned transform, so landmarks and unseen video points map to the intended physical coordinates for rigid, similarity and affine fits.
- Windows CI installs FFmpeg with a pinned setup action instead of the unreliable Chocolatey package feed. Every test platform verifies that FFmpeg and FFprobe are available before running the tests.
- The linear-track validation example records enough repeated traversals and spikes for stable field recovery with continuous Poisson event times. Documentation CI uses each job's declared Python version instead of running both version labels with the repository's default interpreter.
- Partial missing-rate masks retain the position-dependent Poisson factorial terms, preventing incorrect posterior probabilities and MAP bins.
- Decoder xarray exports contain independent writable arrays, coordinates and metadata, so editing an export cannot corrupt cached position estimates.
- Fitting a decoder requires observed training support without requiring its prediction bin width to fit inside the training recordings.
- Phase-precession shuffle statistics use a data-adaptive slope grid that resolves narrow peaks on physical tracks, with the same procedure for the observed and shuffled pairings. Physically equivalent position-unit and slope-bound conversions preserve significance. Searches requiring more than 20,000 candidates explain how to narrow slope bounds or normalize positions instead of silently losing resolution.
- Affine transforms preserve physical cell measures in 1D, 2D and 3D, including anisotropic cells and segment-direction length scaling for embedded tracks. Transformed grids reload from NWB through the documented non-grid fallback instead of attempting reconstruction with stale, untransformed grid edges.
- Circular polar grids with two angular sectors include both shared radial interfaces in their finite-volume diffusion conductance.
- Behavior change: Poisson spike simulation samples counts and continuous event times inside each observed interval, retaining multiple events under coarse or nonuniform sampling. The final sample no longer invents a following recording interval. Seeded spike trains change; a given seed remains reproducible, and refractory filtering still enforces the requested ISI.
- MSD matches the nearest recorded time, excludes outside/missing/disconnected geodesic pairs, and returns NaN for lags with no observed displacement pairs. Curvature retains the original sample timestamps through stationary periods before smoothing within each observed run.
- Position decoding rejects rate results built on different bin coordinates,
measures, coordinate systems, units, frames or weighted connectivity, even
when bin counts match or
validate=False. Equivalent copied environments remain supported. - Rate results own their analysis settings and return independent copies,
including nested recording windows. Fitted decoders also return independent
settings. Editing settings can no longer change training support or reported
provenance. Previously saved rate-result pickles migrate their stored
settings on restoration; results predating provenance keep settings as
None. - Published connectivity graphs reject node, edge and graph attribute edits,
in addition to topology changes, so edge-distance edits cannot bypass cache
invalidation.
env.connectivity.copy()provides an editable graph for separate analyses; protection survives pickling and deep copies. Coordinates, grid indices and edge vectors are owned immutable tuples, including after JSON/file restoration, so nested edits and graph copies cannot corrupt boundary caches or transformed edge geometry. -
The introductory decoding examples execute on partially observed space and pass rate results directly, preserving support even after zero filling.
-
Editing arrays after handing them to neurospatial no longer changes stored state.
SpikeTrainskeeps read-only copies of its trains and labels, so editing the caller's labels can no longer create duplicate unit IDs. Every result'sunit_idsis a read-only copy. ABayesianDecoderkeeps read-only copies of its rate maps, labels and training support, so editingrates.firing_ratesafterBayesianDecoder.from_rates(rates)no longer changes its predictions. Arrays stay read-only after pickling and deep copies. - Static type checkers such as mypy accept every documented spike input: a
list of per-unit arrays, a 1-D or NaN-padded 2-D array,
SpikeTrainsor a pynappleTsGroup. This covers every population encoder and significance test,population_peri_event_histogram,decode_session,decode_session_summaryand theBayesianDecodermethods. Previously, passing a plain list orSpikeTrainstoBayesianDecoder.fitfailed type checking. - Predicting with an unfitted
BayesianDecodernow explains thatfit()returns a new decoder and shows the fix,decoder = decoder.fit(spike_times, times, positions). The old message told users who had already calledfit()to call it again. - Rebinned environments load from files and dictionaries again, and rebinning preserves diagonal connectivity on small and sparse grids.
- Linear interpolation retains known values at active-bin centers and grid lines next to holes; missing corners with positive weight still produce NaN.
- Circular polar grids with one or two angular bins correctly exclude interior radial rings from their boundary. The circular flag also survives NWB saves; older NWB records without the flag retain their previous open-axis behavior.
- Type checkers report wrong arguments to environment methods, such as
env.bin_at("not an array")or an unknown keyword; the fitted-state check previously hid every decorated method's signature.plot_fieldaccepts amatplotlibColormapas well as a colormap name. subset()of a grid keeps the parent's edges among the selected bins instead of reconnecting them by cell adjacency. A 4-connected grid read from NWB, or a saved graph with removed edges, previously came back with edges it never had (a 4-connected grid became 8-connected), changing geodesic distances and smoothing.to_linear()andlinear_to_nd()on tracks read from NWB explain that the track graph is not stored and how to rebuild it, instead of requesting a bug report.- The Euclidean
distance_torefusal on polar environments no longer prints()(). - Type checkers accept calls such as
env.smooth(field, bandwidth)andpolar_env.to_dict()onEnvironmentandEgocentricPolarEnvironment; mypy previously reported "Invalid self argument". - Polar
boundary_binstreats a circular angular axis as wrapping: bins beside the ±π seam are interior, so a full circular grid's boundary is its innermost and outermost rings. grid_cell_sessionno longer permutes rows of its environment'sbin_centerswhile shuffling grid-cell phases; the returned environment's centers matched neither its graph norbin_at.- Refresh active-bin lookup indices when rebuilding a masked polar layout, so angular seam edges and point lookup use the new mask.
- Keep the saved connectivity of environments read with
from_file,from_dictorread_environmenton their layout. Gradients and divergence work on 4-connected grids read from NWB, andcopy()of a read-back environment keeps 4-connectivity and polar seam edges and arc lengths instead of reverting to the rebuilt layout's graph. - Point dense-kernel and mesh-skew warnings at the scientist's call rather than library code.
- Correct spatial information and sparsity for missing data in NumPy and JAX, including per-neuron support normalization in population metrics. Observed zero activity remains distinct from missing rates.
- Correct missing-bin centering in spatial autocorrelation so zero lag is one and constant firing-rate offsets cannot change the correlogram.
- Whiten PCA scores before ICA assembly detection, handle null components, and compute explained variance for the recovered ICA components.
- Correct reverse explained variance to swap pre- and post-behavior periods while retaining the behavioral template.
-
Breaking:
mutual_informationnow computes discrete mutual information from a joint position-response histogram in bits per observation. Old rate-map calls raise migration guidance; useinformation_per_secondfor the previous Skaggs information-rate calculation in bits/second. -
Exclude unsupported training locations from fitted/session decoding and functional decoding of rate results, including silent bins, explicit priors and streamed summaries. Explicit raw-array decoding must supply the support prior separately; computational zero filling no longer supplies evidence for unobserved space in the fitted workflows.
- Preserve missing geodesic steps as NaN instead of indexing the last spatial
bin with the outside-position sentinel. Report missing pre-decision/heading
observations as NaN and expose
PreDecisionMetrics.is_valid; missing evidence cannot produce a positive deliberation label. -
Warn when slicing aligned arrays loses boundaries between observation windows; document using original arrays with
epochs=and half-open event cohorts. Correct epoch input forms and the home-range area recipe, and execute those recipes in documentation tests. -
Correct workflow guidance on raw-second occupancy thresholds and smoothing missing support, with executable occupancy examples.
-
Clarify cell-screen thresholds and the scope of synthetic, biological and cross-package validation.
-
Label decoding errors as in-sample and interpret sequence shuffle tests relative to their null. Refresh notebook outputs and synchronize the source and documentation copies.
-
Preserve containment and inactive bins after affine environment transforms, including chained transforms, and retain polygon-region holes. Reject singular transforms that collapse bin geometry.
- Preserve generated unit-label provenance and spike-recording windows in NWB spatial-rate round trips (schema
2.3), so reloaded maps retain their decoder pairing and coverage. Older2.xfiles remain readable with their existing defaults. - Return
NaNpath progress when the current position is disconnected from the start, even if the goal is reachable. -
Compute video calibration scale from transformed axis lengths so rotations and reflections do not change the reported
cm_per_px. -
Unit labels of mixed types keep their types:
unit_ids=[1, "u"]no longer turns1into the string"1". Result keys, indexed results, labelled spike groups and shuffle random streams all use the label as given, so a unit's seeded null is the same in single and population calls. - Behavior change: spatial rates use only intervals whose two tracking samples both lie inside the environment. An interval ending in a dropout (NaN) or outside was previously kept in the occupancy while its spikes were dropped, biasing rates low (20% isolated NaN frames read a 5 Hz unit as 3.97 Hz); it is now excluded from spikes and occupancy alike, which recovers 5 Hz without imputing positions.
- The all-intervals-excluded, spikes-outside-tracking, out-of-bin and
min_occupancy-masks-all warnings point at the user's calling line instead of a line inside neurospatial, for every rate family. object_vector_cell_significanceandegocentric_object_vector_cell_significancebuild the polar grid and each frame's polar bin once per call instead of once per shuffle: about 3x faster (0.24 s to 0.08 s for 10 units, 20 shuffles, 60,000 samples) with identical results.- Kinematic speeds treat a zero-length interval (duplicate timestamps) as
undefined (NaN) instead of infinite, so
pre_decision_speed_stats, goal approach rates and heading labels no longer absorb aninfor a divide-by-zero warning. - Kinematic speeds from 1-D (linear-track) positions are the track speed.
pre_decision_speed_statspreviously broadcast a 1-D difference against the time steps and returned an unrelated number. - Breaking:
Environment.bin_center_ofraisesBinIndexOutOfRangeErrorfor indices outside[0, n_bins), like the graph queries. Negative indices previously wrapped silently to the last bins, and indices past the end raised NumPy'sIndexError. DecodingSummary.plotbreaks its entropy and MAP lines at recording gaps instead of drawing a straight segment across the pause.- neurospatial exceptions survive pickling, so errors raised in worker processes reach the caller with their original message and attributes; previously several failed to unpickle or repeated their message.
SimulationSessionchecks thattimesandpositionshave one row per sample and thatground_truthis keyed by exactly theunit_ids, so relabeling units withdataclasses.replacemust re-keyground_truthtoo. Duplicateunit_idsare rejected, since two cells sharing a label would both be validated against one ground truth.NWBUnitschecks thatunit_idsandobs_intervalsalign withspike_times.ObjectVectorRateResultandObjectVectorRatesResultreject adirection_frameother than"allocentric"or"egocentric"; plots previously drew any other value as egocentric.spatial_view_cell_significancecomputes the gaze geometry once per call instead of once per shuffle. Withgaze_model="ray_cast"on 3,000 samples, 10 shuffles drop from 6.95 s to 0.64 s with identical observed scores, null scores and p-values.- Object-vector and egocentric
method="binned"rates keep bins whose occupancy equalsmin_occupancy, as every other rate path does, and warn whenmin_occupancymasks every occupied bin. ShuffleTestResultstoresnull_scoresas a read-only copy, and its p-value documentation names the finite-null denominator the significance functions use.DecodingResult(...)checks that the posterior is 2-D with one column perenvbin and thattimeshas one entry per posterior row, instead of silently mapping columns to the wrong bin centers. Thetimeschecks thatto_xarray()used to apply now fire at construction.- Smoothing,
gradient,divergenceandheat_kernel_wavelet_basison a non-grid environment read from NWB explain that the round trip does not restore cell geometry and name the original layout, instead of advising a factory method the user never skipped. BayesianDecoder.fit(and the session decoders that build an encoding model) raiseValueErrorwhen the speed, gap, window andmin_occupancygates leave no occupied bin, instead of returning a model that decodes every time bin as a uniform posterior.-
read_unitsreads files in which a unit has noobs_intervalsrows (a unit that was never observed); that unit's coverage is an empty(0, 2)array, so the sharedspike_windowis empty unless it is excluded withunit_ids=. -
Center continuous posterior image columns on decoder timestamps, preserving MAP/actual overlays, gapped index clocks and explicit extent overrides. Two-column plots use their timestamp spacing; a single column has a documented 1-second display width, with no changes to posterior arrays or clocks.
-
Plot native 1D rate maps as labeled lines over physical bin coordinates, including singular/population results and NaN breaks. Existing graph-track and 2D plotting paths are retained; rate arrays are unchanged.
-
Name the current
spike_timeskeyword in simulation validation's missing-input diagnostic. -
Name nonnumeric times/positions together in shared conversion errors, retaining shape/order problems from the convertible argument and providing Why/Fix guidance without changing valid arrays.
-
Share one swap-aware times/positions validator across encoding and environment trajectory APIs. Timestamp shape, finiteness, ordering and sample-count problems are reported together with a corrected call; missing position samples remain supported.
-
Explain invalid circular-shift windows with a valid
windows=call, preserving all row/shape problems from shared interval normalization. -
Resolve heading interpretation thresholds through
HEAD_DIRECTION_THRESHOLDS, matching the predicate and batch screen while preserving the default text and positional option. -
Align place shuffle windows with inferred speed gates and 1-D trajectory normalization, preserving retained null spike counts. Validate object-vector metric choices before coordinate construction in both frames, with shared public diagnostics.
-
Validate required environments before constructing place/view shuffle windows, so missing environments receive the encoder diagnostic and a corrected call. Object-vector Euclidean significance continues to accept
env=None. -
Normalize allocentric object bearings exactly like zero-heading egocentric bearings at direction-bin boundaries, and make invalid-environment guidance omit headings for allocentric calls. Result help consistently describes the recorded frame.
-
Require the documented spatial-information threshold for every place, grid and border label. In the seeded 10-minute noise example, erroneous border labels fall from 19/20 to 0/20; label precedence and threshold values are unchanged.
-
Honor requested duration in lap-based sessions, including linear-track and T-maze conveniences. Keep every one-way traversal and fixed pause on a shared half-open recording clock, derive traversal speeds from available time, and reject infeasible durations with guidance. Direct speed-driven lap trajectories and other simulation methods are unchanged.
Fixed — documentation¶
-
Document singular/population table parity, readable column order, units/threshold metadata and singular xarray identity. The quickstart covers native 1D rate plots; animation guidance explains explicit overwrite opt-in and the current HTML numeric-colorbar limitation with an executable static-scale/timestamp-label recovery. Posterior plotting help records the centered continuous and short-clock edge conventions.
-
Include required unit labels in the manual
SimulationSessionconstructor example. -
Teach explicit simulator/NWB holder attributes and a complete file → selected epochs → population fields → decoder → summary/overlay workflow. A synthetic HDF5 example executes the published NWB recipe after file close with physical units, nondefault unit IDs and two analysis epochs; synchronized tutorials preserve the simulation-duration contract.
-
Preserve the existing shared-validator NumPy examples and parameter sections while adding timestamp/position diagnostics.
-
Teach explicit tracking arrays, typed precomputed-rate and count-array decoding handoffs, and one retained event cohort across gap-aware PSTHs, rasters, regressors and positioned tables. Keep event identifiers and selection masks visible, explain each helper's existing edge closure, and synchronize the updated tutorials.
-
Correct both decoder tutorial posterior overlays to use spatial bins and the MAP line's plotted time coordinates. Synchronized examples and an executable continuous/gapped-clock recipe preserve physical coordinates for accuracy metrics and separate position plots.
-
Explain graph linearization as geometric coordinates rather than direction separation. The synchronized track tutorial and an executable graph/trial recipe use explicit direction labels and recover separate planted field centers within one bin.
-
Distinguish standardized assembly activity and EV/REV effect sizes from calibrated significance, document controlled REV correctly, and explain that selected dimensions can have no thresholded core members. Statistical calculations are unchanged.
-
Fix the README's simulated-field peak using a complete 5 cm sampling grid and the result's NaN-aware peak lookup. Reference examples use canonical graph, visibility, region, and immutable-data APIs; expected gotcha errors are checked with markers.
Documentation¶
- Add a simulated mazes page that shows every maze in
neurospatial.simulation.mazesat true scale, drawn from the package at each docs build, with each maze's source, default size and named ports and boxes. - Add a population analysis guide organized by scientific question: place-field coverage and place-cell screens, remapping between conditions, cell assemblies and their reactivation during rest, and pointers to decoding. It maps each question to its module, since coverage and remapping tools live in
neurospatial.encodingwhile assembly and reactivation tools live inneurospatial.decoding. Its examples run as tests on simulated recordings with planted answers. -
compute_turn_anglesandcompute_trajectory_curvaturedescribe what they compute for positions with more than two columns: the cross product uses x and y but the dot product uses every column, so the angle is neither the horizontal-plane nor the 3-D angle; passpositions[:, :2].compute_turn_anglesstates that it drops repeated positions first, so its output is shorter than the input and not aligned with the frames, and its formula now has the sign the code uses (positive is a left turn).compute_trajectory_curvaturestates that it returns turning angles in radians per frame, not geometric curvature; usecompute_planar_curvaturefor 1/length. Outputs are unchanged. -
Fix renamed phase-precession imports in executable examples and move the spatial adapter test normalizer import to its owning private module and distinguish independently standardized assembly activation from reactivation strength on a shared template-normalized projection scale.
-
Update root/domain imports and hard-break migration guidance, and make population covariance APIs discoverable from the API index. Add an executable simulation → encoding recipe with separate decoding and population-statistics branches, one shared unit selection and explicit baseline-controlled EV/REV interpretation.
-
Document recording-window metadata on the spatial result classes now exposed at the root; their existing GLM diagnostics and constructor fields are covered by the public docstring checks.
-
Clarify that the head-direction MVL screen weights firing rates and its Rayleigh test weights spike counts, with a rate-times-occupancy fallback. The numerical Rayleigh calculation is unchanged.
-
Update cell-criterion migration, README/quickstart, API references, glossary and neuroscience guidance. Execute six new flagship docstrings and synchronize the view, heading and object-vector tutorials; the object-vector tutorial reports its estimator/criterion and adds seeded shuffle results alongside an explicit place-cell control.
-
Document finite-count information bias and the measured noise-screen results in every cell predicate, field detector and batch classifier. Explain occupancy bias in Rayleigh tests weighted by spike counts, circular-shift assumptions and the need for calibrated publication verdicts; retain the uniform-spike example and show its shuffle rejection.
-
Teach allocentric and egocentric object-vector analysis separately in the guides, migration notes, glossary and synchronized tutorial. Public examples record the frame and use the appropriate heading-free or heading-required call; executable docstrings cover the new APIs.
-
Document marker-based pytest checks and current first-run calls. Publish the changelog from one canonical source, preserving copy-only notes and consolidating duplicate release headings.
Removed¶
-
Breaking: remove
Session,load_sessionandneurospatial.recording, plus the unusedPositionLiketrajectory adapter andvalidate_simulation's raw keyword form. NWB component readers and simulator holders expose arrays for explicit analysis calls; pynapple conversion still returns(times, positions). -
Breaking: remove the image-mask
bin_sizekeyword shim and both view-resultpeak_view_locationmethods. Usepixel_sizeand the sharedpeak_location()/peak_locations()accessors. -
Breaking: remove root decoder/binner, spike-container and epoch-selection re-exports; import them from their domains. Drop public normalizers, event validators, animation internals/protocols, duplicate calibration and renderer helpers, and cache plumbing. Delete the probability-space Poisson wrapper,
integrated_absolute_rotation,Affine3D,SpikeOverlayand surrogate compatibility imports. -
Remove the superseded public API curation plans and the test pinning the old root export list; the public API snapshot replaces that export contract.
-
Remove the deprecated
detect_cell_types,detect_hd_cells,detect_view_cellsanddetect_ovcsmethods; uselabel_cell_typesorclassify. -
Remove the deprecated region-crossing positional order. Use
detect_region_crossings(position_bins, times, env, region_name="home");region_nameis required and keyword-only.
Added — executable documentation¶
-
Require function inputs to be documented under Parameters or Other Parameters; only class constructors may also use Attributes.
-
Install widget support in the headless module-doctest CI job so the newly executable widget example runs there too.
-
Execute Markdown examples using adjacent
docs-testmarkers, cumulative reader state for README/quickstart, fresh seeded blocks for reference fragments, and restored animation setup patches. Replace the indexed snippet manifest and subprocess helper with default pytest coverage. -
Execute flagship docstrings, including NWB geometry and position examples, with only display/video calls skipped. Module doctests write outputs in temporary directories; the NWB workflow enforces its examples separately.
-
Check public docstring sections and constructor inputs, and complete the missing examples and parameter documentation.
Changed — errors that teach¶
-
Corrected-call guidance includes each encoder's and decoder's required arguments and all animation backends. Population egocentric validation reports coordinate and length problems together, and malformed peri-event window bounds fail with guidance before alignment.
-
Region fixes show list-valued
end_regionsand handle missing names without a conversion error. Zero-rate posterior errors distinguish all-negative-infinity likelihoods from corrupt NaN inputs. -
Warn visibly when
bin_sizecovers every data axis or a peri-event window exceeds 60 seconds, with units and corrected values. Large-grid warnings are nowUserWarninginstead of hiddenResourceWarning; the existing hard memory ceiling is unchanged. Length-1 bin-index arrays explain how to unwrapbin_atoutput before a graph query. -
First-run factory, graph-query, encoding, decoding, peri-event, animation and file errors give concrete corrected calls. Segmentation and boundary regressors raise
RegionNotFoundErrorwith the caller's region argument and available names; NWB readers and direction-label lookups name their corrected arguments. -
Replace
HOW:labels withFix:in diagnostic messages. Shared timestamp, spike and trajectory validators name their caller, collect all input problems, detect swapped arrays, and check coordinate dimensions before binning. A non-Environment first argument now raisesTypeErrorwith a corrected call. -
Add
NeurospatialErroras the common base for library exceptions while retaining their standard Python bases.RegionNotFoundErroralso supportsexcept ValueErrorand prints an unquotedFix:line with a suggested region name. - Breaking: raise
BinIndexOutOfRangeErrorfor graph-query bin indices (formerlyIndexError; it subclassesValueError, notIndexError, soexcept IndexErrorno longer catches it),LayoutNotBuiltErrorfor unbuilt layouts, andIncompatibleEnvironmentErrorfor mismatched environment dimensions or decoding bins.
Changed — behavior analyses respect recording gaps¶
- Timestamp-based heading rejects malformed position shapes before velocity division, preventing a 1-D trajectory from broadcasting into a square matrix. The error names the shape and supplies an aligned x/y-coordinate fix.
- Timed home ranges omit bins with zero observed dwell, including at
percentile=100, so rounding in cumulative percentages cannot include an isolated sample's bin. Untimed visit counts retain their previous meaning. - The 13 kinematic/event entry points expose
max_gap=0.5andepochs=None:heading_from_velocity,pre_decision_heading_stats,pre_decision_speed_stats,head_sweep_from_positions,heading_direction_labels,compute_path_efficiency,instantaneous_goal_alignment,goal_bias,approach_rate,compute_goal_directed_metrics,compute_trajectory_curvature,compute_home_range, andevents.add_positions. Pre-decision and VTE composites use their existing window keywords for kinematics too. Examples and notebook pairs now pass timestamps toheading_from_velocity. - Speed statistics, approach rates, direction labels, head sweeps, trajectory
curvature and home-range dwell respect
max_gapandepochs; goal and VTE composites forward the same windows. IdPhi sums runs without joining their headings, and the two-epoch dwell totals 200 seconds instead of charging the pause to a bin. Entirely excluded speed windows return NaN without reduction warnings. Samples outside runs have NaN curvature and stationary labels. - Breaking:
heading_from_velocity(times, positions)replaces scalardt, validates finite, strictly increasing aligned timestamps, and computes velocity, smoothing and circular interpolation separately per observed run. The measured 1,911.44 cm/s teleport no longer produces a heading across the pause. Isolated samples and runs without moving anchors have NaN headings. Shared heading statistics, goal alignment and simulation callers use actual timestamps. Smoothing bandwidth retains its existing units of samples. events.add_positionsacceptsmax_gapandepochs, interpolating only inside closed observed-run spans. Events in pauses, on isolated samples or outside the tracked span now receive NaN coordinates; extrapolation ends. Events exactly at either observed run endpoint retain that sample's position.- Kinematic interval velocity masks unobserved intervals with NaN, preserving interval alignment and position-units-per-second measurements.
- All 16 segmentation and sequence entry points now expose keyword-only
max_gap=0.5andepochs=None:detect_region_crossings,detect_runs_between_regions,segment_by_velocity,detect_laps,segment_trials,detect_goal_directed_runs,running_direction_labels,detect_boundary_crossings,extract_pre_decision_window,compute_pre_decision_metrics,compute_decision_analysis,compute_vte_trial,compute_vte_session, andEnvironment.bin_sequence,bin_sequence_with_runs,transitions. Usemax_gap=Noneto disable the gap gate for intentionally coarse sampling;epochsstill applies. - Position-only analyses warn when
max_gaporepochsexclude every interval, for example tracking sampled more slowly than 2 Hz under the defaultmax_gap=0.5. The warning names the active gate, reports the median sampling interval and points at the calling line, so an empty or all-NaN result is no longer indistinguishable from a measurement. - Pre-decision extraction, metrics, composite decision analysis and trial/session
VTE accept
max_gapandepochs. Pre-decision samples come only from the observed run containing the entry time; entries outside every run return an empty window. The pause example retains five post-resumption samples instead of ten spanning 1000.9 seconds. Session VTE still respects the trial start. - Runs, trials, velocity epochs, laps, goal-directed candidates and decision
boundary crossings accept
max_gapandepochsand analyze each observed run separately. The measured 98.5→1100 s run and 0→1100 s trial are no longer marked successful across a pause, the 98.1→1100 s movement epoch is clipped, and the 88.6→1108.6 s region lap and 599.95 s boundary crossing disappear. Auto laps keep the first 10% of the whole input as their template. Running direction labels forward the same windows and retain one label per sample. detect_region_crossingsacceptsmax_gapandepochs, and detects entries and exits separately within observed runs. A first post-pause target sample no longer invents an entry at 1100 s. The contiguous detector keeps its existing crossing timestamps and region-boundary handling.Environment.bin_sequenceandbin_sequence_with_runsacceptmax_gapandepochs, drop samples that touch no observed interval (including singleton inputs), and split same-bin runs and deduplication at recording breaks. Original run indices and lengths stay aligned with the input. Empiricaltransitionsgates every intervening interval in a lagged pair; the two-epoch fixture gives 9,998 one-step and 9,994 lag-three pairs. Raw bin-only transitions retain their previous behavior without a time gate.- Empirical transitions return a zero CSR matrix when every pair is excluded by gaps or epochs, including lagged pairs under the default adjacency filter.
Changed — decoding and peri-event histograms respect recording gaps¶
- Peri-event counts use original spike times against absolute event-shifted edges, preserving inclusive starts and exclusive stops at nonzero and Unix timestamps. The shared precision guard rejects widths that the absolute clock cannot represent, with an origin-shift fix for spike/event times.
- Peri-event histograms share the decoding edge and count helpers: every bin
is half-open and stays inside its requested window. Spikes at the last
whole-bin edge are excluded, and large timestamp offsets use relative
rounding instead of an absolute
1e-9epsilon. - Conflicting
epochsand explicit bounds inbin_spikes_in_timereport the argument conflict with separateWhy:andFix:lines. - Single-unit and population peri-event histograms accept
epochsandspike_window, keeping only events whose entire analysis window is observed. Means and SEM use retained events, and results, child results, plots and summaries carryn_events_dropped. With neither window supplied, all events remain eligible; partial drops add no warning. - Full and streamed decoding results record
spike_windowandspike_window_assumed, including summaries and NetCDF-compatible xarray attributes. Attaching session metadata preserves the single posterior allocation; public construction and replacement still copy input arrays. - Posterior heatmaps with detected recording breaks use time-bin indices and dashed gap markers, preserving visible separation instead of stretching bins across a pause. Contiguous and single-bin results keep time axes; two timestamps alone cannot distinguish a gap from a larger bin width.
- Breaking:
BayesianDecoder.fit(epoch=...)is replaced by keyword-onlyepochsandspike_window. Training masks the original arrays instead of concatenating epoch slices, preserving aligned speed samples and unit identities.predict,predict_summary, andscoreforward the same window keywords and the decoder's configuredmax_gapto observed-run decoding. decode_sessionand its streamed summary acceptepochsandspike_window, and form bins only within observed sample runs. No posterior is invented inside a tracking pause. Decode bins retain immobility and out-of-bounds periods; speed/bounds restrict encoding only. Full and streamed paths use the same half-open edges and counters, including run breaks.bin_spikes_in_timeacceptsepochs=and tiles each normalized window independently. Shared decoding helpers count only half-open bins and clamp their edges to window stops. Final-edge spikes are no longer counted, and floating-point rounding uses timestamp-relative slack instead of an absolute1e-9epsilon (which lost a bin near1e9seconds). Partial trailing bins are dropped, and unrepresentable bin widths raise with an origin-shift fix.
Changed — recording gaps and time windows in rate maps¶
- The three frame-family cell predicates propagate invalid
epochsandspike_windowdiagnostics, including combined argument problems andWhy:/Fix:guidance, instead of silently classifying malformed inputs asFalse. Each analysis still normalizes the windows once. - Directional, view and egocentric encoders record the normalized
spike_windowon singular, population and zero-unit results. Population indexing/iteration preserves it, and xarray exports include the assumption as an integer and explicit windows as a flat array for NetCDF round-trips. compute_directional_rates,compute_view_rates, andcompute_egocentric_ratesshare the population-silence heuristic: with nospike_window, at least five units silent for at least 60 seconds within a tracked run produce one warning. The scan respects gaps and epochs, includes leading and trailing silence, and ignores invalid directional, view or polar bins; silence is not proof of recording coverage.compute_directional_rate(s),compute_view_rate(s),compute_egocentric_rate(s),is_head_direction_cell,is_spatial_view_cell, andis_object_vector_cellaccept keyword-onlymax_gap,epochs, andspike_window. They parse windows once and apply one shared analysis mask across units, including empty populations. Directional, view and egocentric rates now recover a 5 Hz synthetic rate across a 1000-second recording pause instead of charging the pause to a single bin. In an unsmoothed example, pooled directional/egocentric rates rise from 0.833 Hz to 5.001 Hz and view rates from 0.717 Hz to 4.916 Hz; no bin absorbs the 1000-second pause.- Directional, view and object-vector rates warn, like spatial rates, when
the gates exclude every interval (for example
epochsin milliseconds) or when more than half of the spikes fall outside the tracked time (for examplespike_timesin milliseconds), instead of silently returning an empty or truncated map. - Directional, view and egocentric binning helpers use one interval mask for
both spike counts and occupancy. Their default
max_gap=0.5excludes recording pauses instead of charging them to one bin, and their normalizedepochsandspike_windowkeywords restrict the same intervals on both sides. Behavior change: a spike attimes[-1]is no longer counted; the final sample starts no half-open interval. - Malformed numeric attributes on IntervalSet-like windows now report the
window argument, conversion problem,
Why:andFix:together, without hiding invalid values in the other time-window argument. compute_spatial_rateswarns once when at least five units are all silent for at least 60 seconds of continuously tracked time and nospike_windowwas supplied. This recording-outage heuristic includes leading and trailing silence and rest, but never combines silence across untracked pauses; an explicit window suppresses it. Its absence does not prove coverage.- Every rate result exposes
spike_windowand the read-onlyspike_window_assumedflag insummary(). Spatial encoders record the normalized acquisition windows, preserving them when indexing a population and in direction-conditioned place fields. Spatial population xarray exports store the assumption as an integer and supplied windows as a flat array, so both round-trip through NetCDF. Frame-family encoders now record their acquisition windows as well. - Breaking:
behavior.in_epochs,restrict, andrestrict_spike_trainsshare the same window parser as spatial analyses. Nested sequences always describe(n, 2)rows; parallel(starts, ends)arrays are no longer an input form, and a tuple or list of two 1-D NumPy arrays raises (namingnp.column_stack([starts, stops])) instead of being read as two rows. Overlapping or touching rows merge, zero-width and empty window sets raise with a fix, andepochs=Noneleaves data unrestricted. Point membership still uses the requestedclosed=setting on the normalized windows. - Spatial rate functions,
is_place_cell, andcompute_directional_place_fieldsacceptepochs=andspike_window=. One shared analysis mask excludes the same intervals from spike counts and occupancy, including GLM and empty populations. Direction-labelled runs use epochs instead of concatenated samples, removing artificial occupancy across breaks (1.6 s instead of 1.9 s in a repeated-label example). - Behavior change: with
max_gap=None, intervals starting outside the environment now exclude their spikes as well as their occupancy. A spike exactly at the last position timestamp,times[-1], lies outside all half-open sampling intervals and is no longer counted; it is reported as time-dropped. Environment.occupancyacceptsepochs=as half-open windows in seconds. It excludes intervals that cross window boundaries for both start and linear time allocation, interval counts, and smoothed occupancy.
Fixed — numerical and I/O corrections¶
-
Behavior change:
pre_decision_heading_statsnow excludes samples belowmin_speedusing the same uninterpolated velocity and speed as goal alignment. On an east/stop/north path, circular variance and resultant length are 0.2929 and 0.7071 (formerly 0.1849 and 0.8151 from stationary headings). The all-stationary result remains(0.0, 1.0, 0.0). -
Behavior change:
instantaneous_goal_alignmentnow returns NaN for samples belowmin_speed, andgoal_biasaverages moving samples only. On an east/stop/north path, all 30 stopped samples are excluded andgoal_biasis 0.5055 (formerly 0.5862 from interpolated stationary headings). Fully stationary trajectories still return NaN without raising. -
Behavior change:
heading_from_velocityandheading_from_body_orientationinterpolate missing or low-speed headings along the shorter arc, uniformly in angle. Near a 180° turn, filled headings formerly snapped toward the endpoints (errors up to 0.785 rad) because interpolation followed the chord. Antipodal turns follow the sign of the stored angle difference; unmasked NaN headings no longer contaminate neighboring filled samples. -
Behavior change:
heat_kernel_wavelet_basisnow uses the finite-volume diffusion generator, scaled by squared bin spacing. On uniform Cartesian grids its standard deviation issqrt(2 * scale)bins at every bin size (scale 1 formerly spread 2.77, 3.91, and 6.17 bins at sizes 1, 2, and 5). Layouts without finite-volume geometry now raiseNotImplementedErrorwith a fix.chebyshev_filter_basiskeeps its graph-hop locality and distance-weighted, spectrally rescaled operator. -
Behavior change:
gradient,divergence, andcompute_differential_operatornow use the finite-volume cell geometry ofenv.smooth: gradient divides by edge length, and divergence weights face flux by cell volume with positive values at sources. Forf=x, gradients are 1 at bin spacings 1, 2, and 4 (formerly 1, 2.83, and 8);div(grad(x²+y²))is 4 in grid interiors (formerly -15.31, -122.51, and -980.08). The Laplacian has the continuum sign and units Hz/cm² for rates in Hz and positions in cm. Layouts without finite-volume geometry, including NWB-reconstructed environments, now raiseNotImplementedErrorwith a fix instead of returning physically inconsistent derivatives. decode_position(and every decoder built onnormalize_to_posterior) floored the prior at1e-10, so a bin with zero prior could still win the posterior given a large enough likelihood (for example a MAP in an excluded bin with posterior0.999998). Zero-prior bins now get exactly zero posterior, small positive priors are no longer floored, and thehandle_degenerate="uniform"fallback spreads mass only over bins with positive prior (NaN when the prior has none).theta_phaseused a transfer-function Butterworth band-pass that is numerically unstable for a narrow theta band at typical LFP sampling rates: on a clean 8 Hz sine the phase was off by ~1.56 rad at 2-5 kHz and all-NaN at 10 kHz and above. It now filters with second-order sections (sosfiltfilt); the phase error is below 0.01 rad at 2-30 kHz.circular_linear_correlation,circular_circular_correlationandcircular_basis_metricscomputed p-values as1 - cdf, which cancels to exactly0.0for strong effects (below about1e-16). They now use survival functions, so a strong effect reports its true tiny p-value (for exampleexp(-500)for a perfect circular-linear correlation over 1000 samples).Environment.from_grid_mask(and everyMaskedGridLayout, includingenv.subset) accepted nonuniform, decreasing, duplicate or non-finitegrid_edges, then reported wrong bin sizes (for example[-1, -1]or[0, 0]) and used one width per axis for cell volumes and diffusion. It now raisesValueErrornaming the axis unless the edges are finite, strictly increasing and uniformly spaced (to within float rounding), and raises when coordinates are so large that float64 cannot resolve the bin width, with a fix to subtract an origin offset.- Behavior change:
BayesianDecoder.predict,predict_summaryandscorepassed spike trains to the encoding models by position even when both the fit and the predict input were labelled, so a reordered or different pynappleTsGroupsilently paired each unit's spikes with another unit's model. When both the fit (a labelled group, orunit_ids=at construction) and the predict input carry labels, trains are now matched by label in any order, and a label mismatch raisesValueErrorlisting the missing and unexpected labels. Otherwise trains are still paired by position, and a unit-count mismatch now raises aValueErrorthat says so (previously a Poisson likelihood shape error). A predict input that repeats a label raises. compute_directional_rates,compute_view_ratesandcompute_egocentric_ratesmis-read a pynappleTsGroup(it was coerced as one train, giving one unit labelled0). They now accept it likecompute_spatial_rates: one rate map per unit, with the group's index asunit_ids.- Behavior change: passing
unit_ids=together with a labelledTsGroupsilently replaced the group's labels, so incompute_spatial_ratesa group keyed[10, 20]withunit_ids=[20, 10]attached unit 10's spikes to label - All four population encoders now raise
ValueErrorlisting both label sets unlessunit_idsequals the group's index. - Behavior change: repeated
unit_idswere accepted (for examplecompute_spatial_rates(..., unit_ids=[5, 5])). Every place that takesunit_idsnow raisesValueErrornaming the repeated labels: the population encoders,population_peri_event_histogram, theBayesianDecoderconstructor andfit(fitted labels) andpredictinput labels, and population result constructors (includingsummary_table(unit_ids=)relabelling).SpikeTrainsalready rejected them. - Behavior change:
to_pynapplepassed unsorted timestamps to pynapple, which sorts the times without reordering the values, soto_pynapple([0, 2, 1], [10, 20, 30])returnedt=[0, 1, 2],d=[10, 20, 30](each value paired with another sample's time). It now raisesValueErrorfor unsorted, repeated or non-finite timestamps, naming the first offending indices, and for acolumnslist whose length does not match the value columns (pynapple silently renamed them0, 1, ...). These checks run before pynapple is imported. DirectionalRatesResult.to_xarray()wroteattrs["bandwidth"] = Nonefor unsmoothed results (bandwidth=None), soDataset.to_netcdf()raisedTypeError. The attribute is now omitted when no smoothing was applied, as for spatial results.read_environmentestimated the bin sizes of reloaded non-grid environments (graph tracks, hexagonal, triangular mesh) from the nearest neighbour's index instead of its distance: a Y-track with 2.94 cm² bins came back with 1640.25. The estimate now uses the median nearest-neighbour spacing.- Writing a non-grid environment (graph track, hexagonal, triangular mesh) to
NWB and reading it back lost its geometry: bin sizes were re-estimated
(2.94 became 8.68 on a Y-track),
grid_edgescame backNone, and every edge's directionvectorwas reversed (81 of 81 on the Y-track). The NWB environment schema is now 1.1: it stores the exact bin sizes and graph grid edges, and writes each edge so its vector round-trips. Schema 1.0 files still read, with estimated bin sizes. - Behavior change:
DecodingResultkept a reference to the caller's posterior and cachedmap_estimateand the other derived properties, so editing that array in place (or reassigningresult.posterior) left the cached values stale (for example a MAP of bin 0 whileposterior.argmaxwas bin 1).DecodingResultis now frozen, and its constructor stores read-only copies ofposteriorandtimes. Usedataclasses.replace(result, posterior=new)for a modified result.decode_positionhands over its freshly computed posterior without a copy. - Behavior change:
to_linearreturned wrong positions on graph environments whoseedge_orderdiffers fromgraph.edges()order, such asEnvironment.maze("w", ...): on a W maze with 50 cm segments,(75, 0)on the base mapped to 175 instead of 75 and(0, 40)on the left arm to 114.03 instead of 140, and about 38% of on-track points disagreed withbin_at.track_linearizationlooks a point's nearest segment up by itsedge_idattribute but finds it by its position ingraph.edges(). Graph layouts now linearize a copy numbered ingraph.edges()order, soto_linearreturns along-track distance forfrom_graph,maze,linear_trackand reloaded environments. Anyedge_idon an input graph is ignored, and the caller's graph is not modified. - Behavior change: polar tuning plots were drawn mirrored relative to the
library's angle conventions.
plot_head_direction_tuningandplot_circular_basis_tuningdrew 0 at the top and clockwise, so a North-preferring (π/2) cell pointed East; they now draw angles as in the arena, 0 = East (right), π/2 = North (up), counter-clockwise, and reset a caller-supplied polar axis to that orientation.plot_object_vector_tuningdrew +π/2 (left of the animal) on the right; it keeps "ahead" at the top and now draws left on the left. - Behavior change:
read_position,read_head_directionandread_posereturned an NWB series' stored values and ignored itsconversionandoffset, which NWB defines as the map tounit(data * conversion + offset). A pixel series stored 0-500 withunit="meters",conversion=0.002,offset=0.1came back as 0-500 instead of 0.1-1.1 m, andenvironment_from_positionbuilt a 500 × 500 environment. The readers now return values in the series'unit(head direction converts before the degree-to-radian step), andlazy=TrueraisesValueErrorfor a series whose scaling is not the identity rather than returning unconverted handles. The NWB overlays inherit the fix.environment_from_positionmaps NWB unit names to theEnvironment.unitsregistry, so pynwb's default"meters"becomes"m"(no unrecognized-units warning), and likewisecm,mmandpx. - Behavior change:
environment_from_positionsilently setenv.units = "cm"when the position series declared no unit (unit=""). It now emits aUserWarningnaming the series and saying to passunits=, then still assumes"cm". Passingunits=skips the lookup and the warning. - Behavior change:
population_peri_event_histogramraisedAxisError: axis -1 is out of boundsfor a pynappleTsGroup, because it iterated the group (which yields unit keys, not trains). It now accepts a labelled group like the population encoders: one PSTH per unit, with the group's index asunit_ids. Aunit_ids=passed with a labelled group must equal its index, or the call raisesValueErrorlisting both. - Behavior change:
compute_vte_sessioncut each pre-decision window from the whole session, so a window reached back past the trial start into the inter-trial interval or the previous trial. A trial starting at 5.0 s that entered the decision region at 5.267 s got the window[4.267, 5.267]and a head sweep of 12.57 rad, all from movement before the trial; the trial's own straight run has 0. Windows are now clipped at the trial start, andwindow_startreports the clipped start. A trial left with fewer than 3 samples before its entry is skipped, now with aUserWarning(it was skipped silently).compute_vte_trialis unchanged. - Behavior change:
method="diffusion_kde"(the default forcompute_spatial_rate(s)andsmooth_rate_map(s)) reported huge rates in bins far from all occupancy: a simulated 50 Hz place cell in a 40 s open-field session got 20,554 Hz in a corner the animal never approached, so the map's peak (and anything using it) landed there. Far from occupancy both smoothed densities fall to the diffusion operator's numerical accuracy (1e-6 of the largest value when its eigenbasis is truncated, float64 roundoff otherwise), and the ratio was noise over noise. Bins whose smoothed occupancy is within 1000 times that accuracy of zero, relative to the largest in their connected component (typically 4-7 bandwidths from any occupancy), are now NaN, like other unsupported bins (fill_value=replaces them). The remaining rates match the exact dense kernel to within 1e-4 of the peak rate at bandwidths 5-20. - Behavior change: the simulators' default place-field width was
3 * mean(env.bin_sizes), butbin_sizesholds per-bin volumes (areas in 2-D), so a 2-D grid with 2 cm bins got 12 cm fields and one with 5 cm bins got 75 cm fields.PlaceCellModel(width=None), and through itsimulate_session,open_field_session,linear_track_sessionandtmaze_alternation_session, now default to 3 × the bin spacing (the median bin length along a track, otherwise the median distance between neighbouring bin centres, which isbin_sizeon a regular grid): 6 cm at 2 cm bins, 15 cm at 5 cm bins. A one-bin environment now raisesValueErrorasking forwidth=. Likewisevalidate_simulation's defaultmax_center_erroris 2 × the bin spacing (4 cm at 2 cm bins, previously 8 cm). - Behavior change:
method="binned"withbandwidth > 0had the same far-field problem: on a corridor visited only at x < 40 cm, with raw rates of at most 50 Hz, it reported 295,658 Hz at x = 104 cm (bandwidth 10) and 237,467 Hz at x = 190 cm (bandwidth 20). Its smoothed rate is a ratio of two diffused sums, and far from every visited bin both are noise. Those bins are now NaN under the same rule asdiffusion_kde; the remaining rates match the exact dense average to within 1e-4 of the peak rate.
Added — method="glm": penalized-Poisson GAM estimator (spatial only)¶
compute_spatial_rate and compute_spatial_rates gain a fourth estimator,
method="glm": a batched penalized-Poisson generalized additive model that uses
the cached finite-volume eigenbasis as its smoothness-penalty basis. Occupancy
enters as a log-offset (never a denominator) and the smoothness penalty λ
is chosen by REML, so the fit returns finite firing rates everywhere —
including the low-occupancy and unvisited bins where the ratio estimators
(diffusion_kde / gaussian_kde / binned) produce NaN.
- New keyword-only params on
compute_spatial_rate(s):penalty(fixedλ ≥ 0;None→ choose by REML) andrank(requested basis rank; out-of-range values are clamped, not rejected, to the effective rank reported viaresult.rank). These are mutually exclusive with the ratio-method paramsbandwidth/min_occupancy/fill_value, which now default toNone(resolving to their historical5.0/0.0/ preserve-NaNbehavior). Combining a param with the wrongmethodraisesValueError. - New result fields on
SpatialRateResult/SpatialRatesResult:coefficients,penalty,penalty_weights,rank,deviance,converged,n_iter,reml_objective(allNonefor the ratio methods).bandwidthisNonefor glm.summary_table()gains the GAM scalar columns for glm results. - Spatial only.
compute_view_rate(s),compute_egocentric_rate(s), and the directional encoders keep their ratio-onlymethodset (no"glm"). - The default
method="diffusion_kde"path is unchanged (byte-for-byte).
Added — method="glm" through NWB persistence and the decoder¶
method="glm" is now usable end-to-end (encode → persist → decode), not just as
a standalone estimator.
- NWB round-trip.
write_spatial_rates/read_place_fieldnow persist a glmSpatialRatesResultfield-for-field: the scalarspenalty/rank/n_iter/converged/reml_objectiveand the(rank,)penalty_weightsvector in the table's JSON metadata blob, anddeviance(n_units,)pluscoefficients(stored(n_units, rank), transposed back to(rank, n_units)on read) as per-unit table columns.bandwidthis stored nullable (Nonefor glm). Ratio-method results round-trip unchanged, with the GAM fieldsNone. The spatial-rates schema version bumps2.0 → 2.1(glm-diagnostics addition); ratio tables are byte-identical to2.0apart from the version string. This removes the earlierNotImplementedErrorwrite_spatial_ratesraised on a glm result. The clean-break contract still holds: pre-0.9"smoothing_method"-keyed tables do not read. - Decoder.
decode_session,decode_session_summary, andBayesianDecoderacceptmethod="glm"and forward the glm knobspenalty/rankinto the encoding step.bandwidthandmin_occupancyare now nullable on all three (defaultNone, resolving to the encoder defaults5.0/0.0for the ratio methods) so they can stay unset for glm. All three validate the method-specific params with the same validator the encoder uses, so a ratio param combined withmethod="glm"(or a glm param with a ratio method, or an out-of-domainpenalty/rank) raises the identicalValueErrorat the decoder boundary. The ratio decode path is unchanged.
Added — method="glm" optional float32 JAX acceleration¶
compute_spatial_rate(s)(method="glm", backend="jax") now routes the
penalized-Poisson fit + REML λ selection through an optional float32 JAX
mirror of the NumPy/SciPy core (installed via the jax extra; the base install
and backend="numpy" continue to run the float64 core). The float64 core remains
the correctness reference: the JAX path is a faithful mirror — same box
constraint |B·γ| ≤ η_clip, out-of-domain guard, and REML objective — cast back
to NumPy float64 at the boundary, so SpatialRatesResult diagnostics stay
float64 and the public return contract is unchanged.
- Parity. At a fixed
penaltythe float32 fit matches the float64 core to ~1e-6on the firing-rate map (relative L2 and relative error above a rate threshold). Under automatic REML it is a touch looser (float32 selects a slightly differentλnear the broad objective minimum), approximately~1e-3in measured rate/λ comparisons and still scientifically identical. - Speed. Markedly faster on populations — on a 400-bin, 30-unit, rank-60 problem the warm JAX REML fit is ~40–60× faster than the NumPy path on the CPU backend (fixed reference workload, fixed seed). The first call on an unseen shape pays JIT compilation, so a one-off fixed-penalty fit can be slower than NumPy; the win is on warm/repeated fits and grows on GPU.
- Kernel cleanup. The constant-field warm start is constructed exactly from the MRF basis's leading component intercepts and shared across both backends and every REML evaluation, eliminating the basis-wide least-squares SVD (including its repeated NumPy REML cost). The JAX Newton step uses the SPD Hessian's Cholesky factorization and triangular solves rather than generic LU.
- No new API. Dispatch is driven entirely by the existing
backend=keyword ("numpy"/"jax"/"auto"); there is no new user flag. JAX stays an optional extra (Linux/macOS).
Added — method="glm": per-neuron smoothing (pooled=False)¶
compute_spatial_rate(s)(method="glm") gain a keyword-only pooled flag
(default True, unchanged behavior). With pooled=False REML selects an
independent smoothing penalty λ_k per neuron instead of one shared λ, so
a sharp field is smoothed less and a broad one more. This is purely additive: the
pooled=True default is byte-for-byte identical to before apart from one new
scalar diagnostic (reml_at_boundary).
- Cost.
pooled=Falseruns the REML search once per informative unit (~one REML per neuron), reusing the existing batched Newton fit per distinctλ; there is no separate per-neuron fast path. - Result fields become per-unit vectors. On the automatic-REML path
(
penalty=None, a penalized basis, ≥1 spiking unit)penalty/reml_objective/reml_at_boundarywiden to(n_units,)and a newpenalty_selected_by_reml(n_units,)bool mask records provenance. A single-unitcompute_spatial_ratecall unwraps these back to scalars.summary_table()surfaces them; indexing a population result slices them. - Fixed penalty beats
pooled. Supplyingpenalty=<float>skips REML and records one scalarλregardless ofpooled.pooledis likewise a no-op atpenalty_rank == 0(a shared-basis property, soλstays a scalarNone) and when no unit spikes (the all-zero-spike degenerate path, with no pooled REML run). - Zero-spike units (whose per-unit
λis statistically unidentified) take the pooledλover the informative units as a fallback, flaggedpenalty_selected_by_reml=Falsewithreml_objective=nan(a documented sentinel); their field floors to the rate floor. - Boundary diagnostic. The new
reml_at_boundaryfield (scalar forpooled=True,(n_units,)forpooled=False,Nonewhen REML did not run) flags aλselected near a REML search bound (within5·xatol):λitself is weakly identified — its optimum may lie at or beyond the bound. The fitted field remains finite, but its sensitivity toλshould be checked. A single warning names the boundary side and the affected units (by theirunit_ids); the appliedλis the finite value returned near the bound (the interval is never expanded). - Validation.
pooledis a strictbool(rejects"true"/1/0/ arrays /None) and glm-only (pooled=Falsewith a ratio method raises;pooled=Truewith one is the harmless default). - NWB. The spatial-rates schema bumps
2.1 → 2.2:pooledand a scalarreml_at_boundaryjoin the GAM metadata blob, and the per-unitpenalty/reml_objective/reml_at_boundary/penalty_selected_by_remlvectors are persisted as table columns when present. A schema-2.1file with nopooledkey reads back method-conditionally —pooled=Truefor a glm table,pooled=Nonefor a ratio table.
Changed — BREAKING: smoothing_method renamed to method (estimator axis)¶
The smoothing/estimator keyword is now uniformly named method across every
smoothing encoder, result class, the decoder, and NWB metadata — one name, one
meaning. This is a hard rename with no alias (per project policy); callers
that named smoothing_method= or read .smoothing_method must update. Callers
that never touched it see identical behavior — the default stays
"diffusion_kde" (and "binned" for the egocentric encoders), and no numerics
change.
- Renamed keyword
smoothing_method→methodoncompute_spatial_rate,compute_spatial_rates,compute_view_rate,compute_view_rates,compute_egocentric_rate,compute_egocentric_rates,is_place_cell,compute_directional_place_fields,decode_session,decode_session_summary,BayesianDecoder, andsimulation.validate_simulation. Each encoder keeps its existing method value set and default. - Renamed field
smoothing_method→methodonSpatialRateResult,SpatialRatesResult,ViewRateResult, andViewRatesResult. - NWB:
write_spatial_ratesnow stores the estimator under the metadata key"method"(was"smoothing_method"); the spatial-rates schema version bumps to2.0. This is a clean break with no back-compatibility shim — spatial-rates tables written by earlier versions (schema1.x) no longer read. decode_session_summary: becausemethodnow names the smoothing estimator, it is no longer accepted as a per-block decode kwarg. The Poisson observation model (the only supported likelihood) is applied unconditionally.method="glm"(a penalized-Poisson GAM estimator,compute_spatial_rate(s)only) lands on this renamed axis — see the "Added —method="glm"" entry above.
Added — env.diffuse: matrix-free diffusion smoothing (0.8.0, performance)¶
Smoothing no longer materializes the dense (n_bins, n_bins) diffusion kernel on
its hot paths. A new method
applies the finite-volume heat operator H = exp(-t L) without building an
(n, n) matrix, using a cached, per-component, bandwidth-aware truncated
symmetric eigenbasis of S = M^{-1/2}(D − W) M^{-1/2}. Time and memory scale
with n_bins × rank (rank ~ measure(domain)/σ^d), not n_bins², so smoothing
now scales to large/fine grids. The eigenbasis depends only on geometry, so it is
built once and reused across every bandwidth, mode, and neuron (auto-invalidated
when the environment is mutated). fields may be 1-D (n_bins,) or a 2-D batch
(n_bins, n_fields).
env.diffuseis a pure linear operator — no output clip, no renormalization. The always-retained per-component null mode makes mass conservation exact under truncation (mode="transition"preservessum;mode="average"preserves a constant field), so smoothing a signed field is preserved. Positivity, where required, is the consumer's job.env.smoothnow routes throughenv.diffuseand gains a documented approximation contract: it reproduces the dense kernel to within a near-lossless truncation tolerance (dropped modes contribute ≤tol(default1e-6) in the M-weighted norm; the raw per-bin error carries a volume-conditioning factorκ(M) = sqrt(max vol / min vol), worst on polarr→0/ skewed mesh). On a non-negative input the linear apply may leave tolerance-level negatives (bounded relative to the dense output) instead of the dense kernel's exact 0-floor — clip the result yourself if you need a strict floor.backend="jax"runs the apply in JAX (the cached NumPy eigenbasis is cast tojnp), sojit/grad/ GPU work through the smoothing. The JAXdiffusion_kdeencoders no longer round-trip through NumPy.- The
diffusion_kde,binned, and diffuse-resample_fieldconsumers route throughenv.diffuse; strict> 0support gates (binned, resample) derive their support from the W-component structure (exact, truncation-proof), not the smoothed denominator's sign, so truncation cannot emit a spuriousNaN.diffusion_kdeclips its own output≥ 0(decode nonnegativity).
Breaking: the kernel= parameter is removed from
neurospatial.encoding._smoothing.smooth_rate_map and smooth_rate_maps_batch.
Passing a precomputed dense kernel is obsolete — the cached eigenbasis now
provides the cross-neuron reuse the parameter existed for. There is no
backward-compat shim (per the project default); callers passing kernel= should
simply drop it. env.compute_kernel is unchanged (it still returns the dense
(n, n) matrix via the expm path, byte-identical, for callers that genuinely
need the matrix), as is transitions(method="diffusion").
Changed — diffusion smoothing bandwidth is now the true physical σ (breaking, correctness)¶
The diffusion_kde / diffusion-kernel bandwidth is now the true physical
standard deviation (σ) on every environment layout, independent of bin size or
resolution. Previously the effective smoothing width scaled with bin size (2D
place fields at bin_size=1 oversmoothed by ~70% for the same bandwidth), so
place-field widths and spatial information were not comparable across
resolutions or with other tools. This is a behavior-changing correctness fix:
smoothed values change even on uniform grids — that difference is the
correction.
- New finite-volume operator. The kernel is now the finite-volume heat
operator
H = exp(-t L),t = σ²/2,L = M⁻¹(D − W)withW[i, j] = A[i, j] / d[i, j](A= shared-face measure,d= center distance) andM= per-bin cell volumes. On any K-orthogonal layout this has the continuum limit−∇², so a point source smooths to physical σ exactly. - Impact on existing results. For the same
bandwidth, 2D place fields now smooth ~1.5–1.7× less. To approximate the old amount of smoothing, scalebandwidthby roughly√(bin_size)(rough, mode-dependent — prefer re-choosingbandwidthas a physical σ in your units). - Correct orientation per consumer.
compute_kernel(mode="transition")returnsHᵀ(column-stochastic, mass-conserving smoothing of extensive quantities);mode="density"returnsH·M⁻¹(count → density, integrates to 1 under bin volumes);transitions(method="diffusion")now returns the row-stochasticHvia transpose (correct on non-uniform bin volumes — polar, mesh — where it no longer assumes symmetry). - Breaking: public
neurospatial.ops.compute_diffusion_kernelssignature. The old Gaussian-weight formcompute_diffusion_kernels(graph, bandwidth_sigma, bin_sizes=..., mode=...)is replaced bycompute_diffusion_kernels(graph, *, volumes, sigma, mode). The face measure is read from a per-edge"A"attribute (single source of truth, like"distance"); a missing"A"on an edge raises, andA == 0means no diffusion across that edge.modenow also accepts"average"(row-stochasticH) at the low level. min_occupancycaveat.min_occupancyis documented in seconds, but the KDE paths compare it tooccupancy_density = K @ occupancy, which under the new density kernel is seconds-per-cell-volume on non-uniform grids — a pre-existing unit mismatch that this change neither fixes nor worsens for uniform grids, and that lies outside the grid-invariance guarantee. Tracked as a follow-up.- Out of scope (unchanged): nonuniform-Cartesian custom
grid_edgesinherit the uniform-cell approximation and are excluded from the physical-σ guarantee; performance (denseexpm) is unchanged.
Added — mode="average" intensive-field smoother¶
- New public
mode="average"onEnvironment.compute_kernelandEnvironment.smooth(and the low-levelcompute_diffusion_kernels): the row-stochastic heat operatorHthat averages an intensive field (a rate map or probability density). Unlikedensity(H·M⁻¹), it carries no cell-volume bias on non-uniform bin volumes (polar, mesh), and unliketransitionit is the correct choice for a rate map rather than a total. Discrete probability mass (a posterior summing to 1) still usestransition.env.smooth's default staysdensity(unchanged behavior for existing mode-less calls);averageis recommended for intensive rate maps. smooth_rate_map(method="binned")andresample_field(method="diffuse")now smooth their intensive fields through the row-stochastic average (masked / valid-bin-normalized), removing the volume bias on non-uniformM. Forresample_field, this also fixes a down-bias where covered bins adjacent to an uncovered region were pulled toward zero, and makes a sourceNaNinterpolate from valid neighbours instead of propagating. Uniform-grid results are unchanged (the per-cell volume factor cancels in the rate/weight ratio).
Corrected guidance¶
method="binned"is not a dense-kernel memory mitigation. Earlier notes (through v0.6.0) recommendedbinnedto avoid the dense(n_bins, n_bins)kernel, butbinnedsmooths its rate map through the same diffusion kernel (viaenv.smooth), so all smoothing methods build a dense kernel. The only memory mitigation is reducing the bin count (a largerbin_size). Docstrings and the high-bin warnings are corrected accordingly.
UX hardening — fail-loud errors, one count convention, docs & viewer polish (0.8.0)¶
A broad usability pass: silent failures now raise or warn with actionable messages, a few APIs were made consistent, and the docs/viewers were polished.
Breaking changes¶
add_positionsis nowadd_positions(events, *, times, positions)— reordered to the canonical(data, times, positions)order and made keyword-only. A bare 1-D trajectory makes both arrays 1-D, so a positional swap was shape-indistinguishable and silently mis-interpolated; keyword-only removes the ambiguity. Migration:add_positions(events, times=times, positions=positions).- Assembly functions take
(n_time_bins, n_neurons)—detect_assemblies,assembly_activation,pairwise_correlations, andreactivation_strengthnow use the same time-first count convention asdecode_positionandbin_spikes_in_time, so a binned count matrix feeds them directly. Migration: pass the defaultbin_spikes_in_time(...)output as-is, or transpose an existing(n_neurons, n_time_bins)matrix. - Behavior result
summary()returns adictof scalar metrics (was a formatted string) on everybehaviorresult class. Migration: usestr(result)for the human-readable form. - Fail-loud instead of silent sentinels.
bin_aton a graph/track environment raises on wrong-dimension points (was all-1);heading_from_velocityraises when every sample is belowmin_speed(opt out withallow_all_nan=True); graph queries reject a multi-point coordinate batch.
Added¶
units=andframe=keyword args on the factory methods (from_samples,open_field,linear_track,maze) set the metadata at construction:Environment.from_samples(pos, bin_size=2.0, units="cm").- Graph queries accept coordinates, not just bin indices —
neighbors,path_between, andreachable_frommap a coordinate viabin_at(integers are still treated as indices). ResultMixinon every result class — concise__repr__/_repr_html_(summary metrics, not array dumps) and a scalarsummary()dict.dir(neurospatial.ops)now surfaces the lazily-exported ops (autocomplete).
Fixed¶
min_occupancythresholds raw occupancy seconds, not smoothed density — fixes silent all-zero place fields on the documented golden path; applied identically acrossdiffusion_kde/gaussian_kde/binned(single and batch).min_occupancy=0.0(the default) is unchanged.- Grid allocation is preflighted — a transposed
(2, N)trajectory (read as N dimensions) raises in ~1 ms instead of OOMing inhistogramdd/meshgrid; a likely-transposed array now warns rather than false-rejecting a valid low-sample N-D environment. - Non-finite query rows map to the
-1"outside" sentinel per row (one NaN no longer collapses the whole occupancy/rate map on graph/masked envs). to_filerefuses to overwrite by default (overwrite=Trueto allow);occupancy()checks shape before monotonicity (clear swapped-argument error);from_graphvalidates the edgedistanceattribute.SpatialRateResult.plot()documents the realcolorbar/colorbar_labelkwargs and labels the colorbar "Firing Rate (Hz)"; batchplot()raises an actionable error when no unit index is given.- Interactive-viewer accessibility — the standalone HTML player ignores
global keyboard shortcuts when an interactive control has focus, adds
:focus-visiblestyles, and stops announcing every autoplay frame to screen readers; the napari region dock and track builder gain a compact layout, visible labels, and accessible controls.
Documentation¶
- Docs version bumped to 0.8.0; dead links and the fork-clone step fixed; a
value-first, runnable quickstart; grouped/collapsible nav; a new advanced
architecture page; colorblind-safe
viridisin examples; example-notebook links normalized with an internal link-check step in docs CI.
0.6.0 - 2026-07-03¶
Commit highlights¶
Features¶
- feat(io/nwb): SpatialRatesResult round-trip (write_spatial_rates/read_place_field) + lazy reads (92cb14a)
- feat(decoding): add immutable BayesianDecoder (fit/predict/predict_summary/score) over the decode core (5ec00da)
- feat(recording): add frozen Session bundle + load_session (from_arrays/from_nwb, with_environment/restrict) (5edf393)
- feat(behavior): add restrict/in_epochs/restrict_spike_trains (array or IntervalSet epochs) (9b940fa)
- feat(encoding): add SpikeTrains ragged-spike container (label access, filter, unit_table) (1eba7ca)
- feat(typing): add PositionLike/SpikeTrainsLike/EnvironmentLike Protocols + pynapple ingress (optional) (b35330c)
- feat(decoding): honor float32 end-to-end through decode_session(_summary) via a dtype knob (fd58811)
- feat(encoding): speed filtering masks both spikes and occupancy in encode path (c514061)
- feat(decoding): add memory-safe summary decode + dtype/time_chunk on decode_position (afc1d68)
- feat: Update documentation and code to reflect 'unit' terminology for v0.6 API consistency (a5520ab)
- feat: Add ux-reviewer agent for evaluating user experience in scientific software (b2d3cdb)
- feat: Update API for detect_region_crossings and enhance documentation (0c0488e)
- feat(environment): experiment-shaped factory presets (open_field, linear_track, maze) (928a921)
- feat(encoding,behavior,decoding): enforce naming contract (classify/label_cell_types/is_place_cell/peak_location; decode_position result arg; deprecation aliases) (e3c9c94)
- feat(results)!: split terminal verbs — dense to_dataframe + new summary_table; PSTH ResultMixin (breaking, D1) (ccc72a6)
- feat(results)!: to_xarray returns a labeled xr.Dataset (breaking, D1) (14fe4f3)
- feat(encoding,events): thread unit_ids through population results and compute functions (c3e29e9)
- feat(decoding): add decode_session() one-call encode->bin->decode golden path (b1fabc4)
Bug Fixes¶
- Merge pull request #25 from edeno/phase-3-pynapple-validation (6d8f7e2)
- fix(io/pynapple): validate shapes in to_pynapple (ValueError, not raw AssertionError) (241ed10)
- Merge pull request #24 from edeno/phase-3-trajectory-extra (3d635b1)
- fix(decoding): drop nonexistent neurospatial[trajectory] hints (scikit-image is core) (51e1ac5)
- Merge pull request #23 from edeno/phase-3-nwb-extra-msgs (8f4ac4d)
- fix(io/nwb): ndx-pose/ndx-events ImportError hints name the real [nwb] extra (14a8d93)
- Merge pull request #22 from edeno/phase-3-bugfixes (e5c11d9)
- fix(interop): restrict speed under fit(epoch=); mixed-type unit_ids; correct nwb extra name (56d0b51)
- fix(io/nwb): persist+validate env on rates round-trip (no degenerate env); atomic write; occupancy integrity; lazy length checks + unit caching; add nwb CI job (fefe6c3)
- fix(decoding): score warns/raises on undecodable bins; validate BayesianDecoder config+fitted state; add is_fitted, warn_on_drop, score distance=; error provenance (ff606f3)
- fix(recording): raise on malformed NWB env (not silent None); add environment_name; warn on empty restrict; Position self-validates (822ebc4)
- fix(behavior): raise on ambiguous nested-list epochs; validate closed on empty epochs; doc NaN exclusion (a3d6ee2)
- fix(typing): extract TsGroup trains by unit-id index (not iterate-keys); narrow EnvironmentLike; fix pynapple tests (9fd8d86)
- fix(decoding): route log_poisson_likelihood/poisson_likelihood dt through shared validate_dt (9b939a5)
- fix(decoding): validate dt in decode_position_summary too; test decode_position(_summary) dt guards; unify dt message; fix examples README sync wording (917ad4b)
- fix(decoding): unify dt validation (reject str/bool) via shared helper; clean ValueError for unparseable dtype (726ed71)
- fix(decoding): clean error for non-numeric/bool dt; restrict sync prune to numbered examples (23501df)
- fix(decoding): validate dt (finite, >0) in decode_session(_summary) before grid math (2766022)
- fix(decoding): preserve float32 in result-object handoff; validate time_chunk as positive int (not bool/float/str) (20b0e63)
- fix(decoding): reject time_chunk=None in summary decoders (it defeats the never-materialize-full-posterior contract) (eeb3d80)
- fix(encoding): warn when interval filtering empties the rate map (max_gap/out-of-bounds, not just min_speed) (7536d53)
- fix(decoding): validate prior shape in decode_position_summary (was silently truncating over-long 2D priors) (8b18f59)
- fix(encoding): align spike counts with occupancy on the FULL interval mask (max_gap, out-of-bounds, speed) (87b2164)
- fix(encoding): raise on speed-without-min_speed; warn on all-excluded speed filter; tighten final-sample gate (d13c968)
- fix(decoding): robust scaling test, precise float32 summary reductions, quiet degenerate-row warning (0847aa7)
- fix: address Phase 1 PR review (linear_track NaN guard, unit_table validation, all-NaN peaks, decode_position error, maze kind, honest annotations, docs) (92067e7)
- fix(encoding,docs): add peak_locations() to batch results; correct CLAUDE.md peak/cell-type contract; dedup CHANGELOG (aee29bf)
- fix(environment): order-based (coordinate-independent) maze topology; no track_graph mutation (9d9cc69)
- fix(results): raise (not silently skip) on xarray coord shape mismatch; omit None units (eeac275)
- fix(decoding): validate times up front in decode_session (beginner-grade non-finite error) (ec427eb)
- fix(decoding): warn on out-of-window spike drop in decode_session (units footgun) (09b1dea)
- fix: address Phase 0 PR review findings (E1006 code, docs accuracy, test gaps) (3f779e7)
- fix(decoding): keep decode_session export in neurospatial.decoding only (e6b2030)
- fix(environment): distance_to raises RegionNotFoundError for missing region (KeyError-compatible) (85410c0)
- fix(errors): remove internal-doc (CLAUDE.md) references from user-facing errors (63c8062)
- fix(environment): correct factory examples + error code in bare-Environment() message (3eaa207)
- fix(environment): beginner-grade error for bare Environment() pointing to factories (80d6ae7)
- fix(events): reject Inf event_times in align_spikes_to_events (matches docstring) (a364db1)
- fix(behavior): normalize array-likes at trajectory public boundaries before .ndim (2d4b5f8)
- fix(encoding): address review findings on spike-drop warning (4b3dfa5)
- fix(encoding): warn when spikes are dropped outside trajectory window or inactive bins (0d04d01)
Documentation¶
- docs(interop): add Interoperability guide (session-first-optional: pynapple/NWB/Session/SpikeTrains/BayesianDecoder) (7848436)
- docs(plan): add Phase 3 interop/ergonomics design RFC (locks Protocols, Session, BayesianDecoder) (a0dc512)
- docs(decoding): widen likelihood Raises docstrings to match new dt validation (65b0e2c)
- docs(changelog): sync hosted changelog with latest v0.6 fixes (dt/dtype validation, float32 result handoff, time_chunk) (bdf2afd)
- docs(animation): fix broken per-frame memmap rate-map example; clarify jupytext vs docs-mirror sync; soften index parity claim (06f5e91)
- docs(examples): add missing example-23 row to index; correct CI sync guidance; prune stale mirrors in sync script (ebf0397)
- docs: surface decode_session_summary/scale APIs (api index, workflow long-session callout, batch processing, snippet CI) (15dd728)
- docs(changelog): sync hosted changelog with v0.6 scale API (dtype end-to-end, time_chunk hybrid + None rejection, summary speed forwarding) (e3eb03c)
- docs(encoding): correct compute_spatial_rates dtype note for R8 (decode_session now honors float32 end-to-end); test blockwise prior validation (cc7c498)
- docs: teach compute_spatial_rates batch path in README/animation/overlays/metrics (not per-neuron loops) (fe83cc4)
- docs(changelog): smoothing kernels warn and proceed (no hard gate / allow_large) (4a099e2)
- docs(examples): re-sync stale bayesian-decoding mirror from source; add CI sync guard (961ea4c)
- docs(decoding): teach batch/decode_session path not the per-neuron loop; add Phase 2 entries to docs changelog (241a8d5)
- docs/fix(decoding): correct time_chunk memory claim; DecodingSummary map_estimate + post-init validation; test gaps (59b3a56)
- docs(encoding): clarify float32 dtype decode-memory scope; fix batch return annotations (4dbfbc9)
- docs(encoding): teach compute_spatial_rates batch path, not the per-neuron loop (952b861)
- docs(encoding): note joblib warning-swallowing in population_coverage; coerce label_cell_types score dtype (27be8cf)
- docs: v0.6 naming contract in CLAUDE.md + migration guide + breaking-change CHANGELOG (1.6, 1.7) (c91184a)
- docs(encoding): note is_place_cell (field-based) vs classify (info-threshold) divergence (review nit) (d725d9f)
- docs,fix(encoding,events): document unit_ids/unit_table/unit_id; guard ndim; honest unit_id scalar (2a4d1ac)
- docs: sync Phase 0 plan §0.7 to shipped decode_session; archive re-review (d5cb4eb)
- docs(reviews): archive Phase 0 independent review with resolution note (d296b58)
- docs,ci: bump installation.md to v0.5.0; execute nb-20 in notebook CI (0abba24)
- docs: align decode_session example with manual path + execute notebook (review fixes) (b0b1e55)
- docs: teach decode_session as the one-call decode golden path (720547f)
- docs: fix degenerate Workflow 1 example + review nits (Phase 0.6) (4bb7636)
- docs: teach one canonical beginner path; fix broken API snippets (Phase 0.6) (da3ba4c)
- fix(errors): remove internal-doc (CLAUDE.md) references from user-facing errors (63c8062)
- docs(plan): apply independent + maintainer review findings to v0.6 plan (8cf9c92)
- docs(plan): add v0.6 UX implementation plan + review synthesis (9ccf0f7)
- docs: update CHANGELOG.md for v0.5.0 (9552a27)
Other Changes¶
- Merge pull request #27 from edeno/chore/release-0.6.0 (ce62eff)
- chore(release): 0.6.0 — bump version, cut CHANGELOG 0.6.0, sync docs; fix stale doc refs (aa3e80f)
- chore(reviews): strip trailing blank line at EOF (git diff --check hygiene) (b393fd3)
- Merge pull request #21 from edeno/phase-3-simplify (19beb30)
- refactor(interop): dedup pynapple adapters, collapse Session spike coercion, avoid double spike/interval normalization (35af797)
- refactor(encoding): store SpikeTrains.trains as an immutable tuple; fix lazy-export comment (229135e)
- test(io): cover to_pynapple TypeError guard for non-result + values=None (3d27e81)
- chore(deps): lock pynapple optional extra (quantities, tabulate) (dd06e7e)
- test(decoding): structural throughput guard for summary decoders (per-block decode count = ceil(n_time/time_chunk), independent of n_neurons) (b0da2c1)
- test(encoding): enforce batch throughput contract — occupancy/kernel computed once, not per-neuron (deterministic call-count) (2aa7c21)
- test(decoding): cover decode_position time_chunk<1 guard; route summary prior-shape check through shared helper (f0d40d7)
- refactor(smoothing): warn (don't hard-gate) on high-bin dense kernels; drop allow_large/_KERNEL_HARD_LIMIT_BINS (a3e74eb)
- refactor(decoding): make decode_session_summary block-alignment check unconditional (survive -O) (08568b9)
- test(encoding): guard population_coverage docstring arg order (55c6bee)
- refactor(environment): sync compute_kernel protocol signature with allow_large (63c6d09)
- refactor: Remove type ignore comments for clarity in various encoding functions (fe31432)
- refactor: Rename methods and update docstrings for clarity in encoding tests (64d30ab)
- feat(encoding,behavior,decoding): enforce naming contract (classify/label_cell_types/is_place_cell/peak_location; decode_position result arg; deprecation aliases) (e3c9c94)
- test(encoding): rename stale to_dataframe/neuron_id test names to summary_table/unit_id (review nit) (7e882c0)
- refactor(decoding): let encoder own the drop warning in decode_session None branch (891a5be)
- refactor(encoding): rename normalize_spike_times -> as_spike_trains (f4cf99a)
- refactor(decoding): polish decode_session per review (times guard, ArrayLike, tests) (9bd454d)
- test: let CLAUDE.md backstop raise on unreadable file (review nit) (5ae370c)
- fix(environment): beginner-grade error for bare Environment() pointing to factories (80d6ae7)
- test(events): strengthen finite-event_times assertion (review nit) (8cad979)
- test(behavior): add list-input normalization tests for traveled_path_length (b81d78b)
- chore(worktrees): ignore .claude/worktrees/ for per-task subagent worktrees (994e796)
Full Changelog: https://github.com/edeno/neurospatial/compare/v0.5.0...v0.6.0
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. While the project is pre-1.0, minor releases may still include breaking changes; these are called out under a dedicated Breaking changes heading.
Changed¶
- The internal
_build_encoding_model(shared bydecode_session/decode_session_summary/BayesianDecoder.fit) gained an optionalcontextparameter that names the caller in its up-front timestamp-validation error.BayesianDecoder.fitpassescontext="BayesianDecoder.fit", so anepochthat selects too-few training samples now raises"At least 2 samples required for BayesianDecoder.fit, got N"instead of the misleadingdecode_sessionprovenance. The default preserves every existing message. decode_session/decode_session_summarynow acceptpositions=Nonewhenencoding_modelsis supplied (the passthrough decode never uses a position track). Previously this raised"as_times_positions received a timestamp array but no positions"; now the position track is normalized only when actually needed (the encode step, or aPositionLiketimes). Every existing caller that passespositionsis byte-for-byte unchanged. This enables the fitted-model decode path used byBayesianDecoder.predict/predict_summary.
Fixed¶
- The public likelihood functions
log_poisson_likelihoodandpoisson_likelihoodnow validatedtvia the sharedvalidate_dthelper, consistent with the decode entry points. A non-numericdt(including a numeric string like"0.1"), abool(dt=True), and a non-finitedt(nan/inf) now raise a clearValueErrorinstead of the weakerdt <= 0guard (which leaked a rawTypeErroron strings, silently accepteddt=Trueas1, and letnan/infslip through). The error message changed from"dt must be positive"to `"dt must be a finite number0"`.
bin_spikes_in_timenow validatesdtconsistently withdecode_session/decode_session_summaryvia a sharedvalidate_dthelper: a non-numericdt(including a numeric string like"0.1") and abool(dt=True) now raise a clearValueError("dt must be a finite number > 0, ..."). Previously a numeric string leaked a rawTypeErrorfrom"0.1" <= 0anddt=Truewas silently accepted and used as a chunk size of1.decode_positionroutes through the same helper, so an invaliddtthere also raises the clean message instead of a cryptic downstream error.- An unparseable
dtype(e.g.dtype="bogus") now raises a clearValueErrornamingdtypeacross the decode/encode entry points (decode_position,decode_position_summary,compute_spatial_rates,decode_session,decode_session_summary) instead of a raw NumPyTypeError: data type 'bogus' not understood. decode_sessionanddecode_session_summarynow validatedt(finite,> 0) up front with a clearValueError, matchingbin_spikes_in_time's wording ("dt must be finite and > 0, got ..."). Previously the shared_build_encoding_modelbuilt the decode time grid directly, bypassingbin_spikes_in_time's guard, so an invaliddtleaked a cryptic error:dt=0→ZeroDivisionError,dt=NaN→ "cannot convert float NaN to integer",dt<0→ a misleading "span smaller than one bin" message, anddt=inf→ a similar cryptic failure.decode_positionnow preservesfloat32when handed a rate-result object (anything exposing.firing_rates). Previously the friendly object path promoted afloat32.firing_ratestofloat64, silently losing part of thedtype=np.float32memory win that the raw-array path already delivered. The object path now matches the raw-array path byte-for-byte:float32staysfloat32,float64staysfloat64, an integer rate map is promoted tofloat64, and aNone/ dict / non-2-D.firing_ratesstill raises the same clearValueError.time_chunkis now validated as a positive integer (notbool) acrossnormalize_to_posterior,decode_position,decode_position_summary, anddecode_session_summary, via a shared validator that raises a clearValueErrornaming the value and its type. Previously a float (1.5) or string ("2") leaked a rawTypeErrorfromrange(...)/comparison, andTruewas silently accepted as a chunk size of1.- The summary decoders
decode_position_summaryanddecode_session_summarynow rejecttime_chunk=None(raising a clearValueError). Previously aNonevalue set the streaming block to the full session length, materializing the full(n_time, n_bins)posterior transiently and defeating the memory-safe "never materialize the full posterior" contract these functions promise.time_chunkmust be a positive integer (default1024); usedecode_position/decode_sessionif you want the full posterior.
Added¶
- NWB
SpatialRatesResultround-trip + lazy reads (neurospatial.io.nwb).write_spatial_rates(nwbfile, result, *, name="spatial_rates", overwrite=False)stores a population result on a unit axis — aDynamicTable(one row per unit) inanalysis/with aunit_idcolumn, a 2-Dfiring_ratecolumn of shape(n_units, n_bins), and one column perunit_tablefield; occupancy is stored once as a companionTimeSeries,smoothing_method/bandwidth/ bin counts ride in the table description, and bin centers are shared via the existingbin_centersdataset. The fullEnvironmentis now persisted alongside the rates (viawrite_environmentunder the derived namef"{name}_environment"), so it round-trips with its connectivity edges and geometry intact. The write is atomic: all name collisions (the table,f"{name}_occupancy",f"{name}_environment") and shape validation are resolved before the first object is added, so a duplicate withoutoverwriteraises before any mutation andoverwrite=Truecleans every companion; a later add failure rolls back the partial write.firing_rates/occupancyare defensively copied so the NWB containers never alias the live result, and aunit_tablecolumn namedunit_id/firing_rate(reserved) raises a clearValueError.read_place_field(nwbfile, *, name="spatial_rates", env=None)is the inverse, reconstructing aSpatialRatesResultwithfiring_rates/occupancy/unit_ids/unit_table/smoothing_method/bandwidthall preserved (unit_idsandunit_tablelinks survive non-default ids and non-trivial tables). Whenenv=is omitted it restores the persisted environment with full connectivity (read_place_field(nwb).env.neighbors(i)matches the original; there is no connectivity-less bin-centers fabrication), and it raises if neither anenv=nor a persisted env is present. A mismatched or staleenv=(itsn_binsdisagreeing with the stored rates) now raises a clearValueErrorinstead of silently attaching, the companion occupancy length is validated at read against the table's recordedn_bins, and anamepointing at a non-spatial-rates table raises a clearValueError. Shape mismatches raise param-namedValueErrors and duplicate names honoroverwrite.read_position/read_pose/read_unitsgain a keyword-onlylazy=False:lazy=Truereturns h5py-backed handles (per-unit spike-time handles forread_units) that materialize only when sliced /np.asarray-ed — valid only while the backingNWBFileis open — while the default eager path stays byte-for-byte unchanged. The lazyread_position/read_posepaths now validate lengths via the handles'.shape[0](no materialization), so a positions/timestamps or per-bodypart mismatch raises exactly as the eager path (lazy pose no longer silently misaligns bodyparts of differing length), and the lazy per-unit spike-time handle caches its materialized array (the ragged slice is read and sorted at most once). Atest_nwb.ymlCI job installs thenwbextra and runs the NWB tests (tests/nwb+tests/test_recording.py), which the defaultdev-only job skips.neurospatialstill never imports pynwb (loaded lazily inio/nwb). BayesianDecoder— an immutable (frozen)fit/predict/predict_summary/scorewrapper over the decode core (neurospatial.decoding, also exported at the top level).fit(spike_times, times, positions, *, speed=None, min_speed=None, epoch=None)builds encoding models by reusingdecode_session's internal encoder and returns a new fitted decoder (never mutates the original); an optionalepochrestricts the training data first (viabehavior.restrict/restrict_spike_trains) for train/test splits.predict/predict_summarydelegate todecode_session/decode_session_summarywith the fitted models, so a fitted decoder's posterior is byte-exact withdecode_sessionon the same inputs;scorereports decode error ("median_error"/"mean_error", lower is better) viaDecodingResult.error_against.scoregained adistance=option ("euclidean"default or"geodesic", forwarded toerror_against) so scoring can use the environment's graph distance, not only straight-line error, and now does not silently drop undecodable bins: it warns (naming the excluded fraction) when any decode time bin is undecodable (all-non-finite posterior row, whichnanmedian/nanmeanwould otherwise ignore) and raises a clearValueError— instead of returningnan— when no bin is decodable (naming the likely degenerate-encoding-model or seconds-vs-milliseconds unit-mismatch cause); the all-decodable path stays warning-free. Invalidmetric/distanceare validated before decoding so a typo does not cost a full decode. Construction now validates config and fitted state:dt(via the sharedvalidate_dt) anddtypeare checked at build time, and a directly-injected fitted decoder (BayesianDecoder(env, encoding_models=..., unit_ids=...)) is checked for fitted-state coupling (unit_idspresent, 2-D models, and a bin/unit-count match againstenv) instead of detonating later inside the core. A new read-onlyis_fittedproperty lets callers branch without catchingRuntimeError, and awarn_on_dropconfig field (defaultTrue) threads to both thefitencode step and thepredict/predict_summarydecode steps as a single knob to silence the spikes-out-of-window warnings. AcceptsSpikeTrainsLike/PositionLikeinputs, and decodes through theEnvironment, so geodesic / linearized-track / graph-based decoding works (unlike pynappledecode_1d/decode_2d). Unfittedpredict/predict_summary/scoreraise a clearRuntimeError.Session— a frozen discoverability bundle (neurospatial.recording, also exported at the top level) groupingenv/position/spikes/epochs/metadata. It is not a god-object: it exposes the raw arrays (session.times/session.positions/session.spikes) but carries no heavy analysis methods — compute stays functional, e.g.compute_spatial_rates(session.env, session.spikes, session.times, session.positions).positionuniformly exposes.t/.values(arrays are wrapped in a small internalPositionholder; a pynappleTsd/TsdFramealready conforms). Constructors:Session.from_arrays(*, env=None, times, positions, spike_times, unit_ids=None, unit_table=None, epochs=None, metadata=None)(accepts arrays or aPositionLike, and a list / 2-D array /SpikeTrains/ pynappleTsGroupfor spikes, threadingunit_ids/unit_table) andSession.from_nwb(path_or_file, *, environment_name=None, unit_ids=None, **read_kwargs). Frozen / immutable:with_environment(env)andrestrict(epochs)return a newSessionand never mutate the original.restrictslices the position and the spikes to the epochs and is identity-preserving — restriction trims spikes per unit (never drops units), so it rebuilds aSpikeTrainscarrying the originalunit_ids/unit_tableunchanged, and records the epochs on the new session. Arestrictthat keeps zero position samples (epochs that miss the session entirely — often a seconds-vs-milliseconds unit mismatch) emits aUserWarningnaming the likely cause and returns the (empty) session. A mismatchedposition.t/position.valueslength (also self-enforced by the internalPositionholder, which additionally requires a 1-Dt), apositionthat is notPositionLike(missing.t/.values), or a non-EnvironmentLikeenvraises a clearValueError.load_session(source, **kwargs)— dispatches an NWB file path (str/os.PathLike) or an open pynwbNWBFiletoSession.from_nwb; any othersourceraises a clearTypeErrordirecting you toSession.from_arrays.Session.from_nwbbuilds the bundle via the existing lazyneurospatial.io.nwbreaders (read_units/read_position/read_environment), soneurospatial.recordingnever imports pynwb / pynapple andimport neurospatialstays cheap. The environment is read by presence (membership innwbfile.scratch), not by catching an error: a genuinely-absent environment maps toenv=None, while an environment that is present but unreadable (malformed / wrong schema) now raises instead of being silently swallowed toNone. The newenvironment_name=selector reads the standardspatial_environmentscratch entry by default and can select an environment written under a custom name. (The lazily-materializedlazy=TrueNWB read path is intentionally deferred to a later phase.)restrict/in_epochs/restrict_spike_trains— array-native epoch selection ("give me my running periods / trial N") in a newneurospatial.behavior.epochs.epochsaccepts(start, end)scalars, two(starts, ends)1-D arrays, an(n, 2)array, or a pynappleIntervalSet— theIntervalSetis duck-typed (.start/.end), so this stays array-first and never imports pynapple. The one genuinely ambiguous form — a length-2 pair of length-2 sequences ([[0, 5], [10, 15]]), which could mean two(start, end)interval rows or two parallel(starts, ends)arrays — raisesValueError; pass an(n, 2)NumPy array (interval rows) or explicit 1-Dstart/endarrays to disambiguate. Endpoints are inclusive by default (closed="both", matchingbehavior.segmentationand pynapple; also"left"/"right"/"neither").restrict(times, *arrays, epochs=...)slicestimesand any arrays aligned totimes(e.g.positions) by the same in-epoch mask, order preserved (t, pos = restrict(times, positions, epochs=run_epochs)); with no extra arrays it restricts an event-time array by its own timestamps (restrict(spike_train, epochs=...)).restrict_spike_trains(trains, epochs)restricts ragged per-unit spikes, each train by its own timestamps, and accepts a plain sequence or aSpikeTrains.restrictis also exported at the top level (neurospatial.restrict).SpikeTrains— a frozen ragged-spike-train container (label accessst[unit_id],filter("region == 'CA1'"), optional per-unitunit_table), the one justified new container (ragged per-unit spike times genuinely don't fit a rectangular array). Exported from bothneurospatial(top level) andneurospatial.encoding. It duck-types asSpikeTrainsLike— it is not aMapping, exposes a non-callable.indexproperty (the unit ids), and its__iter__yields the per-unit train arrays — so it flows through the Phase 3.1 spike-input adapter's iterate branch and intocompute_spatial_rates(and the other batch encoders/decoders) withunit_idspreserved. Label access (st[unit_id]) and the adapter's positional iteration coexist because they use different dunders (__getitem__vs__iter__/.index). Frozen and immutable:trainsis stored as atupleso in-place mutation raises,filterreturns a new container and never mutates the original; duplicateunit_idsraiseValueError.- Input Protocol surface + pynapple ingress/egress (optional). A new
neurospatial/_typing.pydefines the structural Protocols that let third-party objects flow into the array-first scientific core without the core ever importing orisinstance-checking them: PositionLike— a.t/.valuestime-series (pynappleTsd/TsdFrameconform).compute_spatial_rate/compute_spatial_ratesanddecode_session/decode_session_summarynow accept aPositionLikein thetimesslot (withpositionsomitted) and normalize it to plainfloat64arrays at the boundary viaas_times_positions. The plain-array path is unchanged byte-for-byte.SpikeTrainsLike— the accepted spike-input union, plus a real pynappleTsGroup. ATsGroupis acollections.UserDict, so iterating it yields the unit-id keys, not the per-unit trains;SpikeTrainsLikeis therefore the indexable-by-id surface (.indexof unit ids +group[uid]returning a per-unitTswith.t). A newencoding.as_spike_trains_with_idsextracts trains by indexing each id (never by iterating), so a rawTsGroupflows correctly intocompute_spatial_rates/decode_session; it surfaces the ids without changingas_spike_trains'slist[NDArray]contract, and when a group carries ids and the caller passes nounit_ids=, they now flow intoSpatialRatesResult.unit_idsinstead of being silently dropped.EnvironmentLike— a public re-export ofEnvironmentProtocol. The internalisinstance(env, Environment)check inCompositeEnvironmentis replaced by a duck-typedis_environment_likecheck, fixing the surprise that the siblingEgocentricPolarEnvironment(not anEnvironmentsubclass) was rejected even though it is a legitimate environment.- pynapple I/O shim behind a new optional
pynappleextra:neurospatial.io.from_pynapple(TsGroup→(trains, unit_ids);Tsd/TsdFrame→(times, positions);IntervalSet→(start, end)) andneurospatial.io.to_pynapple(a decoded MAP track /times+values→Tsd/TsdFrame).import pynappleis lazy inside these functions, so the package and the array path import and run with pynapple absent; calling them without it raises a clearImportErrornamingneurospatial[pynapple]. The scientific modules never import pynapple. - Speed filtering on the encode path.
compute_spatial_rateandcompute_spatial_ratesgain keyword-onlyspeed/min_speedparameters (also forwarded bydecode_session/decode_session_summary). Whenmin_speedis set, low-speed periods are excluded using one shared per-interval speed gate applied to both the spike numerator and the occupancy denominator, so amin_speedknob can never silently bias firing rates by filtering only one side. The spike gate matches the occupancy gate exactly: occupancy keeps intervalkiffspeed[k] >= min_speed, and a spike at timetis kept iffspeed[searchsorted(times, t, "right") - 1] >= min_speed— the same per-interval criterion, so identical intervals drop on both sides. Whenspeedis omitted it is auto-derived with a forward difference (speed[k] = ||positions[k+1] - positions[k]|| / (times[k+1] - times[k]), withspeed[n-1] = speed[n-2]) to matchenv.occupancy'stime_allocation="start"interval semantics; pass an explicitspeedfor geodesic / linearized-track environments.min_speed=None(the default) applies no filtering and leaves output byte-for-byte unchanged. - Passing
speedwithoutmin_speednow raisesValueError(instead of silently ignoringspeed), mirroringenv.occupancy, which raises onmin_speedwithoutspeed. - When the interval filter excludes all trajectory intervals — whether
via
min_speed,max_gap, or the out-of-bounds-start rule (e.g. a wrong-units threshold) —compute_spatial_rate/compute_spatial_ratesnow emit oneUserWarningnaming the active gate(s) and suggesting fixes (check units; passmax_gap=None), instead of silently returning an empty rate map. The warning fires once per call (not per neuron in the batch path) and is suppressed bywarn_on_drop=False. decode_sessionanddecode_session_summarygain a keyword-onlydtypeparameter (np.float32/np.float64, defaultnp.float64). "Decode in this dtype": a singledecode_session(dtype=np.float32)controls both the encoding-model working set and the posterior dtype, end-to-end — the functions no longer force-promote encoding models back tofloat64. Ondecode_session_summaryit is now an explicit parameter rather than adecode_kwargsentry. Defaultnp.float64leaves every existing caller byte-for-byte unchanged; any other dtype raisesValueError.
Performance¶
decode_session(dtype=np.float32)/decode_session_summary(dtype=np.float32)now actually halve the decode working set on the beginner golden path: thefloat32rate-map dtype is honored end-to-end (encoding-model working set + posterior) instead of being silently promoted back tofloat64inside the session helpers. Values matchfloat64withinfloat32tolerance (the rate computation is done infloat64and only the result is cast, percompute_spatial_rates).
Breaking changes¶
to_xarray()now returns a labeledxarray.Datasetinstead of anxarray.DataArraywith integer coordinates. This is a clean break: there is noDataArrayshim and noto_dataset()alias. Two distinct shapes are produced:- Population rate results (
SpatialRatesResult,DirectionalRatesResult,ViewRatesResult,EgocentricRatesResult) return aDatasetwith dims("unit_id", "bin").unit_idis the index coordinate holding the real per-unit identity labels (result.unit_ids), so units are selected by label. Thebindimension carries non-indexbin_center_x/bin_center_y(/bin_center_z) coordinates for Cartesian environments, orbin_center_distance/bin_center_anglefor the polar egocentric result. The rate matrix is thefiring_ratedata var;occupancyis a("bin",)data var.attrscarryunits,bandwidth(where applicable), anenvfingerprint, andsoftware_version. Duplicateunit_idsnow raiseValueError(label-based selection requires uniqueness). -
Decode results (
DecodingResult) return aDatasetwith dims("time", "bin")(a posterior over space per time bin; nounit_idaxis). Theposteriordata var holds the posterior, with the samebin_center_*coordinate logic andunits/env/software_versionattrs.Before → after:
-
The two terminal verbs now mean one thing on every result class.
to_dataframe()on the batch (plural) encoding results —SpatialRatesResult,DirectionalRatesResult,ViewRatesResult,EgocentricRatesResult— is now dense tidy: one row per(unit, bin)(single-unit results: one row perbin), always carrying aunit_idcolumn plus the bin-center coordinate columns (bin_center_x/y/zfor Cartesian,bin_center_distance/anglefor polar egocentric,bin_center_anglefor directional),firing_rate, andoccupancy. The per-unit summary thatto_dataframe()used to return (one row per neuron withpeak_x,peak_rate,spatial_info,sparsity,grid_score,border_score,cell_type, etc.) has moved to the newsummary_table(), which isunit_id-indexed. This is a clean break: there is no mode flag and no transition shim. Theneuron_ids=keyword on the old per-unitto_dataframe()is replaced byunit_ids=onsummary_table()(defaulting to the result's ownunit_ids).Before → after:
# before — to_dataframe() returned one row per neuron with metric columns df = result.to_dataframe() # columns: neuron_id, peak_x, ... place = df[df["cell_type"] == "place"] # after — summary_table() is the per-unit summary; to_dataframe() is dense summary = result.summary_table() # one row per unit, unit_id-indexed place = summary[summary["cell_type"] == "place"] dense = result.to_dataframe() # one row per (unit, bin), carries unit_id
Added¶
-
Memory-safe summary decoding for long sessions.
decode_position_summaryis a new sibling ofdecode_positionthat streams over time, computing the posterior one time-block at a time and reducing each block to per-time scalars/vectors (map_position/map_bin,mean_position,posterior_entropy,peak_prob) without ever materializing the full(n_time, n_bins)posterior. It returns a newDecodingSummaryfrozen dataclass (alongsideDecodingResult) carryingResultMixinand the standard terminal verbs —to_dataframe()(one row per time bin),summary(),plot(), andto_xarray()(a trackDatasetwith atimedim and nobinposterior axis) — sharing accessor names and column conventions withDecodingResultso user code ports between the two.decode_session_summaryis the matching one-call encode→bin→decode wrapper (sibling ofdecode_session). The summary reductions are bit-for-bit identical to reducing the full posterior formap_position/map_bin, and match to floating-point tolerance formean_position/posterior_entropy/peak_prob. -
Experiment-shaped factory presets on
Environmentthat speak experiment vocabulary and delegate to the existingfrom_*factories: Environment.open_field(positions, bin_size, ...)— the only positions-based preset; delegates tofrom_sampleswithfill_holes=Trueflipped on (a sensible open-arena default that fills interior gaps).Environment.linear_track(*, endpoints=..., node_positions=..., bin_size)— builds a 1D track graph (is_linearized_track == True) from an explicit topology (two endpoints for a straight track, or waypoints for a piecewise-linear track) and delegates tofrom_graph.Environment.maze(kind, *, track_graph=..., node_positions=..., bin_size)— assembles the standard W / plus / T track-graph topology (or accepts a readynetworkxgraph) and delegates tofrom_graph.
Track/maze presets require an explicit topology spec; raw positions cannot
infer a linear/W/plus/T graph, and calling them without a topology raises a
clear ValueError.
-
to_xarray()onDirectionalRatesResult,ViewRatesResult, andEgocentricRatesResult(the directional/view/egocentric population results previously had no xarray export). Each returns the labeledxr.Datasetdescribed under Breaking changes above. -
Durable unit identity on encoding/events results. Every population result (
SpatialRatesResult,DirectionalRatesResult,ViewRatesResult,EgocentricRatesResult,PopulationPeriEventResult) now carries aunit_ids: np.ndarrayfield (plus an optionalunit_table: pd.DataFrame | None), and every single-unit result (SpatialRateResult,DirectionalRateResult,ViewRateResult,EgocentricRateResult,PeriEventResult) carries a singularunit_id. Indexing or iterating a population result stamps the per-unit label onto the child (rates[i].unit_id == rates.unit_ids[i], iteration preserves order and labels). The batch compute functions (compute_spatial_rates,compute_directional_rates,compute_view_rates,compute_egocentric_rates,population_peri_event_histogram) gained a keyword-onlyunit_ids=parameter that threads onto the result; a wrong-length value raises a clearValueError. Fully back-compatible:unit_idsdefaults tonp.arange(n_units)and the new fields arecompare=False, so existing callers and equality/hash behavior are unchanged. -
summary_table()— the per-unit summary terminal verb — on every batch encoding result (SpatialRatesResult,DirectionalRatesResult,ViewRatesResult,EgocentricRatesResult) and onPopulationPeriEventResult. Returns one row per unit,unit_id-indexed, with that result's scalar metric columns (peak location/rate, spatial info, grid/border score, cell type, preferred direction/distance, etc.). Accepts an optionalunit_ids=to relabel the index. -
PSTH results now carry the uniform result surface.
PeriEventResultandPopulationPeriEventResultinherit the canonicalResultMixinand implement the terminal verbs:to_dataframe()(dense — one row per time bin for the single-unit result, one row per(unit, time-bin)for the population result, always carryingunit_id),summary()(flat dict of headline scalars — peak rate/latency, baseline rate; population addsmean_peak_rate/population_peak_latency),PopulationPeriEventResult.summary_table()(one row per unit withpeak_rate/peak_latency/baseline_rate), andplot()(delegates toplot_peri_event_histogram, returns the axis). -
decode_session(env, spike_times, times, positions, *, dt, ...)— one-call encode→bin→decode golden path inneurospatial.decoding.session. Gluescompute_spatial_rates,bin_spikes_in_time, anddecode_positioninto a single function so beginners can decode position in ≤10 lines. Exported fromneurospatial.decoding(from neurospatial.decoding import decode_session). Accepts an optionalencoding_models=array to bypass the encoding step entirely. Extra keyword arguments are forwarded verbatim todecode_position. -
as_spike_trains— public helper that coerces the various spike-input formats (1D array, NaN-padded 2D array, list of scalars, list of 1D arrays) into the canonical list of per-neuron spike-time arrays. Exported fromneurospatial.encoding(and present in__all__); the implementation lives inneurospatial.encoding._spikes. It standardizes the container shape only — spike-time values are never shifted, rescaled, or aligned. (This is the previously-internalnormalize_spike_times, renamed before any public release so the name reads as a structural conversion, not a value transform; no deprecated alias is kept since the public name was never released.) -
classify(*, ...)— a single-type boolean cell-type predicate (returnsNDArray[np.bool_]) on every batch encoding result:EgocentricRatesResult(OVC,min_info=0.3),ViewRatesResult(view cell,min_info=0.5),DirectionalRatesResult(HD cell,min_mvl/alpha), andSpatialRatesResult(place cell,min_spatial_info=0.5). These replace the per-domaindetect_ovcs/detect_view_cells/detect_hd_cellsdetectors (now deprecated aliases). -
label_cell_types(...)onSpatialRatesResult— the multi-class string labeler ("place"/"grid"/"border"/"unclassified", returnsNDArray[np.str_]). This is the renameddetect_cell_typesand is kept deliberately SEPARATE from the booleanclassify(different return type). -
is_place_cell(...)— a single-neuron place-cell predicate, both as a free function inneurospatial.encoding.spatial(exported fromneurospatial.encoding) and as aSpatialRateResult.is_place_cell()method. Mirrorsis_spatial_view_cell/is_object_vector_celland agrees withdetect_place_fields(returnsTrueiff that detector finds ≥1 field). -
decode_position(env, spike_counts, encoding_models, dt, ...)now accepts a population rate result object (anything exposing afiring_ratesattribute, e.g.SpatialRatesResult) directly in place of the raw(n_neurons, n_bins)array, removing thenp.stack([r.firing_rate ...])glue between the encoding and decoding steps. -
New keyword-only parameter
warn_on_drop: bool = Trueonbin_spike_train,bin_spike_trains(encoding/_binning.py),compute_spatial_rate, andcompute_spatial_rates(encoding/spatial.py). Set toFalseto intentionally silence all spike-drop warnings (e.g. when the caller handles the diagnostic themselves).
Performance¶
-
decode_session_summarynow streams the time-binning so the full(n_time, n_neurons)spike-count matrix is never materialized. It builds the encoding model once over the whole session (small,(n_neurons, n_bins)), then bins spikes block-by-block against a contiguous slice of the global time grid and decodes + reduces each block via the same shared inner-loop helper asdecode_position_summary. Peak memory is nowO(time_chunk × max(n_neurons, n_bins))plus the(n_neurons, n_bins)encoding model — independent of session length — meeting the 1 hr / 25 ms / 5000-bin / <500 MB summary-decode DoD golden path (the dense(144000, 1000)count matrix alone would be ~1.15 GB). The result is byte-for-byte identical to the prior materialize-then-stream path; per-block counts are binned against the precomputed global edges so they match a single global histogram exactly, and boundary spikes are counted exactly once.decode_session(the full-posterior path) is unchanged. -
decode_positiongains two keyword-only memory knobs with no change to its return contract (.posteriorstays a fully-materializedndarray): dtype=np.float32stores and computes the posterior in single precision, halving stored and transient memory (parity with float64 to ~1e-6 relative). EveryDecodingResultmethod works unchanged on a float32 posterior.time_chunkis now hybrid.time_chunk=None(the default) keeps the full-matmul path byte-for-byte unchanged — the Poisson log-likelihood is computed once over the whole window and normalized at once (transient peak ~3× the stored posterior). An explicittime_chunk=know computes the Poisson log-likelihood blockwise directly into the preallocated posterior, so the full-size log-likelihood and its working copy are never materialized — cutting the transient peak to ~1× over the returned posterior (the posterior itself is unavoidably 1×, sincedecode_positionreturns the full dense array). The opt-in path is tolerance-equal, not byte-exact, to the full path: the per-block likelihood matmul is a different BLAS shape than the full matmul, so it differs by ~1e-15 (MAP/argmax identical; every row sums to 1). For a path that never holds even the full posterior, usedecode_position_summary.
For sessions where even the stored dense posterior is too large to hold, use
the new decode_position_summary / decode_session_summary (see Added),
which never materialize the full (n_time, n_bins) posterior.
-
compute_spatial_ratesgains a keyword-onlydtype(np.float32/np.float64, defaultnp.float64).dtype=np.float32halves the stored(n_units, n_bins)rate-map array. The rate computation (GEMM / division) is still performed in float64 and only the final result is cast, so float32 values match the float64 default within float32 tolerance.decode_session/decode_session_summarynow accept their owndtypeparameter (default float64) that honors float32 end-to-end — the encoding-model working set and the posterior — sodecode_session(dtype=np.float32)halves the decode working set on the golden path (see thedecode_session/decode_session_summarydtypeentry above). Defaultnp.float64leaves every existing caller byte-for-byte unchanged; any other dtype raisesValueError. -
Documented the dense diffusion-kernel O(n²) memory cost and added a loud high-bin memory warning. The heat kernel
exp(-tL)of a connected graph is dense by construction (every entry > 0), so it always costsn_bins**2 * 8bytes of float64 memory (≈ 3.2 GB at 20,000 bins).compute_diffusion_kernelsandenv.compute_kernelnow emit a loudUserWarning(with a GB estimate) above 3,000 bins and then proceed — there is no hard limit and noallow_largeopt-out. The warning names the size, the GB estimate, the dense O(n²) reason, and the fixes (reduce bins or usesmoothing_method="binned"). No numerical results change — this is documentation plus a warn-and-proceed guard. The reliable scale wins this release remain float32 rate maps and the memory-safe summary decode (above). A faster, lower-peakexpm_multiply/ Chebyshev rewrite of the kernel is a deferred stretch goal and is intentionally not part of this release. -
Warned on the dense Gaussian-KDE high-bin path the same way as the diffusion kernel.
smoothing_method="gaussian_kde"builds a dense(n_bins, n_bins)weight matrix (exp(-d²/2σ²), every entry > 0), so it carries the same O(n_bins²) memory cost; it now emits a loudUserWarning(with a GB estimate) above the shared_LARGE_KERNEL_THRESHOLD(3,000 bins) and proceeds — no hard limit, noallow_large. One warn threshold is shared by both dense smoothing paths instead of a divergent copy. Also corrected staleencoding/_smoothing.pydocs that wrongly described the diffusion kernel as "sparse" /O(n_bins)— it is dense(n_bins, n_bins), O(n_bins²) per neuron, with a one-time O(n_bins³) matrix-exponential build. No numerical results change. -
SpatialRatesResult.summary_table()no longer double-computes grid and border scores. It previously ran the expensive per-neuron grid/border score computation twice per call (once for thegrid_score/border_scorecolumns and again insidelabel_cell_types()); it now computes each once and forwards them.label_cell_types()gains optional keyword-onlygrid_scores=/border_scores=parameters to accept precomputed score arrays (validated to be 1-D and lengthn_neurons); when omitted it recomputes as before, so existing callers are unchanged. -
population_coverage()gains a keyword-onlyn_jobsparameter (default1) to parallelize per-neuron place-field detection via joblib (-1uses all CPUs).n_jobs=1keeps the sequential path with no joblib overhead; results are byte-for-byte identical regardless ofn_jobs(returned data identical; per-neuron exclusion warnings are not surfaced undern_jobs != 1).
Changed¶
-
Default rate-map output changed for gappy / out-of-bounds data. With the new
max_gapdefault of0.5 s,compute_spatial_rate/compute_spatial_rates(anddecode_session/decode_session_summary, which forwardmax_gap) now exclude spikes inside large tracking gaps and out-of-bounds excursions from the numerator so it matches the occupancy denominator. See the Fixed entry "Firing-rate numerator/denominator alignment on the FULL interval mask" for the full rationale and themax_gap=Noneopt-out. -
detect_region_crossingsargument order changed to follow the behavioral-segmentation convention:(position_bins, times, env, *, region_name, direction=...). The old positional order(position_bins, times, region_name, env, ...)is still accepted for one release (transitional dispatch emits aDeprecationWarning) and will be removed in 0.7. -
Docs/examples now teach
decode_sessionas the one-call decode golden path; the manual 3-call path (compute_spatial_rates→bin_spikes_in_time→decode_position) is kept as an "Advanced: manual three-call path" section.examples/20_bayesian_decodingnow leads withdecode_session; a new "Workflow 2: Bayesian Decoding (one call)" section was added todocs/user-guide/workflows.md(with aworkflows_decode_session_golden_pathCI snippet indocs/snippets.yml); andREADME.mdpoints tofrom neurospatial.decoding import decode_sessionas the position-decoding entry point. -
Docs (Phase 0.6 sweep): rewrote Workflow 1 in
docs/user-guide/workflows.mdto use the canonicalcompute_spatial_rate(env, spike_times, times, positions, ...).firing_rateidiom withsimulate_trajectory_ou+PlaceCellModelfixtures, replacing the hand-rollednp.histogram/scipy.ndimage.gaussian_filterapproach. Added a CI snippet entry (workflows_place_field_canonical) todocs/snippets.yml.
Deprecated¶
All of the following emit a DeprecationWarning and are scheduled for removal
in 0.7. Each old name forwards to its replacement with unchanged behavior.
EgocentricRatesResult.detect_ovcs→EgocentricRatesResult.classify.ViewRatesResult.detect_view_cells→ViewRatesResult.classify.DirectionalRatesResult.detect_hd_cells→DirectionalRatesResult.classify.SpatialRatesResult.detect_cell_types→SpatialRatesResult.label_cell_types(the multi-class string labeler; not folded intoclassify).ViewRateResult.peak_view_location→ViewRateResult.peak_location.ViewRatesResult.peak_view_location→ViewRatesResult.peak_locations.detect_region_crossingsold positional order(position_bins, times, region_name, env, ...)→ new order(position_bins, times, env, *, region_name, ...).
Fixed¶
-
decode_position_summarynow validatespriorshape (was silently truncating over-long 2-D priors), matchingdecode_position. A 1-D prior must be(n_bins,)and a 2-D time-varying prior must be exactly(n_time, n_bins); an over-long or short 2-D prior, a wrong-length 1-D prior, or a non-1-D/2-D prior now raisesValueErrorbefore streaming instead of silently slicing the prior to the decoded time range. -
Firing-rate numerator/denominator alignment on the FULL interval mask. A firing-rate map is
spike_counts (numerator) / occupancy (denominator)per bin.env.occupancydrops a trajectory intervalkfor three reasons —dt[k] > max_gap(large tracking gap),speed[k] < min_speed(low speed), andstart_bin[k] < 0(interval's start sample out of the active environment) — but the spike binner previously only filtered by the time window and (since the speed-filter work) by speed. A spike inside a dropped interval (e.g. a 1 s tracking gap, or an out-of-bounds excursion) was therefore counted in the numerator while occupancy excluded that interval's time from the denominator, inflating/biasing the rate. The spike numerator and the occupancy denominator now drop the identical set of intervals via one sharedinterval_valid_maskhelper (the single source of truth thatenv.occupancyalso consumes).compute_spatial_rate/compute_spatial_ratesgain a keyword-onlymax_gap: float | None = 0.5(matchingenv.occupancy's default, so occupancy behavior is unchanged) that gates both sides identically. - Behavior change: rate maps now differ for sessions that contain large
tracking gaps (intervals longer than
max_gap) or out-of-bounds samples — previously those rates were inflated. This is a correctness fix. Passmax_gap=Noneto disable gap gating on both sides (restoring the pre-fix, no-gap-gating behavior while keeping numerator and denominator aligned). -
No more silent empty rate maps. When the interval filter excludes every trajectory interval (so occupancy is all-zero and the rate map is all-NaN/0),
compute_spatial_rate/compute_spatial_ratesnow emit a singleUserWarningnaming the active gate(s) (max_gap,min_speed, and/or the out-of-bounds-start possibility) and suggesting fixes (check units; passmax_gap=None). Previously only themin_speedgate warned — a too-largemax_gap(or a wrong-units one) and the out-of-bounds-start rule emptied the map silently. Gated bywarn_on_drop=True(the default); fires once per call (not per neuron in the batch path). -
decode_sessionnow warns loudly when most spikes fall outside the decode time window[times.min(), times.max()]instead of silently dropping them. Previously, whenencoding_models=was passed (which skipscompute_spatial_rates), spikes outside the window were silently discarded bybin_spikes_in_time'snp.histogram, so a milliseconds-vs-seconds unit mismatch produced an all-zero count matrix and a plausible-but-wrong posterior with no warning.decode_sessionnow owns a singleUserWarning(naming the dropped count/total, percentage, decode window, spike range, and units hypothesis) that covers both theencoding_models-provided andencoding_models=Nonebranches; the encoder's now-redundant duplicate is suppressed. A new keyword-onlywarn_on_drop: bool = Trueparameter silences it for genuinely sparse sessions. -
The bare-
Environment()error now uses a unique code[E1006](was[E1001], which collided with "No active bins found") and points at the correct docs host (https://edeno.github.io/neurospatial/). Documented indocs/errors.md. -
Environment.distance_to(region_name)now raisesRegionNotFoundError(fromneurospatial._exceptions) instead of a bareKeyErrorwhen the named region is absent.RegionNotFoundErrorsubclassesKeyError, so all existingexcept KeyErrorblocks keep working without change. -
Removed stale
compute_firing_rate(...)calls (that function does not exist publicly) fromdocs/user-guide/workflows.md(Workflow 3) anddocs/user-guide/spatial-analysis.md; replaced with the correctcompute_spatial_rate(env, spike_times, times, positions, ...).firing_rateAPI and argument order. -
Replaced 11 broken
env.plot(field, ax=...)calls indocs/user-guide/spike-field-primitives.mdanddocs/user-guide/rl-primitives.mdwithenv.plot_field(field, ax=...). Theenv.plot()method's first positional argument isax, not a field array, so passing a field there was silently broken. -
Updated
examples/20_bayesian_decoding.py(and the jupytext-paired.ipynb) to use the batchcompute_spatial_rates(env, spike_times_list, times, positions, ...).firing_ratesfor encoding models and the canonicalbin_spikes_in_time(spike_trains, dt, t_start, t_stop)helper for time-binned spike counts; regenerated the notebook viajupytext --sync. -
Bumped stale version strings from
v0.4.0tov0.5.0indocs/index.md(status line and BibTeX entry) andREADME.md(dependency table header and BibTeX entry). -
GraphValidationErrormessages inlayout/validation.pyand the wrappingValueErrorinenvironment/core.pyno longer reference the internal developer guideCLAUDE.md. Error text now points to the public issue tracker (https://github.com/edeno/neurospatial/issues) instead. A permanent repo-wide backstop test (tests/test_no_internal_doc_refs.py) asserts that no source file undersrc/neurospatial/contains this string, preventing the leak from regressing. -
Environment.__init__now raises a beginner-gradeValueErrorwhen called without arguments (i.e. bareEnvironment()). The old message "layout parameter is required" gave no actionable guidance; the new message explains thatEnvironmentmust be created through a factory method, shows a concrete correct example (Environment.from_samples(data, bin_size=2.0)), lists the other available factories (from_polygon,from_graph,from_grid_mask,from_pixel_mask), and links to the online docs — matching the style already used byEnvironmentNotFittedError. The exception type remainsValueErrorso existingexcept ValueErrorcallers are unaffected. -
align_spikes_to_eventsinevents/alignment.pynow rejectsevent_timescontainingInfvalues with a descriptiveValueError, matching the existingspike_timesInf check and fulfilling the docstring promise. Previously anInfevent time silently produced an empty-or-wrong result. -
behavior.trajectorypublic functions (compute_turn_angles,compute_step_lengths,mean_square_displacement) andbehavior.navigation.traveled_path_lengthnow coercepositions(andtimesfor MSD) withnp.asarrayat the public boundary before any.ndim/.shapeaccess. Passing a plain Python list no longer raises a confusingAttributeError; valid list-of-lists inputs succeed, and malformed inputs raise a descriptiveTypeErrororValueError. -
bin_spike_trainandbin_spike_trainsinencoding/_binning.pyno longer silently drop spikes outside the trajectory time window or spikes that map to inactive environment bins. When the dropped fraction exceeds 50 % (or all spikes are dropped), aUserWarningis emitted naming the dropped count, total, both time ranges, and a units-hypothesis hint (e.g. spike_times in milliseconds vs. times in seconds). Two separate messages cover the two drop causes (time-window and inactive-bin). The batch path (bin_spike_trains,compute_spatial_rates) warns exactly once per cause in the main process — never from joblib worker processes where warnings are commonly swallowed. Default behaviour for in-window spikes is byte-for-byte unchanged.
Documentation¶
population_coveragedocstring example now shows the vectorized batch path (compute_spatial_rates(...).firing_rates) instead of the per-neuroncompute_spatial_rateloop; the pre-existing arg-order bug (population_coverage(firing_rates, env)) has been corrected topopulation_coverage(env, firing_rates).population_coverageshape-mismatchValueErrormessage now steers users tocompute_spatial_rates(the batch function) rather than the slowcompute_spatial_rate+np.stackrecipe.compute_spatial_rate(singular) docstring now includes a short note pointing many-neuron users tocompute_spatial_rates(plural), which shares occupancy and smoothing-kernel work across the whole population.
[v0.5.0] - 2026-06-04¶
What's Changed¶
Features¶
Bug Fixes¶
- fix(io,regions): atomic writes, NWB metadata round-trip, deep immutability (4307973)
- fix(animation): grid artist reuse, frame_times, figure/capture leaks (8f862d3)
- fix(decoding,stats,events): PSTH window, weighted Rayleigh, posterior NaN (301d2b0)
- fix(simulation): RNG independence, exact Poisson, mixed-mode kwargs (f00c048)
- fix(encoding): bearing wrap, occupancy masking, phase-precession optimizer (1525ffd)
- fix(environment,behavior,annotation): invariants, segmentation, leaks (d5c9f34)
- fix(ops,layout): correctness + safety fixes in core primitives (cefe030)
- docs(plan): mark test-suite remediation complete (9f18cc6)
- fix(encoding): recover egocentric distance tuning in binned rate (ef5090a)
- fix(simulation): place uniform field centers in the interior, not on the boundary (9872487)
Documentation¶
- docs(plan): mark test-suite remediation complete (9f18cc6)
- docs: address review follow-up on the fix-all changeset (a2ca036)
- test: reword two docstrings to drop implementation-plan references (28105fe)
- docs: refresh UX-facing onboarding docs (4080f49)
- docs: update CHANGELOG.md for v0.4.0 (c03df84)
Other Changes¶
- chore(release): 0.5.0 (#4) (d1afe9d)
- chore: remove completed test-suite remediation plan (8a7a04b)
- test(animation): drive visual regression through production renderer (8e5ed03)
- test: dedup shared test helpers into shared modules (review follow-up) (215c971)
- test: reword two docstrings to drop implementation-plan references (28105fe)
- test: tighten thresholds, strengthen sim recovery, hygiene (Phase 7) (fad0dcb)
- test: replace mocks with real-path tests where they hid integration (Phase 8) (ad3d105)
- test(ops,layout,environment): core-primitives behavioral coverage (Phase 5) (e23365d)
- test(nwb): add NWBHDF5IO disk round-trip tests for writers (Phase 6) (628acb2)
- test(animation): verify real cross-backend color-mapping parity (Phase 4) (b72dc61)
- test(decoding,stats,events): add closed-form + analytic-reference correctness tests (Phase 3) (d4f8da4)
- test(encoding): add end-to-end recovery + NumPy/JAX parity tests (Phase 2) (a8b216e)
- test(encoding): add object_vector_score tests; fix OVC convention doc + CLAUDE.md drift (d73cdc8)
Full Changelog: https://github.com/edeno/neurospatial/compare/v0.4.0...v0.5.0
0.4.0 - 2026-05-26¶
This release is the v0.4 UX cleanup: a wide-ranging consolidation of
public-API names, argument orders, return types, error semantics, and
example coverage. There are no deprecations; every change is a
clean delete-and-replace. Pin to <0.4.0 if you need the old surface.
Breaking changes¶
Parameter renames¶
distance_metric/distance_type/use_geodesic→metricacross all physical-distance APIs. Legal values are{"euclidean", "geodesic"}. AffectsEnvironment.distance_to,compute_egocentric_rate(s),compute_egocentric_distance,compute_spatial_rate(s),ObjectVectorCellModel,PlaceCellModel,BoundaryCellModel, and the boundary / border modules.smoothing_sigma/kernel_bandwidth→bandwidthacross smoothing APIs (compute_spatial_rate(s),compute_view_rate(s),compute_egocentric_rate(s),Environment.smooth, KDE helpers).velocity_threshold/speed_threshold/threshold→min_speedacross velocity-based behaviour segmentation (segment_by_velocity,heading_from_velocity, etc.).- Overlay
data=→ semantic name.PositionOverlay(data=...)→PositionOverlay(positions=...);HeadDirectionOverlay(data=...)→HeadDirectionOverlay(headings=...).
Result-class field renames¶
EgocentricRateResult.ego_env→env;ViewRateResult.view_occupancy→occupancy.PeriEventResult.firing_rateis now a cached attribute, not a method. Replaceresult.firing_rate()withresult.firing_rate.DecodingResult.uncertainty→posterior_entropy(matches the free function indecoding/estimates.py).- Singular vs plural method/attribute normalization on result classes
(single-neuron results use singular methods; batch results use plural).
Renames:
SpatialRateResult.peak_firing_rates()→peak_firing_rate(),SpatialRateResult.peak_locations()→peak_location(),ViewRateResult.peak_view_locations()→peak_view_location(). is_X_cellmethod names normalized to match the free-function names (is_object_vector_cell,is_spatial_view_cell,is_head_direction_cell).
Argument-order canonicalization¶
- Encoding functions are now
env-first, with the canonical order(env, spike_times, times, positions, headings?, object_positions?, *, ...). Affectscompute_spatial_rate(s),compute_egocentric_rate(s),compute_view_rate(s),detect_place_fields,is_spatial_view_cell,is_object_vector_cell,is_border_cell, and friends.
# v0.3
compute_spatial_rate(spike_times, times, positions, env)
compute_egocentric_rate(spike_times, times, positions, headings,
object_positions, env=env)
# v0.4
compute_spatial_rate(env, spike_times, times, positions)
compute_egocentric_rate(env, spike_times, times, positions,
headings, object_positions)
compute_directional_rate/is_head_direction_cellkeep the heading-domain-native(spike_times, times, headings, *, ...)signature — this is the documented exception to the env-first rule (heading is a circular angular variable, not a spatial position). See the function docstrings andCLAUDE.md"Canonical Argument Order".- Egocentric ops
allocentric_to_egocentric/egocentric_to_allocentricreorder to(positions, headings, targets). - Behavioural segmentation functions reordered to
(position_bins, times, env, *, ...). distance_to_rewardinevents.regressorsreordered to(env, times, positions, reward_times, ...).fit_isotonic_trajectory/fit_linear_trajectoryreordered to(env, posterior, times, *, ...)with a standardizedmethodkeyword.*keyword-only separator added consistently across the public API. Numerical parameters and verbose flags become keyword-only.
Coordinate / convention changes¶
Environment.is_1d→Environment.is_linearized_track. Same semantics (a 1-D graph track embedded in 2-D world coordinates); the new name resolves the historical "is this n_dims==1 or a 2-D track?" ambiguity. Serialized environment metadata uses the new key — pre-v0.4 saved environments will need to be re-saved.GridProperties.peak_coordsis now(x_offset, y_offset)instead of(row_offset, col_offset). Swappeak_coords[:, 0](was row) forpeak_coords[:, 1](now y) when reading the second component.simulate_trajectory_ou(speed_units=...)is now required (was defaulted). Speed defaults switch from m/s to cm/s. Mismatch betweenspeed_unitsandenv.unitsraises rather than silently rescaling.
Removed (no aliases, no deprecation)¶
Environment.save/Environment.load. The pickle path is gone. UseEnvironment.to_file/Environment.from_file(JSON metadata plus npz arrays).Environment.mask_for_region. UseEnvironment.region_mask.from_image/from_maskfactory aliases. Replaced byfrom_pixel_mask(image_mask, pixel_size, ...)andfrom_grid_mask(active_mask, grid_edges, ...).path_efficiency(float-returning). Usecompute_path_efficiencywhich returns aPathEfficiencyResult.- Cross-domain re-exports. Each public symbol now has exactly one
canonical import path; the top-level
neurospatialnamespace no longer re-exports symbols fromencoding,decoding, etc.
Added¶
PlaceFieldsResultdataclass.detect_place_fieldsreturns a frozen dataclass withfields,excluded_reason, andn_excludedfields. Still iterable / sized / indexable, so existingfor f in detect_place_fields(...)andlen(...)patterns keep working. Closes the "silent drop when mean rate too high" failure mode.BinSequenceWithRunsdataclass + new method.Environment.bin_sequencealways returns anndarray;Environment.bin_sequence_with_runsreturns a dataclass withbins,run_starts,run_lengths.MSDResultand friends. Misc result-type cleanup in trajectory analysis:MSDResult,SpatialAutocorrelationResult,PathEfficiencyResult.Environment.is_polarproperty andcoordinate_kindattribute.from_polar_egocentricsetscoordinate_kind="polar". Methods that assume Cartesian (distance_to,distance_between,Environment.contains,apply_transform,bin_aton(x, y)input) raise on polar environments with a clear error.plot_fieldswitches axis labels and skips the equal-aspect call so egocentric polar firing fields still render correctly.- Custom exception classes.
EnvironmentNotFittedError(already existed) now has a free-function variant; addedRegionNotFoundError,RegionAlreadyExistsError, and three more in_exceptions.py. Environment.from_pixel_maskandEnvironment.from_grid_maskfactories (replacingfrom_image/from_mask).Environment._state_versioninvalidation token. Cached properties verify the version on access; subset / transform / rebin bump it, so stale caches are surfaced loudly instead of returning silently-wrong results.Environment.__str__returnsinfo()for quick inspection.- Glossary page at docs/glossary.md defining 14
core terms. Linked from
docs/getting-started/core-concepts.mdand the README. docs/api/index.mdexpansion. Structured sections forencoding,decoding,behavior,events,ops.egocentric,ops.visibility,ops.basis,stats,animation,io.nwb.docs/examples/index.mdrewrite. Goal → notebook table plus full per-notebook entries with Time + Prerequisites.- Notebooks 24–27. Object-vector cells, head-direction tuning, peri-event PSTH, and NWB loading round-trip.
- README "Your First Place Field" front-door example. Canonical
pattern using
simulate_trajectory_ou,PlaceCellModel,generate_population_spikes, andcompute_spatial_rate. - CI doc-snippet test.
scripts/test_doc_snippets.pyplus.github/workflows/test_docs.ymlre-executes a curated manifest of doc snippets on every PR. - CI notebook regen test.
.github/workflows/test_notebooks.ymlre-executes11_place_field_analysis.ipynbper PR to catch silent regressions in the example surface. - Shared example styling.
examples/_style.pyWong / Okabe-Ito palette and fixed figure sizes. Wired into every tutorial notebook that previously set matplotlib rcParams inline (01-08, 19, 20, 22-27). Notebooks 09-18 and 21 had no rcParams blocks and intentionally keep matplotlib defaults.
Changed¶
- Silent failures replaced with loud failures.
subset()round-trip now returns aMaskedGridinstead of a one-offsubsetlayout kind, so the result is fully serializable.bin_atvsmap_points_to_binsstandardize on-1for out-of-environment samples in trajectory contexts.detect_place_fieldsreturns aPlaceFieldsResultwithexcluded_reasonset instead of silently returning[].batch_grid_scores/batch_border_scoresuse NaN as the explicit failure marker and warn once per batch.- Fitted-state checks at entry of
compute_spatial_rate(s),compute_egocentric_rate(s),compute_view_rate(s),decode_positionraise immediately instead of failing deep in the call stack. spike_timesvalidation rejects unsorted / negative / non-finite values with diagnostic messages.decode_position(validate=True)is the default; rejects negative spike counts and posteriors that don't sum to 1.- Canonical exception types throughout. Manual "not fitted" checks
migrated to
EnvironmentNotFittedError; warning-and-overwrite paths inRegions.__setitem__now raise. - Errors carry units and stack context. Length-mismatch errors
from
_binninginclude acontextarg so messages say "in compute_spatial_rate: ..."; magnitude errors include the offending unit. - Warning hygiene.
UserWarningfor data-quality,RuntimeWarningfor numerical fallbacks,stacklevel=2everywhere. - Production
print()calls replaced with module-levellogger.info/logger.debug. Environment.bin_attributes,edge_attributes,differential_operatorconverted from@cached_propertyto methods (get_bin_attributes(), etc.) so the cost is visible.Environment.unitsvalidated against a small registry ({"cm", "m", "mm", "px", None}) with aUserWarningfor unknown values. Documented as advisory.- Heading convention documented explicitly in every function that
takes a
headingsargument (allocentric world-frame: 0 = East, +π/2 = North; egocentric for OVC tuning: 0 = ahead, +π/2 = left). events.__init__is now eager (was lazy).- Bandit-task notebook prints the download URL and exits cleanly
when
data/is missing; CI no longer fails on the example.
Fixed¶
repr(env)name=Nonebug for empty-string names. Now usesrepr(self.name)so empty strings are visible as''.Environment._state_versioncache invalidation prevents stale-cache reads after mutating operations.- Polar environment misuse is now an error instead of producing silently-wrong distances or transforms.
Removed¶
Environment.save/Environment.load(pickle). Replaced byto_file/from_file.Environment.mask_for_region. Useregion_mask.from_image/from_maskfactory aliases. Replaced byfrom_pixel_mask/from_grid_mask.path_efficiencyfloat-returning function. Usecompute_path_efficiency.- All cross-domain re-exports from top-level
neurospatial.
Major feature additions (v0.3.x development cycle)¶
The following features were developed during the v0.3.x development line and ship as part of v0.4.0. The names below reflect the final v0.4 surface — many of these symbols were introduced under earlier names that were renamed during the M2 consolidation pass (see Breaking changes above for the v0.3 → v0.4 mapping).
Added (features)¶
- Spatial View Cells: Firing-rate fields indexed by gaze location
compute_view_rate()/compute_view_rates()— single / batchViewRateResultfrozen dataclass (firing_rate,occupancy,env, plusview_spatial_information(),is_spatial_view_cell(),sparsity(),selectivity()methods)is_spatial_view_cell()free-function classifier-
SpatialViewCellModelsimulation model with three gaze models (fixed_distance,ray_cast,boundary) -
Visibility and Gaze: Ray-casting visibility for view cells
FieldOfViewfrozen dataclass with species presets (FieldOfView.rat(),FieldOfView.primate())ViewshedResultfrozen dataclass (visible bins + visibility fraction)compute_viewed_location()— gaze-directed location projectioncompute_viewshed()/compute_viewshed_trajectory()/compute_view_field()— observer-position visibility analysisvisible_cues()— line-of-sight check to cue / landmark positions-
visibility_occupancy()— time each bin was visible -
Object-Vector Cells: Firing fields in egocentric polar coordinates
compute_egocentric_rate()/compute_egocentric_rates()EgocentricRateResult/EgocentricRatesResultfrozen dataclasses (firing_rate,occupancy,env, pluspreferred_distance(),preferred_direction(),egocentric_spatial_information(),is_object_vector_cell()methods)object_vector_score()— distance × direction selectivity metricis_object_vector_cell()free-function classifierplot_object_vector_tuning()— polar heatmap visualizationObjectVectorCellModelsimulation model with von Mises directional tuning-
ObjectVectorOverlayfor animation with object–animal vectors -
Egocentric Reference Frames: Foundation for object-vector and view cells
EgocentricFramedataclass withto_egocentric()/to_allocentric()allocentric_to_egocentric()/egocentric_to_allocentric()— batch frame transformscompute_egocentric_bearing()— angle to targets relative to heading (egocentric convention: 0 = ahead, +π/2 = left)compute_egocentric_distance()— Euclidean and geodesic distanceheading_from_velocity()/heading_from_body_orientation()— derive heading from tracking data-
Environment.from_polar_egocentric()— egocentric polar coordinate environment with optional circular connectivity -
3D Transform Support: Full N-dimensional affine transformation capabilities
- New
AffineNDclass for N-dimensional affine transforms using (N+1)×(N+1) homogeneous matrices Affine3Dtype alias for convenience (equivalent toAffineNDwith n_dims=3)- 3D factory functions:
translate_3d(),scale_3d(),from_rotation_matrix() - Integration with
scipy.spatial.transform.Rotationfor 3D rotations estimate_transform()now auto-detects dimensionality (2D or 3D) from input pointsapply_transform_to_environment()supports N-dimensional environments with validation- 45 new tests for 3D transforms including scipy integration
-
Backward compatible:
Affine2Dunchanged for existing 2D workflows -
N-D Probability Mapping:
alignment.pynow accepts N×N rotation matrices map_probabilities_to_nearest_target_bin()supports 3D environments- Updated
_transform_source_bin_centers()validates rotation matrix dimensionality - For 2D: Use
get_2d_rotation_matrix(angle_degrees) - For 3D: Use
scipy.spatial.transform.Rotation.as_matrix()
Changed (internal)¶
- Internal: Refactored
environment.py(5,335 lines) into modular package structure for improved maintainability - Split into 11 focused modules:
core.py(1,023 lines),factories.py(630 lines),queries.py(897 lines),trajectory.py(1,222 lines),transforms.py(634 lines),fields.py(564 lines),metrics.py(469 lines),regions.py(398 lines),serialization.py(315 lines),visualization.py(211 lines),decorators.py(77 lines) - Implemented mixin pattern: Environment inherits from all functionality mixins
- No breaking changes -
from neurospatial import Environmentcontinues to work - All 1,076 tests passing (100% success rate)
- Improved code organization for easier contribution and maintenance
- Largest module is trajectory.py at 1,222 lines (down from original analysis.py at 2,104 lines)
Documentation¶
- 3D Support: Updated dimensionality support documentation to reflect 3D transforms availability
- Updated
docs/dimensionality_support.mdwith 3D transform examples and feature matrix - Updated
docs/user-guide/alignment.mdwith comprehensive 3D transformation examples - Added complete 3D alignment workflow example
- Updated compatibility matrix: ~75% of neurospatial now works in 3D (up from 70%)
0.2.0 - 2025-11-04¶
Added¶
Environment Operations (Complete Feature Set)¶
Core Analysis Operations (P0)
Environment.occupancy()- Compute time-in-bin from trajectory data with speed filtering, gap handling, and optional kernel smoothingEnvironment.bin_sequence()- Convert trajectories to bin sequences with run-length encodingEnvironment.transitions()- Compute empirical transition matrices with adjacency filtering and normalizationEnvironment.components()- Find connected components in environment graphEnvironment.reachable_from()- Compute reachable bins via BFS or geodesic distance
Smoothing & Resampling (P1)
Environment.smooth()- Apply diffusion kernel smoothing to arbitrary fieldsEnvironment.rebin()- Conservative grid coarsening with mass/mean aggregation (grid-only)Environment.subset()- Extract subregions by bins, regions, or polygons
Interpolation & Field Utilities (P2)
Environment.interpolate()- Evaluate bin-valued fields at continuous points (nearest/linear modes)Environment.occupancy()linear mode - Ray-grid intersection for accurate boundary handling (grid-only)field_ops.pymodule:normalize_field()- Normalize to probability distributionclamp()- Bound field valuescombine_fields()- Weighted combination (mean/max/min)divergence()- KL/JS divergence and cosine distance
Utilities & Polish (P3)
Environment.region_membership()- Vectorized bin-to-region containment checksEnvironment.distance_to()- Compute distances to target bins or regions (Euclidean/geodesic)Environment.rings()- K-hop neighborhoods via BFS layersEnvironment.copy()- Deep/shallow copying with cache invalidationspatial.map_points_to_bins()- Enhanced withmax_distanceandmax_distance_factorthresholds for deterministic boundary decisions
Diffusion Kernel Infrastructure
kernels.pymodule:compute_diffusion_kernels()- Matrix-exponential heat kernel on graphs with volume correctionEnvironment.compute_kernel()- Convenience wrapper with caching- Support for both transition and density normalization modes
Documentation
docs/user-guide/spatial-analysis.md- Comprehensive 1,400+ line guide covering all operations with scientific contextdocs/examples/08_complete_workflow.ipynb- Enhanced workflow notebook with movement/navigation analysis- All methods have NumPy-style docstrings with working examples
- "Why This Matters" sections explaining scientific motivation for key operations
Changed¶
- GraphLayout: Now supports 1D layouts correctly (conditional
angle_2d, dynamicdimension_ranges) - KDTree operations: Now deterministic by default using
tie_break="lowest_index" - All environment operations: Use
@check_fitteddecorator for consistent state enforcement - Input validation: Comprehensive validation with diagnostic error messages across all operations
- Caching: Object identity-based caching for kernels and spatial queries
Fixed¶
- GraphLayout
angle_2dcomputation for 1D graphs (was unconditionally assuming 2D) - GraphLayout
dimension_rangesnow correctly handles 1D case - Disconnected graph handling in connectivity tests
- Hexagonal layout interpolation edge cases
Testing¶
- 1067 tests passing (up from 614 in v0.1.0)
- 0 skipped tests (eliminated all 12 previous skips)
- Performance benchmarks: occupancy on 100k samples, large transition matrices, kernel computation
- Integration tests: end-to-end workflows, multi-layout compatibility
- Edge case coverage: empty environments, single bins, disconnected graphs
Internal¶
- Systematic debugging skill used to eliminate all test skips
- Test-driven development for all features
- Code review and UX review completed
- Pre-commit hooks for code quality
0.1.0 - 2025-11-03¶
Added¶
- CompositeEnvironment API parity: Added
bins_in_region(),mask_for_region(),shortest_path(),info(),save(), andload()methods to CompositeEnvironment for full API compatibility with Environment class - KDTree-optimized spatial queries: CompositeEnvironment.bin_at() now uses KDTree for O(M log N) performance instead of O(N×M) sequential queries (enabled by default via
use_kdtree_query=True) - Structured logging infrastructure: New
_logging.pymodule with NullHandler by default, enabling optional logging for debugging and workflow tracing - Centralized numerical constants: New
_constants.pymodule consolidating all magic numbers (tolerances, KDTree parameters, epsilon values) for consistent behavior - Comprehensive type validation: CompositeEnvironment constructor now validates input types with actionable error messages
- Graph metadata validation: Added
validate_connectivity_graph()to enforce required node/edge attributes from layout engines - Dimensionality support documentation: New
docs/dimensionality_support.mdclarifying 1D/2D/3D feature support with compatibility matrix
Changed¶
- Updated alignment module: Now uses centralized constants (
IDW_MIN_DISTANCE,KDTREE_LEAF_SIZE) - Updated regions module: Uses
POINT_TOLERANCEconstant for consistent geometric comparisons - Enhanced error messages: CompositeEnvironment now provides detailed diagnostics for dimension mismatches and type errors
- Clarified 2D-only transforms: Updated
transforms.pydocstring to explicitly state 2D-only status and suggest scipy for 3D
Fixed¶
- Removed unused
type: ignorecomment inregular_grid.py - Fixed potential
KeyErrorin logging by renamingnameparameter toenv_name(avoids conflict with LogRecord reserved field)
Documentation¶
- Added comprehensive dimensionality support guide (1D/2D/3D feature matrix)
- Updated CLAUDE.md with latest patterns and requirements
- Added 18 new tests for CompositeEnvironment type validation
- Added 23 new tests for Environment error path coverage
- Added 28 new tests for graph validation
Internal¶
- Consolidated duplicate dimension inference code
- All 614 tests passing
- Ruff and mypy checks passing
- Test coverage: 78%
Highlights¶
- Initial release of neurospatial
- Core
Environmentclass with factory methods - Multiple layout engines (regular grid, hexagonal, triangular, graph-based)
- Region support for defining ROIs
- Composite environment functionality
- Alignment and transformation tools
- Comprehensive test suite
- NumPy-style docstrings throughout
- Automatic active bin detection from data samples
- NetworkX-based connectivity graphs
- 1D linearization for track-based experiments
- Spatial queries (bin_at, neighbors, shortest_path, distance_between)
- Visualization with matplotlib
- Morphological operations (dilation, closing, hole filling)