Skip to content

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_at and every lookup). Lookup, ray casts and the simulated walker share one on-boundary tolerance, so simulate_trajectory_ou no longer needs its own rule for a start_position on 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_size is 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_spikes computes 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_simulation and plot_session_summary estimate every cell's rate map in one compute_spatial_rates call; validating 50 cells is about 20 times faster. Importing neurospatial.simulation no longer loads scipy.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_distances measures, so the rate on the wall equals the rate just inside it.

Breaking changes

  • simulate_trajectory_sinusoidal no longer takes seed, 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_spacing used to build grid cells with NaN spacing.
  • Head-direction and boundary-vector ground_truth report 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_session put every place field on the wall, and every border cell in a session was the same cell.
  • validate_simulation compared 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_modulation halved 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 a requires_headings property. Head-direction cells used to take headings first, and spatial-view and object-vector cells took positions first. generate_population_spikes no 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 the NeuralModel protocol can now receive headings.
  • Models are frozen dataclasses with read-only arrays. Assigning center or preferred_direction after construction used to leave the rates unchanged while ground_truth reported the new value.
  • A model's attributes are its constructor arguments, and a parameter left to its default reads None: PlaceCellModel(env, seed=1).center and .width are None. ground_truth holds the values the model fires with, such as the drawn center, the default width in env.units and a boundary-vector cell's default tolerance; read them there. So dataclasses.replace and 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 default distance_tolerance follows a replaced preferred_distance. These used to keep the first model's resolved values. seed is now an attribute. A model that draws a parameter with seed=None draws a seed from the operating system first and keeps it there, so replace(model, max_rate=30) and pickling keep the drawn center or direction instead of drawing again. ground_truth always includes cell_type and every parameter needed to rebuild the model (for example place metric).
  • Object-vector cells: distance_width is now distance_tolerance and direction_kappa is now direction_concentration, the names used by boundary-vector and head-direction cells.
  • simulate_session takes model parameter names (grid_spacing, grid_orientation, distance_tolerance, preferred_distance, ...) instead of spacing, orientation, border_width and distance. It refuses a parameter for a cell type the session does not simulate, which it used to drop silently. In border_cell_session, border_width is replaced by distance_tolerance.
  • validate_simulation returns a SimulationValidationResult with summary(), summary_table(), report() and plot(ax=None), instead of a dict; show_plots is removed.
  • SimulationSession holds 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_width replaces width in the T, Y, plus, linear, repeated-T and repeated-Y mazes, and arm_width in the radial maze;
  • the W-maze's width/height are now outer_arm_spacing/arm_length;
  • Hampton Court's width/height are now frame_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 for pause_duration; the old pauses began short of the ends and lasted 2–5 s regardless of pause_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_session spreads fields over the whole track, not just 40–160 cm of 200 cm.
  • grid_cell_session draws 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_simulation builds 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 as excluded and 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_error holds a fixed center-error limit, so report() states it even for an empty selection, which used to raise IndexError.
  • validate_simulation and plot_session_summary refuse a session on an egocentric polar environment up front, with CartesianOnlyError naming the function. The error used to come from compute_spatial_rate inside them, and only when a unit had spikes.
  • plot_session_summary draws 1-D sessions, such as linear_track_session, as position against time with a rate curve per cell; it used to raise IndexError. Rate maps are drawn by Environment.plot_field in physical units, with unsupported bins shown as missing, where 2-D grids used to be labelled in bin indices. 3-D sessions raise NotImplementedError pointing to the fastplotlib 3-D field plot.
  • SimulationSession compares 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_rate refuses positions whose width differs from the environment's. generate_poisson_spikes raises 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 without require_visibility; before, it had no effect without require_visibility. Visibility with boundary_source="analysis" is about 70 times faster.
  • add_modulation keeps 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 = -1 and direction = "pause", and lap boundaries no longer repeat a sample.
  • OU headings use the exact integral of the turning rate. At dt = 0.1 s 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 is exp(-t/τ) at any dt. At lag τ the old Euler step gave 0.48 at dt = τ, 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_mode values, periodic on 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_watermaze refuses 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.
  • MazeEnvironments is 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_visits summed 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's point_radius disc 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_region and regions_containing_points know no environment and use the union of the polygon regions passed (all of them, also those not named in region_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_visits accepts quadrants together instead of refusing them as overlapping.
  • include_boundary= is removed from all of these. Passing it raises TypeError.
  • point_radius must 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_information and 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_likelihood refuses NaN, infinite and negative rates; it passed NaN through, and normalize_to_posterior then made every row uniform. normalize_to_posterior raises on rows with NaN or +inf in every handle_degenerate mode (only all −inf rows are degenerate), and its axis parameter is removed.
  • Prior shape, weights and mass are checked even with validate=False; a negative prior gave negative posterior mass.
  • decode_session_summary and BayesianDecoder.predict_summary report polar mean_position in the animal's plane, like the full decode; they averaged raw (distance, angle).
  • BayesianDecoder.predict, predict_summary and score raise 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 pynapple TsGroup) and pass the same object to both steps.
  • A fitted BayesianDecoder refuses dataclasses.replace(decoder, env=...) with a different environment. from_models refuses negative rates, and NaN or infinite rates inside spatial_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 of times.
  • decode_position and decode_position_summary take a rate array or a SpatialRatesResult; other objects raise TypeError.
  • bin_spikes_in_time explains a t_start after the last spike instead of suggesting dt <= 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_estimate is renamed map_bin, and the summary's map_estimate alias is removed. posterior_mode(posterior), which ranked bins by mass, is replaced by map_bin(env, posterior), which ranks by density like every other MAP.
  • Undecodable (all-NaN) rows report map_bin == -1 and 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.
  • DecodingResult and DecodingSummary refuse 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_position is derived from map_bin and is no longer a constructor argument. peak_prob is renamed peak_probability (also the data-frame column, xarray variable and mean_peak_probability key).
  • summary() on both results averages over decoded time bins and reports n_undecodable; one undecodable row made mean_entropy NaN, 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_distance and mean_angle; they were labelled x and y.
  • posterior_entropy is float64 on every path, and credible_region never includes zero-mass bins.

Replay and decoding metrics

  • fit_linear_trajectory, fit_isotonic_trajectory and detect_trajectory_radon refuse 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_trajectory requires n_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 within band of 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 reports speed (env units per second), start_position at times[0], the replay score (in [0, 1], equal for forward and reversed sweeps and independent of the decode's bin size) and the sinogram over sinogram_speeds and sinogram_start_positions. theta_range/theta_step, angle_degrees and the pixel offset are removed: the angle and offset depended on bin size, and the score was larger for one direction of the same event.
  • New replay_trajectory_significance tests 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 isotonic direction of None.
  • 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) takes env, 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.activations and reactivation_strength use 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_strength is 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.activations is time-first, (n_time_bins, n_assemblies).
  • detect_assemblies(algorithm=) is renamed method=. With n_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. NMF explained_variance_ratio is the variance along the pattern, wᵀSw / tr(S).
  • pairwise_correlations returns 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 its z_score_input argument is removed. explained_variance_reactivation returns NaN statistics when undefined. ExplainedVarianceResult.reversed_ev is renamed reversed_explained_variance and is NaN without a control period.
  • AssemblyDetectionResult.patterns is 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_regions refuses a world_to_pixel that changes the shape of its input or returns NaN, naming the region, instead of failing inside Matplotlib.
  • Regions.buffer refuses NaN or infinite source coordinates as such; it said the buffer "leaves nothing".
  • A transform passed to points_in_any_region, regions_containing_points or 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.regions is typed Mapping[str, Region], so a type checker flags env.regions.add(...); with_regions, the factories' regions= and every function that only reads regions accept any Mapping[str, Region].
  • Region.area and Region.center replace Regions.area(name) and Regions.region_center(name). Regions.list_names() is removed (use list(regions)). Regions.to_dataframe() and regions_to_dataframe() are replaced by regions_table(regions): one row per region, indexed by name, with kind, n_dims, area, center and metadata.
  • points_in_any_region and regions_containing_points take positions (was pts), shape (n_positions, 2); a bare (2,) point is refused. A point region is a disc of radius point_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_points has typed overloads for return_dataframe.
  • plot_regions returns the Axes and keeps axis limits the caller set.
  • mask_to_region(mask, *, region_name, pixel_to_world=None, simplify_tolerance=0.0) replaces mask_img= and approx_tol_px=; it traces exact pixel cells (the old default simplified by about 1 px), keeps holes and no longer needs OpenCV.
  • load_cvat_xml and regions_from_cvat take image= (name or id), required when the export annotates more than one image.
  • Region is unhashable, n_dims is a property, and metadata errors are ValueError. Region and annotation-draft metadata must be standard JSON: string keys, finite Python numbers (NumPy scalars such as np.float64 are refused; convert with float(...)); record a missing value as None.
  • update_region keeps the region's position in the collection (it moved to the end), so env.region_membership() columns stay put, and accepts a metadata-only update.
  • regions_from_json refuses a file without the Regions-v1 format tag (it warned and loaded), and regions_to_json writes standard JSON.
  • The unused NWB region JSON helpers and their legacy reader are removed.

Input that now raises (each was silently accepted):

  • Region refuses 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.
  • Regions holds only Region values of one dimension (a line Landmark was published as a region). Regions.buffer refuses a result that is empty or split into several parts.
  • with_regions refuses a region whose metadata["coord_system"] differs from the environment's units, such as pixel annotations on a centimeter environment. with_metadata(units=...) and a length-preserving apply_transform(..., units=...) refuse to label an environment in units other than a region's recorded ones, since neither converts the region.
  • points_in_any_region and regions_containing_points refuse NaN or infinite positions, which were reported as outside every region, a transform output that is not finite (n, 2), and repeated region_names.
  • load_labelme_json dispatches on shape_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 (now label_0, label_1, ...) and a top-level list of shapes no longer warn-and-skip or crash.
  • load_cvat_xml refuses video track exports and files with no shapes (both returned empty regions), and raises on malformed shapes instead of skipping them.
  • mask_to_region refuses 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_regions refuses unknown per_region_kwargs names 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_region and VideoCalibration. 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_region covers 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_regions draws polygon holes open whatever their winding, and env.plot widens its limits to show the regions it draws past the bins.
  • with_metadata(units=...) relabels each region's recorded coord_system that 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, pairwise mean_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_runs keeps 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 with np.nanmean or np.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_displacement gives the same value at a lag whatever max_tau is; on the uniform-sampling fast path the values shifted by about 1e-13 with max_tau.
  • Error messages for an invalid choice argument (compare_spatial_rates, population_vector_correlation, detect_region_crossings, and the Run, Trial, Lap and Crossing records) and for an invalid valid= 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 renamed success_only=, matching runs_to_direction_labels and Run.success.
  • compute_turn_angles and compute_trajectory_curvature are removed. Use compute_planar_turn_angles(times, positions) for the turn at each frame; its new smooth_window= (seconds, default 0) gives the smoothed turn the old curvature function returned, for example smooth_window=0.2 for 30–60 Hz video. Use compute_planar_curvature to compare path shape (1/cm) across frame rates and speeds, and compute_movement_angular_velocity for 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; pass positions[:, :2] for horizontal-plane turns.
  • heading_direction_labels(times, positions, *, min_speed, ...) takes a trajectory only. To label headings you already have, for example from heading_from_body_orientation, use the new headings_to_direction_labels(headings, speeds, *, min_speed, n_directions=8) instead of speed=/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). Pass max_tau to keep longer lags.

Input that now raises (each was silently accepted):

  • distance_to_reward refuses an unknown metric; "Euclidean" or a typo ran the geodesic metric. It also refuses empty input, repeated timestamps and decreasing times when reward_positions is given, and checks mode first.
  • position_bins outside [-1, n_bins) raise BinIndexOutOfRangeError, and float or boolean bins raise TypeError, in detect_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) and distance_to_decision_boundary (returned NaN).
  • A NaN, infinite or negative min_speed raises in heading_from_velocity, instantaneous_goal_alignment, goal_bias and compute_goal_directed_metrics; goal_bias(min_speed=nan) returned NaN.
  • segment_by_velocity refuses empty input (it returned a (0, 2) array) and checks all timestamps, so a repeated timestamp next to a NaN row raises.
  • time_to_goal and trials_to_region_arrays refuse empty, unsorted, repeated or non-finite times.

Results that change:

  • distance_to_reward with 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_labels gives 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 and max_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 raise max_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, mismatched position_bins/times, and positions without x and y columns read the same in every behavior function.

Faster (same results):

  • mean_square_displacement with 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 and metric="geodesic" keep the pair loop.
  • trajectory_similarity(method="dtw") is about 100× faster.
  • segment_trials, detect_region_crossings, detect_runs_between_regions and running_direction_labels compute region masks once per call instead of once per recording segment (running_direction_labels with 500 epochs: 0.44 s → 0.02 s); direction labels, time_to_goal and trials_to_region_arrays slice 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_efficiency and subgoal_efficiency is 3× faster at high sampling rates (rounding-level differences, below 1e-13).
  • Geodesic distance_to_reward prepares all reward fields in one batch.
  • Geodesic path lengths (compute_step_lengths, traveled_path_length, compute_path_efficiency) and other metric="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 geodesic mean_square_displacement pair 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 across neurospatial.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), and path_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_runs and the lap far-side test used bin-graph distances. Environment.distance_between and ops.distance_field still measure along the bin graph. Behavior geodesic functions refuse egocentric polar environments.
  • angular_efficiency (and compute_path_efficiency, subgoal_efficiency) measures heading toward the goal. It ignored goal and 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; under metric="geodesic" the goal direction is where the in-environment shortest path leads. It now takes (times, positions, goal) plus metric, env, max_gap, epochs and break_before.
  • compute_decision_analysis().chosen_goal is the first goal entered after the decision-region entry. It was the first goal in list order visited at any time, so swapping goal_regions changed the answer.
  • distance_to_decision_boundary is (d2 − d1) / 2, the distance to the boundary; it returned twice that. The DecisionBoundaryMetrics field is renamed distance_to_decision_boundary.
  • Bins equidistant from two goals are labelled -1 (undecided) by geodesic_voronoi_labels; ties went to goal 0, so a symmetric T-maze recorded crossings for right turns only.
  • detect_laps lap 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 report overlap_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_regions ends a run when the animal re-enters the source (outcome "returned"); runs overlapped and were double-counted, and running_direction_labels marked walks back home as outbound.
  • segment_trials reports a goal entry just past max_duration as 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_displacement reports the lags it measured (multiples of the sample interval); linspace labels were up to 16% off, biasing α.
  • compute_trajectory_curvature is 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") uses Environment.boundary_bins; on a maze with a junction almost every bin counted as an edge. It and geodesic distance_to_reward measure 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_sequence labels 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_rate is 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_metrics measures its distances with metric and only inside epochs.
  • Goal alignment is NaN at the goal itself (arctan2(0, 0) scored −1).
  • path_progress is d_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_labels centres 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_range is an occupancy level set that includes every bin tied at the cutoff.
  • subgoal_efficiency skips missing rows (one NaN made every segment NaN).

Breaking:

  • Trial and between-region Run record outcome ("reached", "timeout", "returned", "censored"); success is derived. A trial cut off by a tracking gap, an epoch edge or the recording end is censored, not failed. Run also names source_region and target_region; write_trials stores an outcome column (outcomes= replaces successes=).
  • segment_by_velocity and detect_goal_directed_runs return (n, 2) [start, stop) epochs instead of Run objects. detect_goal_directed_runs scores 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=None defaults to 20 cm in the environment's units.
  • compute_vte_session keeps one VTETrialResult per input trial, with a status field and NaN metrics for unmeasured trials; trials were dropped, so results did not line up with trials. VTESessionResult derives its counts and means and adds trials_table(). classify_vte(NaN) is None.
  • min_speed result fields are min_observed_speed; suggests_deliberation(max_mean_speed=) replaces min_speed=.
  • GoalDirectedMetrics.time_to_goal is time_to_reach_goal.
  • subgoal_efficiency(env, times, positions, subgoals, ...) takes times.
  • graph_turn_sequence(tolerance=) replaces min_samples_per_edge=.
  • distance_to_boundary(boundary_type="obstacle") is removed (it could not work).
  • DecisionAnalysisResult.boundary is None with one goal; crossing times and directions are read-only arrays.
  • Derived result fields are no longer constructor arguments: PathEfficiencyResult.efficiency, SubgoalEfficiencyResult means, 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_window and event_indicator name their first argument times, not sample_times, like every other per-sample function. window may be passed by position, in the canonical events order f(times, event_times, window); window= still works.
  • events_to_intervals raises when an interval stops before it starts; it warned and kept the negative-duration row, which also passed any max_duration filter. 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 with match_by= instead.
  • PeriEventResult and PopulationPeriEventResult derive bin_centers, firing_rate(s), mean_firing_rate, n_units and mean_histogram instead of taking or caching them, so they can no longer disagree with window, bin_size and histograms (a hand-built result with n_units=7 and 3 rows reported 7 units). Drop those arguments when building a result by hand. Construction now checks every array's shape against the bins window and bin_size give, a finite positive bin_size, and integer event counts with n_events >= 1.
  • PopulationPeriEventResult takes mean_sem, the standard error across events of the population-mean histogram, and plot() 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_histogram and population_peri_event_histogram refuse a NaN or infinite bin_size and one longer than the window, which returned an empty histogram and all-NaN summaries, and 2-D event_times, which were flattened into extra events.
  • ContinuousEventResult derives relative_times from window and dt instead of taking it. Drop that argument when building a result by hand.
  • add_positions refuses unsorted trajectory times, 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_positions samples positions exactly as align_continuous_to_events samples a signal. An event exactly at a tracked sample gets that position even when the sample is isolated by gaps longer than max_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_events wraps sampled circular values to (−π, π], the convention of wrap_angle and of its own circular means; they were in [−π, π), so due West read −π in values and +π in means. An exact half-turn between two samples is now interpolated the positive way.
  • The PSTH baseline_rate (in summary() and summary_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_window and event_indicator count each event at exactly one sample when bin-centred windows tile time. Window edges computed twice with different float rounding (s[k] + dt/2 and s[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_events keeps column dtypes and the row labels of events. 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= on time_to_nearest_event, event_count_in_window and event_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. With epochs, 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. PSTH summary_table() and to_dataframe() record window, bin_size, n_events and n_events_dropped in df.attrs, and to_dataframe() has a firing_rate_sem column in Hz next to the count-unit sem. PeriEventResult.firing_rate_sem gives 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_histogram and population_peri_event_histogram warn once, not once per unit plus once more, for a window longer than 60 s.
  • align_continuous_to_events queries each event at exactly 0 s. The grid start + dt * j can 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 epochs or spike_window, an event window that ends exactly at an epoch's stop is kept. Its stop, computed as event + window[1], can round one ulp past the epoch (0.1 + 0.2 against 0.3), which dropped the event from peri_event_histogram, marked it outside_epochs in align_continuous_to_events and gave NaN in event_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 than max_time: the sign of a clipped value comes from the true nearest event, so a seen event 5 s back read as +max_time although an unseen one 2.6 s ahead was possible. Without any event, signed=False with max_time gives max_time (no event is nearer), checked against epochs like any other value, instead of all NaN; a session without rewards is a known covariate, as in event_count_in_window.
  • time_to_nearest_event refuses max_time=True, which clipped every value to 1 s, and an infinite or non-numeric max_time; pass None for no clipping.
  • align_events refuses a NaN or infinite event or reference time, which it dropped without a word, and a non-DataFrame input with a TypeError.
  • filter_by_intervals refuses NaN event times (with include=False they 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_intervals and intervals_to_events refuse NaN times, a NaN match_by key and a NaN or negative max_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 float timestamp column.
  • add_positions refuses 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_events already refused them) and non-numeric timestamps, which it parsed.
  • event_count_in_window and event_indicator refuse a NaN or infinite window bound (a NaN start gave negative counts) and 2-D time arrays. time_to_nearest_event refuses max_time=nan, which returned all NaN.
  • ContinuousEventResult turns list and tuple fields into arrays and tuples before checking them: a list event_status passed the checks and then reported 0 retained events. Construction also checks dt, window, max_gap, epochs, interpolation per value kind, unique feature_ids, counts within the retained events, finite values wherever support is True, and the unit of circular features, and that epochs are sorted and separated as as_intervals returns them. align_continuous_to_events stores every radian spelling ("radian", "radians") as "rad".

Faster:

  • peri_event_histogram and population_peri_event_histogram count 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.events no longer imports pandas.
  • in_epochs, restrict_spike_trains and 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_value is removed from compute_spatial_rate, compute_spatial_rates, has_place_field, is_place_cell and place_cell_significance, and from analysis_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 passing fill_value=0.0 before BayesianDecoder.from_rates did nothing. For a tool that needs finite values, fill a copy with np.nan_to_num(rates.firing_rates) and keep rates.spatial_support; a zero-filled raw array still needs prior=rates.spatial_support * env.bin_sizes to decode.

Results that change:

  • DirectionalPlaceFields.correlation, rate_map_correlations and population_vector_correlation call a correlation undefined by the rule field_stability and compare_spatial_rates use: 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_correlation needs at least three units: with two it returned ±1 at every location, and it now raises.
  • object_vector_score refuses 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) sets jax_enable_x64 for 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, matching peak_location() and summary_table(). It could report a rate in a never-viewed bin and returned the first bin for an all-zero map.

Faster:

  • place_cell_significance and spatial_cell_type_significance bin 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_rates fills 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_significance and egocentric_object_vector_cell_significance (and the predicates with criterion="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_coverage diffuses all candidate field peaks in one batch (about 5x faster).
  • compute_field_emd uses sparse transport constraints, and with metric="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_precession shuffles 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_properties and the grid statistic of spatial_cell_type_significance build the five autocorrelogram rotations once per map shape (batch_grid_scores on 100 maps: 100 ms instead of 180 ms; spatial_cell_type_significance for 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.encoding no longer imports matplotlib or scipy.stats (median 0.22 s instead of 0.43 s).

Messages that change:

  • A bad spike train in a *_significance function 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_rate and compute_view_rates check method (and, for views, gaze_model and view_distance) before other settings, matching their single-unit or population counterparts. An invalid method with no environment or a unitless one used to report a misleading "needs a bandwidth" error.
  • spatial_view_cell_significance reports wrong-length gaze_offsets and positions that do not match the environment with the same errors as compute_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_significance and, without an environment, compute_object_vector_rates report positions with the wrong number of columns (for example (n, 3)) instead of raising a NumPy broadcasting error.
  • smooth_rate_maps_batch refuses a negative, NaN or infinite min_occupancy on the JAX backend too, so the view and object-vector shuffle tests refuse it before shuffling. Every smoothing method compiles under jax.jit with traced counts, occupancy and threshold; tracing with JAX float64 off raises an error that says to enable jax_enable_x64 first, instead of failing with a buffer-size mismatch.
  • Single-unit gaussian_kde rate 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_metrics and compute_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_coverage and SpatialRateResult.region_coverage. in_out_field_ratio averages rates over space.
  • compare_spatial_rates reports mean_rate_a and mean_rate_b as 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")) and credible_region rank 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 explicit prior is a mass per bin: pass prior=rates.spatial_support * env.bin_sizes for 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 with env.subset, or pass a prior.
  • Replay fits read each bin's position along the track instead of its index: fit_linear_trajectory and fit_isotonic_trajectory report slopes in environment units per second (not bins per second), and detect_trajectory_radon resamples 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_radial counts 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_circular and shuffle_posterior_weighted_circular shift 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:

  • env is the first argument of in_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_circular and shuffle_posterior_weighted_circular. PopulationCoverageResult has a bin_sizes field.

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 giving interval_valid is crossed like a deleted row.
  • simulate_trajectory_ou keeps 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_ou refuses a start_position outside the environment, or one without env.n_dims coordinates, 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_gap is 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, whatever max_gap said: 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_sequence and 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_session and decode_session_summary tile decode bins on the tracked clock, so a tracking loss longer than max_gap is not decoded, as when its rows are deleted; to decode spans without tracking, predict with a fitted BayesianDecoder on times=np.arange(t0, t1, dt). bin_sequence drops NaN samples instead of reporting −1, so −1 means only "outside the environment", and functions that take position_bins bridge bin −1 like a missing sample.

  • max_gap defaults 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 for max_gap between 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_velocity while the animal is still) is bridged like missing tracking when it is shorter than max_gap. Pass a smaller max_gap where that would invent values, as the behavior-around-events guide shows.

Encoding review (neurospatial.encoding)

Grid score

Results that change:

  • spatial_autocorrelation returns 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_score sweeps 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 to spatial_cell_type_significance for a verdict.
  • grid_scale and grid_properties report 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_rel is relative to the zero-lag correlation.
  • On layouts without a rectangular grid (hexagonal, track, mesh), SpatialRateResult.grid_score() and SpatialRatesResult.grid_scores() raise NotImplementedError, as spatial_autocorrelation does, instead of returning NaN; NaN now means an all-NaN or constant map. summary_table() records the refusal in df.attrs["unavailable_metrics"], and a tuned cell is labeled "undetermined" instead of "place".

Breaking API changes:

  • grid_score loses inner_radius_fraction and outer_radius_fraction. grid_scale and grid_properties lose min_distance and take pixel_size= (the side of an autocorrelogram pixel, autocorrelation_pixel_size(env)) instead of bin_size=; n_peaks must be at least 3.
  • GridProperties is frozen with read-only arrays. peak_coords are offsets in the environment's units (they were autocorrelogram pixels) and n_peaks is derived from them.
  • periodicity_score is 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_radial stays.

Border score

Results that change:

  • border_score is 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 above threshold (0.3) of the peak and at least min_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_area defaults to 200 cm² (Solstad et al., 2008) converted to env.units. Without metric length units (the default for from_samples and from_polygon), a field needs at least 9 bins (the BNT/opexebo default) instead of the score being unavailable. summary_table() records the rule in df.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) and untuned_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 in df.attrs["border_observed_wall_fraction"] and df.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 without spike_count_dispersion (pass 1.0 to assume Poisson spiking). classify(), summary_table() and interpretation() use the same test.
  • Mean vector length uses only visited heading bins, so classify(), is_head_direction_cell(), summary_table() and interpretation() warn when more than 90° of heading was never sampled and point to the shuffle.
  • bin_size defaults to None, meaning 6° in either angle_unit, in compute_directional_rate(s), is_head_direction_cell and head_direction_cell_significance. The old default np.pi / 30 was read in angle_unit, so with angle_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 record direction_frame="allocentric".
  • tuning_width(), tuning_width_deg() and tuning_widths() return NaN for a unit whose heading bins were never visited instead of raising.
  • bandwidth must be None or a finite number > 0; bandwidth=0 raised ZeroDivisionError in 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.radians or angle_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_significance runs 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; use DirectionalRateResult.tuning_pvalue() or DirectionalRatesResult.tuning_pvalues(). summary_table() gains tuning_pvalue and spike_count_dispersion columns.

Added:

  • Directional results gain keyword-only spike_count_dispersion and dispersion_dof fields, analysis_settings["dispersion_window"], and largest_unvisited_arc() (radians; also in summary_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_cell and is_spatial_view_cell compare spatial_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() and summary_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=None and fill_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() and summary_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_significance bins 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_properties spacing), 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() (keyword min_spatial_info) and criterion="shuffle" on is_place_cell, is_spatial_view_cell, is_object_vector_cell and is_egocentric_object_vector_cell (keyword min_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 alpha because too few null scores are finite (1 / (1 + n_finite) >= alpha) is "undetermined", not "unclassified". p-values are (1 + k) / (1 + n) over the n finite null scores, as the docs now state, and summary_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 the SpatialRatesResult and counts rates.classify(min_info=...), so it applies the bias correction. Passing an information array raises.
  • is_place_cell names its information screen criterion="threshold", like every other cell family (was "spatial_info").
  • place_cell_significance loses n_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 a CellTypeSignificanceResult with observed and null scores, p-values and a summary_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 a spatial_information_bias column after spatial_information in their summary tables, so a unit that fails a screen despite high information shows why. summary_table() of spatial results adds nullable passes_spatial_information, passes_grid_score and passes_border_score columns.
  • CellTypeSignificanceResult.label_cell_types() and summary_table() take correction="holm" (Holm–Bonferroni across each unit's applicable tests: the chance that an untuned unit gets any label stays at alpha) or correction="fdr_bh" (Benjamini–Hochberg across every unit and test: the expected fraction of false labels in the population stays at alpha); the default None tests each statistic at alpha. 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_adjusted columns, 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_fields stops 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 at min_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_significance for a verdict.
  • Coverage counts only observed bins. spatial_coverage_single_cell and population_coverage divided 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_metrics reports orientation as an axial angle in [−π/2, π/2) (it could read −3.1), and area is physical area, not a bin count.
  • compute_field_emd compares 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 give inf instead of jumping walls. The value is in the environment's units.
  • field_shift_distance(metric="geodesic") raises IncompatibleEnvironmentError unless both environments have the same bins and connectivity, instead of silently returning a Euclidean distance.
  • DirectionalPlaceFields.directionality_index is 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_fields keys 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 say gaussian_kde leaks across thin walls (21–60% of the peak in a simulated maze); use diffusion_kde or binned in walled mazes.

Breaking API changes:

  • detect_place_fields and population_coverage take a required keyword bandwidth (the map's smoothing; 0 for an unsmoothed map) and lose min_size. has_place_field passes the map's own bandwidth.
  • PopulationCoverageResult stores only place_fields, is_observed and bin_centers; counts, bin sets and coverage_fraction are properties. PlaceFieldsResult.n_excluded is derived from excluded_reason.
  • DirectionalPlaceFields loses its labels argument (labels come from the firing_rates keys) and checks its maps against the environment when built.

Phase precession

Results that change:

  • compute_phase_precession rescales positions to [0, 1] over position_range (default: the spike extent), and slope_bounds are 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_precession decides on the shuffle p-value alone. The min_correlation cutoff 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_bounds edge (two fields fit as one, or both running directions pooled) warns and is flagged by slope_at_bound. has_phase_precession warns 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_precession raises on configuration errors (a bad n_shuffles, slope_bounds or position_range) instead of returning False; only too few spikes or constant positions return False. An unknown angle_unit raises.
  • The docs say positions must increase along the direction of travel (one field and one running direction at a time), state where theta_phase puts phase 0 (the theta peak; π is the trough), and add a linear-track recipe.

Breaking API changes:

  • PhasePrecessionResult is frozen; pval is renamed p_value; slope_units is removed; slope is in radians per position unit, normalized_slope is radians across position_range, and offset is the phase at position_range[0]. It gains a required slope_at_bound field.
  • has_phase_precession loses min_correlation and gains slope_bounds, position_range and min_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_settings records time_beyond_distance_range and fraction_of_time_in_distance_range, and a peak in the outermost ring of a range that cut time off warns.
  • object_vector_score scores 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() and preferred_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_range and fraction_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_location also defaults to method="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 in env.units) and direction_bin_size (radians, default π/6) instead of n_distance_bins and n_direction_bins, and distance_range defaults to None. Defaults in cm need known length units: with env=None pass distance_range, and without env.units pass distance_bin_size (and, for kernel methods, bandwidth, default 5 cm) in the positions' units.
  • object_vector_score loses max_distance_selectivity and takes the ObjectVectorRateResult or its (n_distance_bins, n_direction_bins) map; a 1-D array raises with the reshape to use.
  • Boundary-vector results replace spatial_information() with distance_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" (and compute_viewed_location(method= "fixed_distance")) view_distance is required; it is refused with "ray_cast". The 10-unit default had no source.
  • Polar results read distance_range, n_distance_bins and n_direction_bins from env; they are no longer constructor fields. Object-vector and boundary-vector results gain method and bandwidth.

Rate maps and result classes

Results that change:

  • Kernel rate maps (diffusion_kde, gaussian_kde, binned; both backends) are NaN outside spatial_support (occupied bins at or above min_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() and peak_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_occupancy must be finite seconds ≥ 0; NaN masked every bin without a warning and negative values skipped masking. Every rate result has a typed min_occupancy field that defines spatial_support.
  • rate_map_coherence returns the Fisher z (arctanh r) of Muller & Kubie (1989) and BNT, not r, and loses method (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() column peak_rate is peak_firing_rate, spatial_info and view_spatial_info are spatial_information, and ViewRateResult.view_spatial_information() is spatial_information().
  • unit_table is copied on every read, so editing it cannot misalign units, and every population family keeps the unit_table of SpikeTrains input 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) as n_spikes_outside_intervals, also a summary_table() column.
  • Every family records method (and bandwidth) in summary(), to_dataframe() and summary_table().attrs.
  • NWB spatial-rate tables (schema 2.5) store min_occupancy and n_spikes_outside_intervals; files without min_occupancy are 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, BorderCellModel and BoundaryVectorCellModel use 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 naming outline="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.clip keeps 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_from paired 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_known is 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 expose outline and wall_kinds. neurospatial.arena.BoundaryKind names the kinds. The geometry helpers boundary_geometry, physical_walls, boundary_view and physical_ray_hits take a required blocking="movement" | "gaze". The arena codec (version 3) and STATE_FORMAT (7) record the kinds and refuse older records; save them again from rebuilt environments. The elevated_arena maze helper is removed.

Simulation and mazes

Results that change:

  • simulate_trajectory_ou varies speed (gamma-distributed with an OU time course) instead of holding it at speed_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.
  • SpatialViewCellModel gets 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_session runs 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 with bin_size, so simulated ground truth does not change with the analysis binning; an environment without length units needs an explicit width.
  • 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_simulation compares 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 as max_center_errors.
  • from_polygon gives 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:

  • BoundaryCellModel is replaced by BoundaryVectorCellModel (Barry et al., 2006: every visible piece of wall contributes) and BorderCellModel (firing by distance to the nearest wall), measured against the declared walls. Both default their tuning (see above); BoundaryVectorCellModel draws a random preferred direction from a new seed. boundary_cell_session becomes border_cell_session (n_boundary_cells becomes n_border_cells), and simulate_session's cell_type="boundary" becomes "border".
  • grid_cell_session(grid_spacing=) defaults to 41.0 (was 50.0), and linear_track_session passes 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, distance cutoff or neighbors_within radius is refused. A sign check alone (value <= 0) let NaN through, so a NaN bandwidth made every basis function NaN and a NaN max_distance marked no point outside. Infinite bandwidths, scales, factors and lengths are refused too; an infinite cutoff, radius or max_distance still means no limit.
  • env.nearest_bin breaks ties with the same rule as env.bin_at on 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_field reads the source field with env.interpolate(mode="nearest") instead of its own copy of that lookup, so it refuses a field with an infinite value, for both methods, as interpolate does. 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 and validate_simulation's max_center_error. On a polar environment validate_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 track max_distance_factor and the wavelet basis used straight-line neighbors, which can lie on another arm.
  • stats.wrap_angle returns 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_trajectory and visibility_occupancy sampled 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, and visibility_occupancy gave 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_rays is removed. visibility_occupancy over 1000 frames runs in 0.6 s instead of 9–39 s.
  • ViewshedResult.occlusion_map counted ray samples per bin (high near the observer), not visibility. It is replaced by visibility_map: 1 visible, 0 hidden, NaN for a missing observation. visible_bins, n_visible_bins and visibility_fraction derive 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. With return_seconds=False, an interval leaving the environment added a whole count; it now adds the inside fraction. 2-D layouts are unchanged.
  • resample_field filled 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 without track_width is refused, since its extent is unknown.
  • resample_field(method="diffuse") treats missing (NaN) source values the way env.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_basis in 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_basis weighted 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_transform returned 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 a weight attribute that some edge lacks (a misspelt name counted hops).
  • The geodesic_rbf_basis design-matrix recipe used env.bin_sequence, which drops repeated bins, so rows no longer matched time bins. The examples now use env.bin_at(positions) and drop outside samples.

Breaking API changes:

  • Affine2D is removed; the 2-D factories return AffineND. AffineND refuses 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).
  • VideoCalibration is frozen, computes its inverse when built, and requires a positive integer frame size.
  • convert_to_cm and convert_to_pixels require cm_per_px (it defaulted to 1, labelling pixels as cm). from_rotation_matrix refuses matrices that are not rotations.
  • map_probabilities takes the source alignment as one AffineND, source_transform=, instead of source_scale, source_rotation_matrix and source_translation_vector, so estimate_transform(source_landmarks, target_landmarks) passes straight in. get_2d_rotation_matrix and apply_similarity_transform are removed; the new rotate_2d(angle, *, center=None) builds a rotation in radians, about center when given, so turning an arena about its center no longer needs c - s * R @ c.
  • map_probabilities refuses to map when more than max_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 than max_distance (default target_env.bin_spacing) from every target bin center. One rule covers both limits, so an explicit max_distance also keeps centers that lie inside a coarser target bin. A smaller share, such as a posterior's far tail, goes to its nearest bin; raise max_outside_fraction to accept a known mismatch, and max_distance=math.inf keeps the old nearest-bin assignment. It also refuses non-finite probabilities and eps <= 0. ProbabilityMappingParams is removed.
  • compute_diffusion_kernels and geodesic_rbf_basis take bandwidth= instead of sigma=.
  • geodesic_distance_matrix loses n_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 -1 as the last bin.
  • FieldOfView loses blind_spot_behind (the presets already encode it through left_angle/right_angle) and refuses non-finite or crossed bounds. compute_viewed_location defaults to max_distance=None (reach every wall; the 100-unit default returned NaN in larger arenas) and loses method="boundary", which ran the same code as "ray_cast"; the view encoders and SpatialViewCellModel lose gaze_model="boundary".
  • EgocentricFrame is removed; use allocentric_to_egocentric and egocentric_to_allocentric. Geodesic compute_egocentric_distance refuses a target outside every bin instead of returning a NaN column, and allocentric_to_egocentric refuses a points time axis that differs from positions.
  • TieBreakStrategy is a string Literal; the enum is removed.
  • normalize_field refuses arrays that are not 1-D (a (n_time, n_bins) array was normalized as one distribution) and loses its unused eps. apply_kernel refuses NaN fields, pointing to env.smooth, and refuses bin_sizes with mode="forward". select_basis_centers loses method="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, including SpikeTrains, refuses NaN or infinite spike times and names the unit by its label.
  • A (n_spikes, 1) column of spike times (the default scipy.io.loadmat shape) was read as n_spikes units of one spike each; it now raises and suggests spike_times[:, 0]. A 2-D array whose columns, not rows, are the units now raises and suggests spike_times.T, and NaN before a spike in a row is refused instead of being dropped as padding.
  • A pynapple Ts or pandas Series of 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_gap is checked wherever it is used. Encoding accepted max_gap=-1, NaN or infinity, excluded every interval and recorded the bad value in analysis_settings; every analysis now refuses a max_gap that is not a positive finite number of seconds (or None), including strings and booleans. A speed gate (min_speed) without speed now raises instead of being skipped.

  • With max_gap equal 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 a max_gap above the sampling interval, instead of saying (at 1 kHz) that times are probably not in seconds or (at 30 Hz) saying nothing.
  • Decoding warns when whole dt bins leave more than 10% of the observed time undecoded. Each recording run keeps only whole bins, so with frequent tracking dropouts a large dt silently dropped the end of every run and its spikes (27% of the observed time in one example at dt=0.5). The warning names dt and max_gap; warn_on_drop=False silences it.
  • Unit labels are checked the same way everywhere. unit_ids refuses a single string (which split "ab" into units a and b), a scalar, and missing (None or NaN) labels, which sel(unit_id=...) could never find. unit_ids that equal a spike group's own labels only in value (1.0 for 1, True for 1) now raise, since results key shuffle seeds by the label. RateComparisonResult refuses duplicate unit_ids, and a unit_table that is not a DataFrame is named as such instead of reported as a length mismatch.
  • detect_region_visits, DecodingResult.error_against and the true-position overlay of DecodingResult.plot now give the shared excluded-interval warning. With times in 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 returns False, and compare their arrays to test values.
  • Results without a to_dataframe() or plot() no longer inherit a stub that raises NotImplementedError; the method is absent (AttributeError), as the API contract says for results such as ResampledPathResult and ContinuousEventResult. A result's repr names an error raised by its summary() instead of showing ClassName(...), and rounds NumPy float32 metrics like other floats.
  • A missing_label that 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_order order, 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 on edge_order. env.edge_pairs gains 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=True no 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_polygon on a polygon that holds no cell center and from_grid_mask with an all-False mask built an environment with zero bins, on which occupancy returned 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_spacing is 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. rebin no longer warns about such pairs across a barrier in the outline: they are now closed.
  • Grid cells are exactly bin_size wide. Before, the bin count was rounded up and the cells were shrunk to fit the extent, so bin_size=3 on 0-100 cm gave 2.94 cm cells, bin_size=7 gave 6.68 x 6.69 cm cells that were not square, and layout_parameters["bin_size"] named a size no cell had; rebin already 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 to from_samples, from_polygon and RegularGridLayout, 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 infinite dimension_ranges entry is refused instead of silently building one cell. A grid no longer depends on where its range lies: a 1-unit range at 1e6 with bin_size=0.1 was read as a single point (a relative np.isclose tolerance 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 as np.float32(2.0) now works; before, from_samples raised TypeError: object of type 'numpy.float32' has no len() and np.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 a TypeError instead of being read as a number. neurospatial.layout.validation now holds this one check, validate_cell_size; its unused validate_bin_size and validate_dimension_ranges are 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_ind maps the exact end of the track (a reward well at the last node) to the last bin instead of -1, as bin_at does. 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). MaskedGridLayout kept the caller's mask itself, so editing it after build() left the engine with more bins than grid cells and from_layout refused it with an unexplained mismatch. A build() that raises now leaves the engine unbuilt (binning() raises LayoutNotBuiltError) instead of mixing the old bins with the new grid.
  • A negative or fractional bin_count_threshold is 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 honor figsize. They silently ignored every keyword argument, including the node_kwargs, bin_edge_kwargs and show_bin_edges options GraphLayout.plot documented; an option a track plot does not take is now a TypeError. Every layout engine's plot() draws the same picture as Environment.plot, through one shared method.
  • create_layout is 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 bare LayoutEngine protocol, which has only build() and binning().
  • create_layout with a keyword its engine does not take raises TypeError, as Python does for an unexpected keyword, with a message that lists the options the engine's build() takes. It raised a bare ValueError naming only the unexpected keyword.
  • get_layout_parameters marks 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_type is typed ClassVar[str], so type checkers flag layout.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 a LayoutType differed from those built on 3.10.
  • ScaleBarConfig refuses invalid options when it is made, with a message that names the option and the allowed values: a zero, negative or non-finite length, a position or box_style outside the listed choices, a background_alpha outside [0, 1] and a non-positive font_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, so length=0.1 + 0.2 reads "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 LayoutNotBuiltError naming the method that was called, such as TriangularMeshLayout.binning or HexagonalLayout.bin_sizes. Messages named private state (_full_delaunay_tri, hex_radius_, "hexagonal geometry") or an attribute the method read, and ShapelyPolygonLayout.outline() raised a bare RuntimeError.
  • ImageMaskLayout.build requires pixel_size, as Environment.from_pixel_mask already 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, and layout_parameters["pixel_size"] recorded None instead of the size used.
  • A reversed (max, min) dimension range is refused, with the pair and axis named, by create_layout, every layout engine that takes ranges and get_n_bins, as Environment.from_samples already refused it. The layouts silently reordered it, so a swapped pair (often the wrong column or sign) built a grid without a word, and get_n_bins returned a negative count. get_n_bins also 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, as Environment.from_samples does. Before, a regular grid refused it with a message about a non-finite range and a hexagonal layout raised OverflowError.
  • ShapelyPolygonLayout refuses 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 example create_layout("RegularGrid", positions=...) without bin_size) names the engine and the missing option, with a fix, instead of Python's bare "missing a required argument".
  • A track edge_order entry whose two nodes no edge of the graph joins is refused with a message naming it; the build failed later with a bare KeyError.
  • A misspelled layout option, passed to create_layout or directly to an engine's build(), is named in one message pointing to get_layout_parameters(). Before, create_layout named it, while a direct build() reported a missing required option instead of the misspelling.
  • HexagonalLayout refuses a non-finite dimension_ranges end 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_field draws one collection instead of a patch per hexagon (about 5x faster at 13k hexagons; the collection is now a PolyCollection), 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; a nan_color with its own alpha keeps it there as on hexagons and polygons.
  • ImageMaskLayout cell 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: ...) in Environment.maze, linear_track, from_graph and GraphLayout. 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_spacing is refused by the track layout before binning, with the gap count it needs. It was refused after binning by TrackLinearization, which the caller never named; a string raised a bare IndexError and an infinite gap warned first.
  • Environment.plot draws 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_mask and rebin(bin_size=...), which refused them. Cells of 0.3 at 1e12, whose widths are up to 4e-4 uneven, are refused by create_layout and from_samples as by from_grid_mask; they were built with wrong cell volumes.
  • Hexagonal environments recorded away from the origin keep their cells after apply_transform moves them, for example to center the arena at (0, 0). The cells (used by plot_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_bins refuses 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 in closed_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, in bin_at and to_linear.
  • Triangular-mesh positions exactly on the polygon outline now get a bin, instead of -1.
  • Environment.boundary_bins is 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, so border_score was NaN or wrong there. Area bins without cell geometry now raise NotImplementedError; track chain ends are found for any length.
  • Nearest-bin lookup without a distance limit is now an explicit choice. A NearestBinLocator with max_distance=None on bins with no known domain refuses position lookup, also as a component of a combined environment; pass max_distance= in length units, or math.inf to accept any distance. max_distance is 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_nd returns 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_cues and compute_view_field refuse non-finite positions, headings or cue positions; compute_viewshed and compute_viewshed_trajectory mark a missing frame's occlusion map and visibility_fraction NaN 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 lattice grid_shape and active_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 MaskArena with a declared physical footprint keeps wall_hit_bins(..., "physical") working.
  • Geometry records check their own invariants when built: Binning refuses pairs naming missing bins, inconsistent faces or face normals, and stores one grid (RectilinearGrid, FramedGrid or None) in place of the rectilinear and grid_frame arguments, which remain read-only properties. RectilinearGrid, RectilinearLocator, TrackLinearization, HexLocator, HexagonShapes, TriangleShapes and GridIndex validate 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.build and MissingFaces.build take bins and connections), 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 takes flat_to_active; TransformedLocator(base, forward) stores the forward map (inverse is 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 TypeError is no longer reported as a corrupt file. Hexagon lookup treats infinite coordinates as missing and refuses a non-positive radius.
  • Environment.combine validates 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 HeadDirectionOverlay with more than one PositionOverlay now raises ValueError in 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_decoding and expose half-open AnimationField(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=None explicitly 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.
  • Animation and named AnimationField streams retain physical clocks, lazy sources and fixed color scales across notebook viewing and MP4/GIF export. Animation.from_decoding retains the result's environment and times. AnimationView adds recording-time seeking, interval selection and export, with native viewers available through native. A view exports the whole recording through its last time until select_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. Use render="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.
  • EventInspector preserves 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_before boundaries. Pass max_gap=None explicitly 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 vmin to 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 their edge_color and edge_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.export writes .mp4, .webm, .mov, .avi, .gif and self-contained .html players. Video takes codec ("h264", "h265", "vp9", "mpeg4"), crf (default 18), preset and bitrate; HTML takes image_format, max_html_frames, embed and frames_dir. An option the chosen file type does not use raises TypeError. Without imageio-ffmpeg (the video extra), a video export raises ImportError saying 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; None uses 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 an if __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_fields now builds an Animation and returns an AnimationView (napari, widget, fastplotlib; the viewer is view.native) or the written Path (video, HTML), instead of a napari Viewer, FastplotlibAnimation or None. 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 by layer_names). Fields are shown on their recording clock: each row is held until the next for at most max_gap (new, default 0.5 s; None holds every row), and longer gaps show as missing instead of a stale field. The frame rate is the sampling rate of frame_times times speed, capped at max_playback_fps; a capped file keeps speed and skips rows, with a warning, instead of playing slower than requested. An export of the whole recording ends on its last row; selected time_range windows are half-open. Fields shown side by side share one cmap, colorbar label and, without vmin/vmax, one robust color scale from up to 100 rows of each, so a color means the same value in every panel; build an Animation with separate AnimationFields for independent scales. Lazy sources that are not arrays (h5py datasets) are read by row, and an iterator of rows raises TypeError. HTML players label each frame with its recording time ("t = 12.345 s"). trail_length spans 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's show_cursor and cursor_color apply in every view.
  • FastplotlibAnimation is the fastplotlib view's view.native; build it through Animation.show(backend="fastplotlib") or animate_fields. Animation.show(backend="fastplotlib") no longer takes cmap, vmin, vmax, colorbar_label, title, show_regions, region_alpha, window_seconds or track: set them on the Animation or its AnimationFields, which every view reads. animate_fields still takes them.
  • Custom overlay adapters: OverlayProtocol needs only convert_to_data, which is called once with the scene clock; sampled data must carry its times, and its support masks and shapes are checked when it is built. Readers an adapter returns stay the adapter's to close (VideoData._owns_reader is removed). Animation.overlay_data() has no own_samples argument and returns sampled overlays with times set to the requested clock.
  • Requests no view can draw are refused when the Animation is built, for built-in overlays and custom adapters alike: angle-only headings in 3-D, headings or video in 1-D, and misspelled show_regions names. speed, max_playback_fps and napari's fps are checked before any viewer opens. animate_fields(speed="fast") raises ValueError, and SpikeRasterOverlay rates on a different environment raise IncompatibleEnvironmentError.
  • TimeSeriesOverlay no longer takes window_seconds, interp, update_mode, playback_throttle_hz or scrub_throttle_hz: every signal panel spans the scene's window_seconds, and no view throttled or paused its panels by them.
  • Removed animation options: napari cache_size, chunk_size and max_chunks, widget initial_cache_size, cache_limit, image_format and return_container, dry_run, and frame_labels outside fastplotlib. large_session_napari_config is removed: napari scene views read on a worker thread with bounded caches. The legacy napari, video, HTML and widget backend modules, neurospatial.animation.transforms and the neurospatial.animation._video_io import path are removed.
  • annotation.TrackGraphResult has a trailing units field. Existing manual constructors still work because it defaults to None, but positional tuple unpacking must now include this ninth item. Prefer named attributes such as result.node_positions and result.units.
  • heading_direction_labels, instantaneous_goal_alignment, goal_bias and compute_goal_directed_metrics use the movement rule of heading_from_velocity: a direction exists only where the smoothed speed is above zero and at least min_speed. A missing position (NaN row) is a gap that ends a run instead of an error. heading_direction_labels labels an observed animal that is not moving "stationary" and a sample with no observed movement (missing position, no usable interval) None, which labels_to_epochs leaves out; with precomputed speed and heading, a NaN speed now gives None (was a direction label or "stationary") and a moving sample with a NaN heading gives None (was "stationary"). A still animal at min_speed=0 was labeled "0–45°" (East). goal_bias of an animal that never moves is nan; it was 1.0 when the goal lay East. compute_goal_directed_metrics warns 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 most max_gap seconds; the fill is linear in time along the shorter arc. Longer gaps, samples before the first or after the last keypoint, and samples outside epochs are 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 Environment method 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 widths dt and bin_size, sampling rates, durations, thresholds such as min_speed, counts, radii and NWB table names). An NWB reader or writer given a former positional option raises a TypeError that 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 analysis epochs= and labels_to_epochs already used, so adjacent windows never share a spike or event:
  • in_epochs, restrict and restrict_spike_trains default to closed="left" (was "both"); pass closed="both" to match pynapple's inclusive restrict.
  • align_spikes_to_events and align_events leave out a spike or event exactly at event + stop, so the raster agrees with the peri-event histogram, which already did. event_count_in_window, event_indicator and filter_by_intervals do the same at the window or interval stop.
  • A window with stop <= start is refused, since it holds no time. event_indicator therefore needs an explicit window= (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_labels and goal_pair_direction_labels label [start_time, end_time): a lap's last sample starts the interval that leaves the lap, so it is now None, and labels_to_epochs gives 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 None instead of "other", and "other" is an ordinary label everywhere. laps_to_direction_labels no longer takes unknown_label: laps whose direction is unknown are None. compute_directional_place_fields leaves out None, NaN and a new missing_label= value, like labels_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_occupancy counts viewing time only over usable sampling intervals. It takes max_gap (default 0.5 s), epochs and break_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 exactly times[-1] - times[-2]. fov, n_rays and boundary_source are checked even when no interval is usable, a non-integer n_rays raises ValueError (was TypeError), and headings need one entry per timestamp.
  • behavior.distance_to_reward takes max_gap (default 0.5 s), epochs and break_before. NaN positions give NaN distance instead of raising. Without reward_positions, a reward location is read from the sample at the reward time or interpolated only within an observed interval that passes max_gap and break_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. With break_before, a sample selects only rewards from its own recording, and samples outside epochs are NaN. Gap-free tracking with rewards inside the recording gives identical results.

  • Per-sample behavioral covariates have one home, neurospatial.behavior. heading_from_velocity and heading_from_body_orientation move from neurospatial.ops next to compute_speed, and distance_to_reward and distance_to_boundary move from neurospatial.events next to distance_to_region. There are no aliases, so the old imports raise ImportError. Both heading functions also change in this release (see the heading_from_velocity and heading_from_body_orientation entries). ops keeps the egocentric frame conversions, and events keeps 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.mazes now 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); new BarnesDims.hole_inset and CheeseboardDims.rim_margin. The small hex maze's wells are now port_A, port_B and port_C, and an invalid barrier set raises ValueError naming 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's metadata["role"] is one of MAZE_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_right well_left, well_right
    W-maze (track nodes) well_1_top..well_3_top well_1..well_3
    T-maze left_end, right_end well_left, well_right (food wells)
    radial arm arm_0..arm_7 food_cup_0..food_cup_7
    Barnes hole_k and escape_hole at the same hole escape_hole only; new start_box
    cheeseboard reward_i_j well_i_j
    Sungod reward_well_0..10, center home_port, center_port_left/_right, arm_port_1..8, box
    crossword box_*, reward_* start_box_*, cap_*
    Hamlet house_0..4 hide_house, run_house, interact_house, eat_house, drink_house; new agora
    Hampton Court start, goal entrance, central_enclosure
    repeated T start, goal start_box, food_box
    repeated Y start, goal entrance_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_correlation is now rate_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 is firing_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_stability returns NaN when fewer than 3 bins are finite in both maps (the correlation is undefined, not "uncorrelated"): fewer than 2 paired bins was 0.0 and exactly 2 was always +-1; both are now nan. Use np.nanmean or np.isfinite when averaging across cells. It is also nan when either map is flat up to rounding (highest and lowest rates within 1e-12 of its largest rate), the rule compare_spatial_rates uses. Before, only an exactly constant map gave nan, so a flat map from a smoothing estimator, which differs from constant by rounding, was correlated with the other map.

  • SpatialRateResult and SpatialRatesResult raise ValueError when built with firing rates or occupancy that do not have one value per bin of env (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 from compute_spatial_rate(s) and read_place_field are 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) checks env against the environment saved with the rates, as the decoder checks rates from another environment (see Fixed), and raises IncompatibleEnvironmentError naming 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. Omit env= to use the saved environment. A file whose rates have no saved environment cannot verify env= and raises ValueError; rewrite it with write_spatial_rates.

  • AnnotationResult from annotate_video has four fields, (environment, regions, walls, landmarks); unpack it as env, regions, walls, landmarks = result or use the attributes. RegionType adds "wall" and "landmark".

  • count_place_cells counts a cell at exactly threshold and takes threshold by keyword, matching the inclusive >= cutoff of classify() and the other threshold screens. Before, a cell had to exceed the threshold, and threshold could be passed positionally: count_place_cells(info, 0.5) becomes count_place_cells(info, threshold=0.5).

  • traveled_path_length takes times first 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_efficiency follows the same observation rule as traveled_path_length, and takes break_before for 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_window returns (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, not 0.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_labels takes times before positions, 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_rate follows the same observation rule as traveled_path_length, and takes break_before for 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_curvature takes times first, and times is 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_displacement follows the same observation rule and takes epochs and break_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_velocity no longer invents a heading where it has no velocity direction. Movement heading is the direction of the velocity vector, so it is not observed below min_speed, or where speed is exactly zero (where it used to return 0, East). Those samples are now NaN; 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 default min_speed=0 most of a short stop still has a heading: set min_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. SpatialBins holds centers, measures and measure_dim; the new BinConnections holds the neighbor pairs, their navigation distances, is_open, and closed_by (the index in arena.walls of the wall closing each closed pair, recorded when walls are applied), and Binning takes it after the bins: Binning(kind, bins, connections, locator, faces). Replace SpatialBins.from_arrays(centers, sizes, dim, pairs, distances) with SpatialBins.from_arrays(centers, sizes, dim) and BinConnections.from_arrays(pairs, distances), and read binning.connections.pairs where you read binning.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.pairs and env.edge_pairs are 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_dims has one entry per bin (an integer passed to SpatialBins.from_arrays applies to all), and SpatialBins.measure_dim is the shared value, or None when bins mix lengths and areas. Environment.bin_sizes raises 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.rebin keeps 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 after rebin(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), so tree1 is tree2 is 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 with nearest_bin. Use env.nearest_bin there. heat_kernel_wavelet_basis on a polar environment now measures bin spacing physically instead of in those rows. Before, it returned the cached tree itself, and changing its indices or reshaping its data in place changed the environment's own lookups and bin centers.

  • Binning no longer has a clip_mapping field. The clipped locator already holds the mapping, and the stored copy had to agree with it, so Environment.clip_mapping now reads it from the locator. Drop clip_mapping= when building a Binning for from_bins.

  • Environment.from_samples (and open_field) tile the arena's bounds when given an arena= with a known outline and no dimension_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 set dimension_ranges by 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_layout and io.nwb.environment_from_position take wall_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, rebin and saved environments still refuse a wall through a bin, with an error that names the wall and the bin.

  • Environment.apply_transform takes units=, 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 with apply_transform(scale_2d(0.1)) still said "px" while its bin areas shrank 100-fold. Pass the new unit (units="cm"), or units=env.units for an arena stretched in the same units. Translations, rotations and reflections keep the units as before.

  • Environment.smooth, Environment.diffuse and Environment.compute_kernel require mode=. smooth defaulted to mode="average" and the other two to mode="density", so the same call gave different numbers on the same field depending on the method. Pass mode="average" for a rate map or other value per bin, mode="transition" for counts, occupancy or probability mass, and mode="density" to turn counts into a density. Leaving mode out raises a TypeError that 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 of node_positions sets 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. When node_positions holds 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_points and ops.pairwise_distances raise networkx.NodeNotFound for a bin that is not a node of the graph, such as the outside marker -1. Before, they returned inf (or default), the value they also return for two bins with no path between them, so a missing sample looked like an unreachable place. default now applies only when no path exists. Drop -1 samples before calling (bins[bins >= 0]).

  • Environment.occupancy (with the default time_allocation="start") now counts an interval only when both of its samples are inside the environment, the rule compute_spatial_rate already 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 by env.occupancy used a different denominator than the rate maps. Fill short dropouts during tracking preprocessing to keep that time.

  • Environment.animate_fields raises TypeError for an option that no backend accepts, naming the closest option, instead of ignoring it: a misspelled frame_label= used to change nothing. fps= raises too (use speed=). The unused contrast_limits, show_colorbar and colorbar_label options are removed.

  • Environment.from_polar_egocentric refuses an angle_range wider than one turn (2π) with a ValueError that 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) raise NotImplementedError. 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 example Environment.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 -1 and add no occupancy. A track you already have, including one saved by an earlier version and read with Environment.from_file, from_dict or from_nwb, takes its width with the new env.with_track_width(10.0), which keeps its bins; the error message shows that call. linear_to_nd and graph queries on bin indices do not need the width. to_linear does not either, because it projects every position onto the nearest point of the centerline, however far off the track: drop off-track samples (bin -1 from bin_at on a track with a width) before using linear positions. The simulated mazes in neurospatial.simulation.mazes with 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, and tmaze_alternation_session builds 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 Environment written by an earlier version can no longer be loaded. Pickle still works for passing an environment between processes of the same version (for example with multiprocessing), but it is not a storage format. An environment state from another version raises a TypeError that says to use files instead. Save with env.to_file(...) or NWB export and reload with Environment.from_file(...).

  • Old import paths are removed. The compatibility modules neurospatial.environment.transforms, ._coordinates, ._metadata, ._ownership and ._from_bins, the former private geometry modules under neurospatial.layout, the private names re-exported by neurospatial.arena and neurospatial.environment._HAS_SHAPELY are gone (shapely is a required dependency). The modules that implement Environment methods are private: neurospatial.environment.fields, .queries, .metrics, .trajectory, .regions, .visualization, .polar, .cartesian and .factories are now _fields, _queries and so on, and neurospatial.environment.serialization is removed. Call the Environment methods (for example env.smooth, env.occupancy, env.to_file) instead, and import BinSequenceWithRuns from neurospatial.environment. The public arena classes in neurospatial.arena are unchanged.

  • neurospatial.ops.binning is renamed neurospatial.ops.mapping, so it is no longer confused with neurospatial.binning, which holds the Binning records. It maps points to bins, regions to bin masks and fields between environments. Import regions_to_mask, resample_field and TieBreakStrategy from neurospatial.ops or neurospatial.ops.mapping.

  • Environment is 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 returns False without an error, so a check such as if env == "session1": silently takes the other branch. Write env.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) and copy.deepcopy(env) return the same object. Use with_metadata, with_regions, subset or rebin to get a changed environment.

  • EnvironmentNotFittedError (error code E1004), the check_fitted decorator and the neurospatial.environment.decorators module are removed. An environment always has its bins and connectivity; calling Environment() directly still raises E1006. Passing something that is not an environment to an analysis function raises TypeError naming 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 behind bin_at on tracks, triangular meshes and clipped environments. On a track, bin_at and nearest_bin share one KD-tree. The kdtree=, kernels= and cached_properties= options and neurospatial.ops.binning.clear_kdtree_cache are removed; call env.clear_cache() instead.

  • Environment.to_linear() and Environment.linear_to_nd() raise TypeError instead of AttributeError when the environment is not a linearized track. The message is unchanged.

  • Egocentric polar environments (Environment.from_polar_egocentric) report env.layout_type == "Polar" instead of "MaskedGrid", and their printed summary says Polar. 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, test env.coordinate_kind == "polar" instead of the layout name.

  • A polar environment takes the choice of whether its angle wraps around only from its stored EgocentricSurround arena. Old files (Environment-v2 files 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 the Binning the 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, the dimension_ranges the bins cover. An engine needs only build(...) and binning(): the environment derives its connectivity graph, grid_edges, grid_shape, active_mask and dimension_ranges from the Binning and no longer reads an engine's connectivity, dimension_ranges or grid attributes, so attributes an engine adds to its own graph do not reach env.connectivity. Lookup and faces come only from binning(), so to change them, change what binning() returns. Two labels are read when an engine has them: its layout_type label, and the build arguments recorded with neurospatial.layout.base.capture_build_params (env.layout_parameters). All built-in engines implement binning(), a subclass of a built-in engine inherits a working one, and LayoutEngine lists the method. Objects without binning() still need an installed snapshot adapter. The private neurospatial.layout._capabilities module (RectilinearGridProvider) is removed; read layout.binning().rectilinear.

  • env.connectivity is 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, and env.connectivity.graph is an empty read-only mapping. Reading an attribute returns the stored value without a copy; editing any of them is refused. env.to_networkx() and env.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() and clip() keep the named regions (they used to drop them), and subset() keeps the name. rebin() keeps the name (no _rebinned suffix). apply_transform() keeps the name (no _transformed suffix) and no longer invents a frame: the new frame is the frame= you pass, or None.

  • env.layout_parameters (and the layout_parameters record in files, dicts and NWB) holds only the scalar build choices, such as bin_size, connect_diagonal_neighbors or circular_angle. Input data is no longer kept: the positions given to from_samples, the masks of from_grid_mask, from_pixel_mask and polar environments, polygons, the track graph and edge order of from_graph, and nested values such as dimension_ranges (read env.dimension_ranges). A saved environment no longer grows with the length of the trajectory it was built from (a 100,000-sample from_samples file 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 from env.binning.linearization.graph (and .edge_order).

  • Factories take their data positionally and every option by keyword:

  • Environment.from_samples(positions, bin_size=2.0) and Environment.open_field(positions, bin_size=2.0), not from_samples(positions, 2.0). Their signatures list every option, so a misspelled option raises TypeError instead of reaching the layout engine.
  • Environment.from_graph(graph, edge_order, bin_size=2.0). edge_spacing was a required positional argument before bin_size; it is now a keyword that defaults to 0.0 (edges end to end), as in linear_track and maze.
  • Environment.maze(kind="plus", node_positions=..., bin_size=...).
  • Environment.from_polygon(polygon, bin_size=2.0) and Environment.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_field and neurospatial.io.nwb.environment_from_position refuse 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 raises ValueError with layout="Hexagonal". With infer_active_bins=False every bin in the extent is active, so changing bin_count_threshold, dilate, fill_holes or close_gaps from its default raises ValueError. 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_holes defaults to True in open_field). open_field also accepts fill_holes=False with layout="Hexagonal" or infer_active_bins=False, because neither fills holes. A layout= other than "RegularGrid" or "Hexagonal" raises ValueError listing the supported layouts and the factory to use instead; it used to raise NotImplementedError. Errors from environment_from_position name that function.

  • Environment.from_layout takes 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 raises TypeError with the corrected call. layout_type and layout_parameters come 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_nwb only loads an environment saved in the file: Environment.from_nwb(nwbfile, name="linear_track"). The record name is name= (it was scratch_name=), matching env.to_nwb(nwbfile, name) and read_environment, and defaults to "spatial_environment". To build an environment from the file's Position data, call neurospatial.io.nwb.environment_from_position(nwbfile, bin_size=...) instead of from_nwb(nwbfile, bin_size=...). It takes bin_size as a keyword and lists every from_samples option (name=, regions=, arena=, layout=, fill_holes= and so on) instead of forwarding **kwargs. from_nwb(nwbfile, bin_size=...) raises a TypeError that shows that call. When the file has no saved environment of that name, from_nwb and read_environment raise a KeyError that lists the saved environments and, if the file has Position data, names it and points to environment_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=False to count only centers strictly inside. The rule is shared by bins_in_region, region_mask, region_membership, distance_to with a region name, subset(region_names=...), subset(polygon=...) and neurospatial.ops.mapping.regions_to_mask, so they agree on every layout. Before, they disagreed:

  • region_membership() and regions_to_mask() gave a point region no bins (regions_to_mask also warned). They now give the bin that contains the point. Time spent at a point region, and visits, crossings and laps found through regions_to_mask, now count that bin.
  • bins_in_region(), subset(region_names=...) and subset(polygon=...) left out bins whose centers lie exactly on a polygon's edge, while region_mask included them. All of them now include those bins. bins_in_region and subset take include_boundary=False for the old interior-only selection (subset refuses it together with mask=). Analyses built on bins_in_region can change when a polygon edge passes through bin centers: the decision-region entry time in decision_region_entry_time, compute_decision_analysis and compute_vte_session, the goal bins and decision boundary of compute_decision_analysis, the start and goal bins of trials_to_region_arrays, and the region boundary of neurospatial.events.distance_to_boundary(region_name=...).
  • subset(region_names=[...]) keeps the bin that contains a point region instead of raising ValueError.
  • 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 a ValueError that says so.
  • On egocentric polar environments a point region is read in the same (distance, angle) coordinates as bin_centers; bins_in_region and region_mask used to refuse point regions there.
  • A polygon region on an environment whose bins are not 2-D raises ValueError with a fix in region_membership and regions_to_mask; it used to raise NotImplementedError.

  • Graph and distance queries take their target by keyword: bins= for bin indices, points= for coordinates and, for distance_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, and env.neighbors([0, 1]) treated bins 0 and 1 as the point (0, 1).

  • env.distance_to(targets) is env.distance_to(bins=...), env.distance_to(points=...) or env.distance_to(region="reward").
  • env.neighbors(i), env.reachable_from(i, ...) and env.rings(center_bin=i, ...) take bins=i or one point, points=[[x, y]].
  • env.path_between(a, b) is env.path_between(bins=[a, b]) or env.path_between(points=[[x0, y0], [x1, y1]]).
  • env.distance_between(p1, p2) is env.distance_between(points=[p1, p2]) or env.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_boundary and goal_reward_field. Floats and booleans raise TypeError instead of being truncated to bin indices, and out-of-range bins raise BinIndexOutOfRangeError (a ValueError). -1, the value bin_at gives a position outside the environment, is never accepted as a single target, whether it is an int or a one-element array such as env.bin_at([[x, y]]): target_bins=-1 and goal_bins=-1 used to return all-NaN distances and now raise. Only an array with one entry per sample (shape (len(position_bins),), as built by trials_to_region_arrays) is per sample, and there -1 still marks a sample with no target and gives NaN; an array of any other length raises ValueError. path_progress also 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 with env.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 raise ValueError naming the point. So compute_path_efficiency and subgoal_efficiency with metric="geodesic" raise that ValueError when the trajectory starts outside the environment. geodesic_voronoi_labels and distance_to_decision_boundary raise ValueError for an empty goal list; distance_to_decision_boundary used to return inf for every sample.

  • Environment.subset(bins=mask) is now env.subset(mask=mask), so bins= means integer bin indices everywhere. mask= is still a boolean array with one entry per bin. Integer values in mask= raise ValueError that 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 now True and dir(polar_env) lists subset, rebin, interpolate and the other Cartesian-only methods; calling one raises CartesianOnlyError. CartesianOnlyError is now a TypeError (the operation does not apply to this kind of environment) and no longer an AttributeError or NotImplementedError; import it as neurospatial.CartesianOnlyError. Replace hasattr checks with env.coordinate_kind == "polar", and except (AttributeError, NotImplementedError) with except 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 as spatial_autocorrelation and field_shape_metrics, compute_egocentric_distance(metric="geodesic") (it reported Environment.bin_at()), and Environment.combine (it raised a plain TypeError). A function that takes two environments names the polar one, for example resample_field(dst_env=...). Where a polar environment has an equivalent, the message ends with the code to run instead, also available as error.example. The suggested code keeps containment: rows outside the environment get bin -1 (or NaN from interpolate), never the nearest edge bin.

  • bin_at, contains, occupancy, bin_sequence, bin_sequence_with_runs and transitions(times=..., positions=...) work on egocentric polar environments, where they used to raise CartesianOnlyError. Each row is (distance, angle) and gets the bin whose cell holds it, or -1 outside the distance and angle ranges, the same lookup the points= queries use. occupancy gives 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_OPERATIONS no longer lists these methods. Analysis functions that read positions as the animal's allocentric positions and relied on bin_at to 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 of compute_step_lengths, mean_square_displacement and decoding_error, the distance_to_reward and distance_to_boundary event regressors, simulate_trajectory_ou (and so simulate_session), and the boundary, object-vector, spatial-view and geodesic place-cell models.

  • Three neurospatial.ops functions that only renamed an Environment method are removed; call the method instead. map_points_to_bins(env, points, ...) is env.nearest_bin(points, ...), compute_differential_operator(env) is env.get_differential_operator(), and apply_transform_to_environment(env, transform, ...) is env.apply_transform(transform, ...). The arguments and results are the same, with one difference: map_points_to_bins refused polar environments, and env.nearest_bin accepts them.

  • neurospatial.layout.validation.validate_environment is 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 example Environment.from_samples(positions, bin_size=2.0, units="cm", frame="room"), or later with env.with_metadata(units=..., frame=...).

  • The built-in layout engines no longer have point_to_bin_index, finite_volume_geometry or is_grid_compatible, and the LayoutEngine protocol no longer lists the first and last. An environment never used them: it locates points and builds cell faces from the engine's binning(). Use env.bin_at(points), or engine.binning().locator.locate(points) on a built engine; engine.binning().faces.build() for the cell faces; and binning.rectilinear is not None to ask whether the bins form a rectangular grid.

  • A custom layout engine that records its build arguments with neurospatial.layout.base.capture_build_params declares its input-data arguments in the decorator: @capture_build_params(data=("positions",)). They are left out of env.layout_parameters. Any other argument that is not a scalar or a flat sequence of scalars now raises a TypeError naming 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_parameters attribute, and their recorded choices are unchanged. When a saved environment's layout_parameters hold 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 with POLAR and COMBINED) is the only vocabulary: a built-in engine's layout_type is its LayoutType value (RegularGridLayout().layout_type == "RegularGrid", it was the category "grid"), env.binning.kind holds the same label, and env.layout_type reports it. Engine categories such as "grid" or "mask", the private _layout_type_tag and the layout_category field of saved files are gone. User spellings are reported canonically: from_samples(..., layout="regular-grid") gives env.layout_type == "RegularGrid" (it gave "regular-grid"), and create_layout also accepts "grid", "polygon" and "mesh". The new neurospatial.layout.find_layout_type(name) returns the LayoutType a spelling names, or None. from_bins spells a Binning.kind that names a layout canonically. It refuses "Polar", which is reserved for from_polar_egocentric, and "Combined" on bins that are not joined components of combine. 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); combine reports "Combined" (it was "combined"). The new env.derivations lists the derivations applied, oldest first, for example ("subset", "rebin"). layout_parameters keeps the parent's build choices through subset, clip and apply_transform (they used to be replaced by {"source": ..., "original_n_bins": ...} or gain "transformed": True), and rebin(bin_size=...) records bin_size and connect_diagonal_neighbors as rebin(factor) does. Files from earlier versions load with the canonical label and their derivation markers as derivations. A subclass of a built-in engine that sets its own layout_type reports it everywhere; it used to report the parent's label as env.layout_type.

  • Assigning or deleting a public attribute of an Environment raises an AttributeError that 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 by env.name.

  • Building from any layout engine, including a custom one, and rebin now check that each bin center is located in its own bin, as from_bins, subset and the readers already did. The built-in engines pass; a custom engine whose binning fails the check is refused with a ValueError.

  • GridCellModel(grid_orientation=θ) (and simulate_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 orientation grid_properties reports, 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 given grid_orientation are rotated by π/6. To reproduce an earlier simulation, pass grid_orientation=old + np.pi / 6.

  • Grid orientation values change units and frame. grid_properties().orientation (and SpatialRateResult.grid_properties()) read the autocorrelogram as an image, with its first axis as y, but spatial_autocorrelation puts 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_std is in radians too. peak_coords columns are now offsets along x then y, as documented; they used to come out y first, so code that read peak_coords[:, 0] as the x offset was reading y. Convert old orientation values with np.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 with GridCellModel(grid_orientation=θ) is reported with orientation θ.

  • Environment method options are keyword-only, as factory options already were. bandwidth is keyword-only in smooth, diffuse, compute_kernel, compute_gaussian_kernel and diffusion_accuracy: write env.smooth(rate, bandwidth=5.0), not env.smooth(rate, 5.0). In plot, plot_1d and plot_field, everything after the field and ax is keyword-only (env.plot_field(rate, ax, cmap="magma")), and to_nwb takes name= by keyword, as from_nwb does. A positional call raises a TypeError that names the keyword and shows the corrected call, as the factories do: env.smooth(rate, 5.0) suggests env.smooth(field, bandwidth=5.0). env.subset(bins=mask) says to pass mask=. 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. Pass overlays=[PositionOverlay(positions=..., times=..., trail_length=...)] instead.

  • The coordinate argument of bin_at, contains, nearest_bin, to_linear and interpolate is named positions, the name analysis functions use (it was points_nd, points or nd_position). Only a call that passed it by keyword changes: env.bin_at(points_nd=xy) becomes env.bin_at(xy) or env.bin_at(positions=xy). The points= query target of distance_to, neighbors and the other graph queries is unchanged.

  • A nonzero edge_spacing on a track with one edge (for example Environment.linear_track(endpoints=..., edge_spacing=5.0)) raises ValueError. A gap goes between consecutive edges, so the value used to be recorded in layout_parameters and ignored. Remove it, or use node_positions with 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=...) or plot_regions(region_names=...) raises RegionNotFoundError with the closest name, before anything is drawn. These names used to be skipped, plot_regions with a warning, so a region silently went missing from the animation, the table or the plot. Fix the name. A single string, such as show_regions="goal", now means that one region; it used to be read letter by letter.

  • graph_turn_sequence(env, position_bins) no longer takes start_bin and end_bin. They were documented as orienting the path but were never used: the order of position_bins already 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 connectivity graph (for example create_layout("RegularGrid", ...).connectivity) is now made from the engine's binning() when you first read it, so it is the same graph as env.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's binning() instead. Reading it before build() raises LayoutNotBuiltError instead of returning None. Its attributes now match env.connectivity: on a track, edge vector and angle_2d point from the lower to the higher bin (along a segment they pointed the other way), source_grid_flat_index counts the gap cells of a track with edge_spacing > 0, a 3-D track has no angle_2d, the angular-seam edges of a polar grid number their edge_id by pair row, and values are Python numbers instead of NumPy scalars. Neighbor pairs, their order and their distances are unchanged.

  • neurospatial.GraphValidationError and neurospatial.layout.validation.validate_connectivity_graph are 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 raises ValueError. Code that caught GraphValidationError can catch ValueError, its parent class.

  • Decoding error is measured along the environment by default. DecodingResult.error_against, decoding_error, median_decoding_error and BayesianDecoder.score default to metric="geodesic" (distance= for score): 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. Pass metric="euclidean" for the straight-line error. decoding_error and median_decoding_error now take the environment first, like confusion_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_speed is in position units per second, so its 5 cm/s default was 5 m/s for positions in meters (every sample excluded; goal_bias returned 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 require min_speed; pass min_speed=5.0 for the old behavior with positions in cm. compute_decision_analysis, compute_goal_directed_metrics and compute_vte_session take min_speed=None, meaning 5 cm/s in env.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_bin returns the nearest bin for every finite point, however far away, unless you pass max_distance or max_distance_factor. Before, a point more than 10 typical bin spacings from every center got -1 with no limit given; code that fed that -1 back as an index read the last bin (geodesic decoding error did), and nearest interpolation dropped points deep inside a long cell. Use bin_at() or contains() to ask whether a point is inside the environment, or pass max_distance_factor=10.0 for the old cutoff. resample_field keeps 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_at gave -1 and 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 by subset(), holes and positions beyond the grid stay outside. Applies to rectangular grids (from_polygon, and from_samples/open_field with an arena=); time_allocation="linear" still splits paths by the exact cells. rebin therefore no longer reports fine bins in perimeter coarse cells as lost.

  • simulate_trajectory_laps and simulate_session(trajectory_method="laps") refuse four inputs they used to accept (see Fixed): a custom outbound_path or inbound_path whose consecutive bins are not neighbors in env.connectivity; the default speed_mean on an environment whose units are unset or not a metric length (simulate_trajectory_laps only; pass speed_mean=); an environment whose bins at the smallest and largest x are not connected, where there is no default path (pass outbound_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 an Environment.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; Escape keeps napari's normal finish/cancel behavior. Save Project and Ctrl/Cmd+S save 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 (its null_scores became writable again after copying), CircularBasisResult (which kept the caller's angles array, so editing it changed the stored angles but not the sine and cosine; it is now also frozen), ViewshedResult, PlaceFieldsResult and PopulationCoverageResult. PlaceFieldsResult.fields and PopulationCoverageResult.place_fields are now tuples, so fields cannot be appended to or replaced; copy with list(...) to edit.

  • compute_directional_place_fields leaves out frames labeled None, NaN or missing_label, and records missing_label on the result and in summary(). Before, None and NaN labels raised TypeError.

  • animate_fields color 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. Pass vmin and vmax to keep the old range or to compare animations on one scale. estimate_colormap_range_from_subset uses the same rule on its sample of frames (its percentile is 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 unused rendering.compute_global_colormap_range is 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_regions now defaults to True; show_walls=False and show_landmarks=False hide 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 animation extra 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, whose border_color names 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. New neurospatial.behavior.compute_speed returns it. It takes break_before to declare recording joins on one clock, never smooths across a declared break, a lost frame, a gap longer than max_gap or an epochs edge, and needs strictly increasing, non-empty times. Every min_speed gate 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/s min_speed 98-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_velocity had 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 by smooth_window= (seconds, default 0.1), and segment_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.histogram includes its right edge. Before, bins were half-open on every face, so bin_at gave -1 there 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 in Environment.combine a face shared by two components keeps its half-open owner.

  • segment_trials starts 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 least min_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_time and trial durations change accordingly.

  • The neurospatial.layout.LayoutEngine protocol lists only build() and binning(), the two methods an environment uses. A custom engine needs nothing else, and type checkers no longer ask it for bin_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() and with_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 as env.bin_at(positions) instead of to_linear. They used to answer with the Cartesian-coordinates refusal. The error is still a TypeError, but no longer a CartesianOnlyError, 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 as distance_to, geodesic compute_egocentric_distance). It names the operation, the shape given and the shape needed, for example Environment.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 a ValueError; only the wording changed.

  • Environment.from_bins(binning) is annotated as taking a Binning. 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 as typing.cast(Binning, obj) there.

  • nearest_bin on 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_distance and 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 Environment method that returns bin indices returns np.intp, the dtype NumPy uses for indexing, as bin_at already did. bin_sequence, bin_sequence_with_runs(...).bins, components and rings returned int32, and nearest_bin was declared int64; all are now np.intp (64-bit on 64-bit systems). The values are unchanged. neighbors and path_between still return lists of Python ints.

  • env.smooth takes the same field layout as env.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 with Fix: pass field.T (or fields.T) instead of a bare shape error, and passing a SpatialRateResult or SpatialRatesResult raises TypeError naming 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 positions array of shape (n_dims, n_samples) in from_samples or open_field raised Exceeds the limit (4300 digits) for integer string conversion; it now says the grid would need too many bins and to pass positions.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 pass arena=OpenArena(polygon);
  • Environment.from_bins(env.binning) shows the two lines that build the faces first;
  • env.rebin(1.5) raised TypeError: 'float' object is not iterable; a factor that is not a whole number now raises ValueError suggesting rebin(2) or rebin(bin_size=...) with the matching size;
  • env.apply_transform(np.eye(3)) raised AttributeError; a matrix now raises TypeError saying 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=)) raised KeyError: 'pos'; it now names the node and how to set pos;
  • a bin_size sequence whose length differs from the number of position columns, or any sequence with layout="Hexagonal", names the expected length instead of an internal argument;
  • dimension_ranges that is not one finite (min, max) pair per position column raises ValueError naming 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;
  • rebin on 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, or env.smooth(field, bandwidth=...) for a coarser scale. A grid with unequal cells says so;
  • rebin factors that are not integers (including 2.0), are 0 or larger than the grid, or have the wrong length; a 3-D transform passed to apply_transform on 2-D bins; three endpoints for linear_track (use node_positions); bare numbers in maze(node_positions=...); an edge_spacing sequence with the wrong number of gaps; bin_size as text; a list of points passed to from_polygon; grid_edges that do not fit the from_grid_mask mask; and a list of names passed to region_membership all 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_field or environment_from_position is treated as a missing sample, like NaN, which these factories already leave out. It used to raise Requested binning overflows (grids) or OverflowError (hexagons).

  • Every lookup of a region by name raises RegionNotFoundError with the closest existing name, for example Fix: pass region_name='reward' for env.bins_in_region("rewrd"). This covers env.regions[name], bins_in_region, subset(region_names=...), the Regions editing methods (remove, update_region, region_center), region_reward_field, compute_region_coverage, decision_region_entry_time and compute_decision_analysis, which used to raise a bare KeyError or a ValueError without a suggestion. RegionNotFoundError is both a KeyError and a ValueError, so existing except blocks still catch it.

  • spatial_autocorrelation raises NotImplementedError instead of ValueError on 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 caught ValueError around it should catch NotImplementedError; see the migration page. Rate-map grid_score() and summary_table() still report NaN there.

  • env.occupancy(..., time_allocation="linear") on bins without cell shapes (bins passed to Environment.from_bins with only centers and sizes) raises NotImplementedError instead of a ValueError about locators, and its message, like the one for tracks, says why linear allocation needs each bin's outline and points to time_allocation="start".

  • On a track, bin_at and nearest_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_at used 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 TypeError naming 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_dict and NWB export refuse them with a ValueError that now names the attribute.

  • On a polar environment, wall geometry (border_score, view-cell rates with walls, boundary_source=...) and time_allocation="linear" rate maps raise CartesianOnlyError instead of NotImplementedError, like every other operation that reads polar bins as Cartesian positions. Wall geometry on a 3-D environment still raises NotImplementedError. The error reference page lists the two error types for environments that cannot run an analysis.

  • The linear track of simulation.mazes.make_plus_maze numbers its arms in the order they are laid out along the linear position, clockwise from north (north 0, east 1, south 2, west 3), as Environment.maze(kind="plus") does. East and south used to have each other's number in the source_edge_id of each bin and the edge_id of the track graph. Bin positions, linear positions and every analysis are unchanged. The simulated T and plus mazes are now built with Environment.maze.

  • env.plot() names the axes of a 2-D environment by what the coordinates mean, as env.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_sources and env.get_bridge_attributes() give the answers of any environment that was not combined: an empty tuple, None and an empty table. They used to raise CartesianOnlyError, although they read only the records that Environment.combine keeps and never the coordinates.

  • env.occupancy, env.bin_sequence, env.bin_sequence_with_runs and env.transitions(times=..., positions=...) warn when the max_gap gate and the other interval gates exclude every interval, and when the median sampling interval is longer than max_gap, which usually means times are not in seconds (milliseconds turn every frame into a recording gap). occupancy also warns when no interval keeps any time because every sample is NaN or outside, with either time_allocation. They used to return an empty or mostly empty result silently. The same warning comes from compute_spatial_rate(s) and the behavior analyses that share the gate, which now also warn about a median interval longer than max_gap when some intervals survive.

  • env.smooth and env.diffuse with mode="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 and resample_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 from smooth's. mode="transition" and mode="density" refuse a field with NaN, because missing bins have no defensible mass under a conserving operator: fill them explicitly, or use mode="average" for an intensive field. diffuse(backend="jax") refuses a concrete field with NaN and points to backend="numpy". Before, smooth refused any NaN and diffuse silently turned every bin NaN. Fields without NaN give exactly the same values as before.

  • compute_decision_analysis represents each goal region by the region bin nearest the centroid of the region's bins, the same bin trials_to_region_arrays uses. 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 and distance_to_boundary change for goal regions that cover more than one bin.

  • env.connectivity is 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_id and, in 2-D, angle_2d), plus pos_1D and source_edge_id on 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 by env.to_networkx().

  • Environment.from_bins on a binning with a triangular-mesh locator numbers each node's source_grid_flat_index and original_grid_nd_index by its triangle, as built mesh environments do and as env.grid_shape and env.active_mask describe; it used to give the bin ID. On a binning with a track linearization, from_bins now adds each node's pos_1D (linear position) and source_edge_id (track-graph edge), as track factories do.

  • env.with_metadata(...) and env.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, diffuse and get_mrf_basis calls on one environment and its relabelled copies run one at a time, because each holds a lock on the shared cache for the whole call; use worker processes for parallel smoothing.

  • A factory given an arena= that leaves out some bins (for example from_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, but layout_type and layout_parameters now describe the factory's layout (for example "MaskedGrid" with connect_diagonal_neighbors) whether or not the arena dropped a bin; they used to switch to a subset record.

  • dimension_ranges is the extent of the bins' cells for every layout, and Environment.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_transform reports 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 in grid_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 read grid_edges and grid_shape, with each pixel centered on its cell (overlays used to be stretched by up to half a pixel). Readers of dimension_ranges change with it on hexagonal, mesh, track and grid-subset environments: plot limits (a hexagonal env.plot() is framed by its cells' extent, the same limits as env.plot_field(), with no extra margin), env.info(), the automatic scale of ops.spatial_basis, the "bandwidth spans the environment" warning, the uniform field placement of simulate_session, and the extent animation overlays and an uncalibrated video are drawn over. The same applies to the bins an arena= removes in Environment.from_layout, from_grid_mask and from_pixel_mask. For Environment.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 CartesianOnlyError from a Cartesian-only method or property, such as subset, rebin or interpolate, names the class of the environment that refused the call. On a subclass of Environment, polar_env.interpolate(field, points) reports MyEnvironment.interpolate() does not work on a polar environment., for the bound call and for Environment.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 Environment class. Use env.coordinate_kind for coordinate meaning and Environment.from_polar_egocentric(...) instead of the removed EgocentricPolarEnvironment.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 -1 rather 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 schema 3.0. They preserve Arena, walls, exact cells and clip mappings without rebuilding layouts. Version 2/2.0 remains readable with an explicitly unknown Arena. Version 1/1.x must 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_sizes now returns an owned, read-only array, like bin_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.smooth now defaults to mode="average", preserving the units and level of a rate map. Pass mode="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). Pass max_mean_rate=10.0 to 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() and to_dataframe() return editable copies; editing a to_xarray() Dataset used to edit the result.
  • interpolate(mode="linear") and occupancy(time_allocation="linear") work on environments built with from_polygon, from_grid_mask and from_pixel_mask, and on grid environments read from NWB. They previously required a RegularGrid layout. Inactive cells behave as on masked from_samples environments.
  • Polar environments no longer define the twelve Cartesian methods. Accessing one raises CartesianOnlyError, both a NotImplementedError and an AttributeError, with the same message. hasattr reports 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_mask and grid_edges reject writes, and connectivity is 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, rebin or apply_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_angle build 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_kernels rejects directed graphs and multigraphs, whose reciprocal or parallel edges were summed into doubled conductances. Collapse them first with nx.Graph(graph).

  • Extend rate-result analysis_settings and spatial_support to 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 -n unless 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; constant method is no longer a summary column, and labels are documented as heuristic screens.

  • Breaking: population rate summaries name n_units and max_peak_firing_rate explicitly. 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, NWBHeadDirection and NWBUnits holders. 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 SimulationSession with keyword-only unit_ids selection 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 SimulationSession attributes with analysis inputs: spike_times, integer unit_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, and decode_session(_summary). Session decoding always encodes tracking and no longer accepts encoding_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) in approach_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_occupancy and generate_population_spikes. Place env first in compute_region_coverage, field_shape_metrics, rate_map_coherence, map_points_to_bins, shuffle_place_fields_circular_2d and calibrate_video. Rename the spike parameters on bin_spikes_in_time, population_peri_event_histogram, restrict_spike_trains and validate_simulation to spike_times. Analysis entry points take explicit arrays rather than a PositionLike adapter.

  • Breaking: rename the phase-precession callable to compute_phase_precession, preserving the sibling encoding.phase_precession module for normal imports.

  • Breaking: rename place-field detection to has_place_field; is_place_cell now 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 spatial classify(min_spatial_info=) to min_info, and propagate invalid-input errors.

  • Breaking: default ObjectVectorCellModel to allocentric direction tuning without headings, which changes simulated spikes, without an error, for code that relied on heading-relative tuning. Set direction_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 ObjectVectorRateResult and ObjectVectorRatesResult, and their information accessor to spatial_information(). Removed names have no aliases; indexed population results preserve their frame.

  • Environment.combine, and subset and 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.py launches CPU or optional SAM 2 assisted annotation on a sample arena, image, or video frame. A --check mode runs sample inference without opening a viewer.

  • Resumable polygon annotation projects: AnnotationProject and AnnotationShape save 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_video accepts project_path= and autosave_interval= (30 seconds by default, None to disable); annotate_project resumes without the original video. AnnotationResult.calibration returns the calibration the outputs use, including one applied in the editor, to map pixel tracking into the environment. Both entry points and AnnotationProject.save refuse to replace an existing file other than the one being resumed unless overwrite=True, and refuse a project_path whose folder does not exist. annotate_video checks 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 RegionGrowProvider uses CPU color segmentation, and its preview can be painted or erased before acceptance; while painting, Ctrl/Cmd+Z undoes brush strokes. SegmentationPrompt, SegmentationProposal, SegmentationProvider and mask_to_polygons also work without a viewer, preserving disconnected areas and holes. Optional SegmentAnythingProvider.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 two SpatialRatesResult: units are paired by unit_ids (unit_selection="intersection" or "exact", as in compare_spatial_rates), generated row-position ids are refused, bins are paired by place (different environments need bin_mapping=, and topology= is checked), and only bins observed in both conditions are compared. It warns, like compare_spatial_rates, when the conditions were estimated with different method, bandwidth or min_occupancy. Plain (n_units, n_bins) arrays are also accepted; then the rows must be the same units in the same order, and spatial_support must 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 wherever max_gap is: 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, the is_*_cell predicates and *_cell_significance tests, 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_runs and transitions, neurospatial.events.add_positions and neurospatial.simulation.generate_population_spikes. A declared break is treated exactly like a gap longer than max_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 to epochs by hand (touching windows merge, so that never worked anyway). Rate results and fitted decoders record the break times in analysis_settings["break_times"] (None when 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 each AssemblyPattern, and pattern.member_unit_ids names the core members. assembly_activation(..., unit_ids=) and reactivation_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's pairwise_correlations came 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 a BinMapping (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. sum carries counts, occupancy and probability mass with NaN kept as missing, rates pools rates from summed counts and occupancy (rates are never averaged), and category keeps a label only where all merged bins share it. mapping.pairs is the bin_mapping= that compare_spatial_rates and population_vector_correlation take. These functions and bin_mapping_from now 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 a LabelEpochsResult mapping each label to its windows. Pass conditions[label] as epochs= 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 are valid, no more than max_gap apart, inside one epochs window and not across a break_before; a dropout or gap ends an epoch, so the same label afterwards starts a new one. None, NaN and missing_label mark unlabeled frames. durations gives the time per label and epochs_table() one row per epoch; input_epochs records the epochs= 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 a RateComparisonResult. Units are paired by unit_ids, never by row order; population maps computed without unit_ids= are refused. Each unit is scored only over its own common support: bins occupied for at least each map's min_occupancy in both conditions where both rates are finite, so a smoothed rate in an unvisited bin never counts. A unit with fewer than min_common_bins (default 3) such bins, or a map that is flat over them, gets NaN and a status saying why, never 0.0. Maps estimated on different environments raise IncompatibleEnvironmentError; equal bin counts are not enough. A bin_mapping must 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) needs topology="may_differ", which the result records. Different estimator settings between the maps raise a warning. The result gives correlation, n_common_bins, status, rate_difference (B - A, Hz, NaN outside common support), summary_table(), to_dataframe(), to_xarray(), summary() and plot(); 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 labeled None. 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 a ContinuousEventResult. A value is taken only where the signal was observed: never across a gap longer than max_gap, a frame marked in break_before, an epochs edge 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 an overlapping flag. retain_aligned=False keeps only the summaries, computed in bounded memory. The result exports events_table(), aligned_table(), time_course_table() and category_counts_table(), plus summary() and plot(); 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; fields is (n_features, n_bins)) at the animal's positions, and sample_egocentric_features(env, fields, positions, headings, ego_points) reads them at points fixed in the animal's frame (x ahead, y left). Both return a SpatialFeatureResult with values, per-sample support and the containing bins. Lookup is by containment only: a position outside the environment, a NaN position or heading, or a NaN map value gives NaN with support False, and nothing is smoothed or interpolated. Environments whose layout cannot tell whether a position is inside a bin (a track built without track_width, a linearized 1-D track) raise NotImplementedError instead of assigning the nearest bin.
  • heading_from_velocity takes break_before, like compute_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. Touching epochs rows merge into one window and do not separate recordings.
  • compute_cumulative_distance(times, positions, *, max_gap, epochs, break_before) returns a CumulativeDistanceResult: 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, and total_distance, which is NaN when nothing was observed. Lost frames (NaN rows), time gaps longer than max_gap, intervals outside epochs and frames marked in break_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 every spacing along each run, so routes from sessions with different running speeds and frame rates can be compared sample by sample. The ResampledPathResult keeps 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, and break_before marks run starts for passing the resampled path on with max_gap=None. The trajectory guide compares two sessions filmed at 30 and 120 Hz.
  • compute_planar_turn_angles, compute_planar_curvature and compute_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 and NaN next 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" for env.plot, env.plot_field and env.animate_fields draws 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_fields returns a FastplotlibAnimation, a fastplotlib NDWidget with 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_graph or a 1-D environment) the fields are also drawn as position along the track against time, over window_seconds around 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. SpikeRasterOverlay draws 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 with rates=, 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 own times move at their own sampling rate, so slow fields (a rate map per lap) do not freeze the animal or the camera between frames. EpochOverlay marks 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's window_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, and track= (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. plot and plot_field keep Matplotlib as the default and return an Axes for it; with backend="fastplotlib" they return the fastplotlib figure, and Matplotlib-only options raise a TypeError.

  • annotate_video draws 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.walls and result.landmarks hold 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 and env.landmarks.near(gaze, "monitor", tolerance=2) tells whether projected gaze lands on it. subset, clip and rebin keep them, apply_transform moves them, combine prefixes their names, and to_dict, to_file and NWB save them. The object-vector and egocentric encoding functions accept landmark names as object_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 in analysis_settings["objects"].

  • Environment.combine joins components whose bins measure different things, such as a corridor built with linear_track and 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: a bridge_conductances entry for one, or a positive face between such bins passed to from_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_bins builds the lazy face recipe that env.binning carries 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 pass wall_names=). arena.wall_names lists 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_rate and compute_boundary_vector_rates estimate firing by distance and direction to the walls, for boundary-vector cells (direction_frame="allocentric") and egocentric boundary cells (direction_frame="egocentric" with headings). 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, BoundaryVectorRateResult and BoundaryVectorRatesResult, have preferred_distance(s), preferred_direction(s), spatial_information, plot and the shared summary_table, to_dataframe and to_xarray exports. 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_system says what it is: kind is "cartesian" or "polar", circular says whether the angle axis wraps around (always False for Cartesian coordinates), and periodic_axes lists 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 its EgocentricSurround arena (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 without track_width, such as one saved by an earlier version, which refuses to look up 2-D positions: after Environment.from_file(path).with_track_width(10.0), positions more than 5 cm from the centerline get bin -1 and rate maps, occupancy and decoding work again. It adds "with_track_width" to env.derivations and refuses an environment that is not a track.

  • The cell-shape records in neurospatial.binning (HexagonShapes, TriangleShapes, PolygonCells) have extent(), the (low, high) bounds of their cells per axis, and subset(keep), the cells selected by a boolean mask of one entry per bin, in their original order. These are the same rules env.dimension_ranges and env.subset() use, so explicit bins built for Environment.from_bins() can be bounded and subset the same way.

  • neurospatial.ops.KernelMode names the values mode= accepts in Environment.compute_kernel, smooth and diffuse ("transition", "density", "average"), for type annotations in your own analysis code.

  • neurospatial.layout.ScaleBarConfig is the scale-bar settings class that Environment.plot(scale_bar=...) accepts. It is the same class as neurospatial.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 of spatial_autocorrelation. Pass it as bin_size to grid_scale and grid_properties.

  • Environment.bin_center_tree() returns the KD-tree over bin_centers that nearest_bin searches, built once and kept in the analysis cache. Use it for the k nearest bins with their distances. map_probabilities, select_basis_centers(method="kmeans"), heat_kernel_wavelet_basis and the simulated place cells now search this tree instead of building their own, with identical results; repeated map_probabilities calls onto a 160,000-bin grid take 28 ms instead of 38 ms.

  • neurospatial.environment.BinSequenceWithRuns, the result type of Environment.bin_sequence_with_runs(), is importable from a public path for type annotations and isinstance checks.

  • Environment.from_layout accepts your own layout engine after its build(...), with no registration. The engine can be a plain class with only build(...) and binning(). Subclasses of built-in engines, which used to be refused, now build environments normally.

  • Binning has a dimension_ranges field: the (low, high) extent of each axis the bins' cells cover. env.binning.dimension_ranges equals env.dimension_ranges, so Environment.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 the Binning.

  • subset() and rebin() accept name= like clip() and apply_transform().

  • from_polygon, from_grid_mask, from_pixel_mask, from_graph, linear_track and maze accept regions=, so every Cartesian factory takes the same name=, units=, frame= and regions= keywords. open_field accepts regions= and arena=, and from_samples and open_field accept dimension_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 (None for 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_points accepts weight=, the edge attribute that measures length ("distance" by default; None counts edges). Environment.distance_between now calls it, so the two always agree.

  • Expose detached, read-only env.binning snapshots 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 independent geometry/ 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 FaceNormals data to Binning, 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 owned GridBinningSpec separate 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.combine for 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: remove CompositeEnvironment; use Environment.combine({"key": env, ...}) and the ordinary environment APIs. Overlapping or uncertifiable support and incompatible declarations raise.

  • Add Environment.with_metadata(...) and with_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 standalone Regions builder, then publish it with with_regions. Environment snapshots own caller data and protect geometry, provenance, regions and cached numerical exports through copy and persistence.

  • Add Environment.from_bins(...) with public neurospatial.binning records, explicit lookup and explicit face availability. env.edge_pairs preserves 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 SnapshotAdapter objects in the versioned neurospatial.snapshot_adapters.v1 entry-point group, plus neurospatial.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-parent clip_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 with error_against, preserving tracking gaps and missing observations without manual coordinate conversions.

  • Add plot_peri_event_response for 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_visits and region_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_indices identifying 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 use None for 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=...) on SpikeTrains and every population result (spatial, directional, view, object-vector and peri-event). [] selects by position on all of them, so spikes[i] and rates[i] are the same neuron. A label passed to [], or a position out of range, raises an error that names the sel call to use. Breaking: SpikeTrains[...] used to look up a label; replace st[unit_id] with st.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=...). Without unit_ids, spike trains pair with models by position. Breaking: the BayesianDecoder constructor now takes configuration only. encoding_models, unit_ids, spatial_support, spike_window and analysis_settings are read-only attributes, set by fit, from_rates or from_models; replace BayesianDecoder(env, encoding_models=m, unit_ids=ids) with BayesianDecoder.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) with replace now raises; construct and fit a new decoder. Prediction defaults remain replaceable. from_rates uses the rate result's method, bandwidth and recorded encoding settings.
  • Add Environment.get_divergence_operator(), the cached finite-volume divergence operator that divergence(env, edge_field) applies, alongside get_differential_operator().
  • Preserve spatial analysis_settings through population indexing, tables, xarray/NetCDF and NWB schema 2.4, including selected/observed windows, gap, speed and occupancy gates. Expose spatial_support on rate results and fitted decoders. Legacy results identify unavailable settings as None.
  • 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_intervals and their shared acquisition spike_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 SpatialRatesResult with BayesianDecoder.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_cell for heading-relative tuning. Breaking: is_object_vector_cell now measures allocentric direction and no longer accepts headings; call is_egocentric_object_vector_cell for 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 required direction_frame on singular and population results.

Performance

  • Dense diffusion kernels (env.compute_kernel, compute_diffusion_kernels and 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_field from 1,000 sources on a 10,404-bin grid takes 14 ms instead of 0.9 s; geodesic compute_egocentric_distance to moving targets (20,000 samples) takes 1.5 s instead of 55 s; geodesic neighbors_within for 463 centers takes 25 ms instead of 0.2 s and returns each neighborhood sorted. Distances are bit-identical, and geodesic decoding_error shares 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_reduce and random-walk transitions included) reads the graph through one adjacency builder, so all of them refuse a graph whose nodes are not the bins 0 .. n_bins - 1 or whose edges lack the distance attribute, instead of silently counting edges. They also refuse a directed graph or a multigraph: distance_field ignored edge direction while neighbors_within followed 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, and linear_to_nd gathers 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_score on 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() and label_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_score reuses the same cached geometry across calls on one environment.

  • Shuffle tests and other repeated diffusion_kde maps no longer repeat the environment's eigendecomposition on every call when the bandwidth is small for the arena. env.diffuse now solves that full basis once and keeps it for later calls at any bandwidth, with identical results. On a 2,500-bin open field at bandwidth=5, each extra shuffle in place_cell_significance for 10 cells takes about 35 ms instead of 2 s. The basis takes 8 * n_bins**2 bytes (50 MB for 2,500 bins) until env.clear_cache(). method="glm" fits that need the full smoothness basis (a rank of 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 Environment no longer imports matplotlib or pandas; 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_rate call after with_metadata(name=...) takes 0.51 s instead of 0.88 s.

  • Reading graph attributes of env.connectivity is about 5 times faster, and Dijkstra shortest paths on env.connectivity about 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") and neurospatial.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 default allow_teleports=False checks 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_bin and interpolate(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.

  • subset and apply_transform reuse 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 grid subset takes 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_mask takes 0.48 s instead of 4.4 s and subset 0.48 s instead of 3.2 s; Environment.from_samples with 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 (zone becomes zone_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 through to_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 from Environment.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_rates and read_place_field(env=...) emitted it while comparing environments, and a later smoothing call then did not warn; subset, gradients and Environment.combine() (at construction) emitted it too.

  • simulate_trajectory_laps simulates a running animal: the default speed is 20 cm/s expressed in the environment's units (speed_mean=None; environments without metric units need speed_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 default speed_mean=0.1 was 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 with duration, such as linear_track_session and tmaze_alternation_session, snapped the same way: 96 % of the running steps of linear_track_session(duration=60, n_laps=6) were exactly zero. With duration, a speed_mean you pass now gets speed_std=0.2 * speed_mean by 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_transform keeps the linear position (pos_1D) and track segment (source_edge_id) of every bin in get_bin_attributes() and env.connectivity. Before, rounding in the moved segment lengths dropped both for about a third of rotation angles, and simulate_trajectory_laps on such a track raised KeyError: 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 raises ValueError on 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, and compute_spatial_rate on it failed with TypeError: '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 in summary_table and label_cell_types) scores circular and other curved or diagonal arenas built with from_polygon. With boundary_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, as mode="nearest" does there. Points outside the environment still read NaN.

  • Geodesic decoding_error (and error_against) measures a position far outside the environment from its own nearest bin. The nearest-bin lookup's distance cutoff returned -1 for it, which indexed the last bin: a decode 1000 cm left of a row of bins was measured from its rightmost bin.

  • env.nearest_bin checks the number of coordinates before skipping missing rows. An all-NaN array with the wrong number of columns returned -1 for every row instead of naming the shape mistake, and a finite one failed with a SciPy error.

  • env.smooth and env.diffuse refuse an infinite bandwidth, as env.compute_kernel already did. bandwidth=np.inf had 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 although env.contains was 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_field and distance_to_boundary(boundary_type="region")) raise a ValueError when 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 refusal env.distance_to(region=...) already gave.

  • Every named region of the built-in mazes in neurospatial.simulation.mazes now 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 linearized env_track carries 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, ObjectVectorOverlay and VideoOverlay raise CartesianOnlyError on 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. PositionOverlay and EventOverlay still draw (distance, angle) rows.

  • The multi-session workflow in the user guide compared rate maps with map_probabilities(..., source_probabilities=...), which raised TypeError (the parameter is source_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 shows resample_field for maps that already have different bins. The example runs in the documentation tests. The alignment guide's map_probabilities examples use the right keyword, and its docstring says to use resample_field for rate maps.

  • Environment.arena and Environment.clip_mapping return detached copies, as Environment.binning does. 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.contains then raised IndexError.

  • On an egocentric polar environment, the edge vector and angle_2d in get_edge_attributes(), connectivity and to_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 used bin_at, which returns -1 for 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.

  • PlaceCellModel and ObjectVectorCellModel with metric="geodesic" give a NaN rate for a NaN position, as metric="euclidean" does. Before, the missing position took the rate at bin 0.

  • A sample outside the environment (bin -1) is in no bin in trajectory_similarity, compute_home_range and detect_goal_directed_runs. Before, -1 indexed the last bin: trajectory_similarity scored an outside sample as the last bin (Hausdorff, DTW) or as a place both trajectories shared (Jaccard), compute_home_range returned -1 as a home-range bin and counted outside time in the total, and detect_goal_directed_runs failed with NodeNotFound: Node -1. Now trajectory_similarity compares only places inside the environment and refuses a trajectory with none, compute_home_range leaves outside samples out, and detect_goal_directed_runs ends 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 with Environment.from_file(path). Before, a pickle from the previous release loaded and then failed on its first subset, apply_transform, rebin or to_dict with an AttributeError. 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, so bin_at, bin_sequence, occupancy(time_allocation="start") and spike counts could disagree with occupancy(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 with Environment.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 with clip has 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 of env.rebin(bin_size=...). Each built-in layout engine now names the option that sets its cell size (size_parameter, such as pixel_size or hexagon_width), and the suggestion reads that option. Bins from a custom engine no longer get a size guessed from a recorded bin_size.

  • After apply_transform scales the bins, a refused rebin(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 at bin_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 as length_scale; files saved earlier give no size after a transform.

  • After apply_transform, a track with a known width still says so. The rebin refusal on such a track suggested an Environment.from_graph(...) rebuild without track_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 of time_allocation="linear" on a transformed track also names the track instead of "this arena".

  • apply_transform accepts 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_fields warns once when you pass an option the chosen backend ignores, such as crf= with backend="html" or overwrite=True with napari, naming the backend that uses it and the call that would. With backend="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, and crf= reaches the encoder. Environment.animate_fields used to send bitrate=5000 on every call, which replaced constant quality, so crf= 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 both crf= and bitrate= warns that the bitrate wins and crf is ignored.

  • Environment.animate_fields(..., dry_run=True) writes nothing on every backend and returns None after reporting what it would render. The HTML backend ignored dry_run and wrote animation.html into the working directory; napari opened a viewer and the widget displayed. The HTML backend now also draws scale_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 shapely Polygon; it was annotated as taking the Polygon class itself. from_file, from_dict and from_nwb (and neurospatial.io.from_file, from_dict and neurospatial.io.nwb.read_environment) keep the static type Environment[Any], because the stored data decides whether the environment is Cartesian or polar; their docstrings now say so and point to env.coordinate_kind. The neurospatial.io.nwb functions, which load lazily, now have their declared types instead of Any.

  • The presets Environment.open_field, Environment.linear_track and Environment.maze are easy to find: the from_samples docstring, 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 undefined data, polygon and graph variables), and the quickstart's example peak rate (about 12 Hz) matches what the code prints.

  • The Environment docstring examples run in the documentation tests. The skipped ones now execute on fixed example data, so they show real output: from_dict compares the round trip with np.array_equal(env.bin_centers, reconstructed.bin_centers) (it asserted env == reconstructed, which is False now that environments compare by identity), and to_linear, subset, interpolate, smooth, transitions and to_nwb show their results. n_dims, from_polygon, plot_1d and linearization_properties no longer document errors or keys that do not exist. Only the napari and video examples of animate_fields stay skipped, because they need a display and ffmpeg.

  • simulate_trajectory_sinusoidal runs 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)), so width (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-axis width on a polar environment raises CartesianOnlyError with code that passes one width.

  • neurospatial.testing.assert_environment_contract works on a track built without track_width: it checks that position lookups refuse with the fix that names track_width, instead of failing on the refusal. A track with a width gets the full lookup checks against expected_bins.

  • ops.spatial_basis sizes 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_sequence labelled 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_parameters reports only options that shaped the bins. Environment.open_field(..., infer_active_bins=False) recorded fill_holes=True, its default, although every bin is active and nothing was filled; it now records fill_holes=False, and bin_count_threshold, dilate and close_gaps are recorded as 0/False whenever 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 grid rebin derives from it, so it records connect_diagonal_neighbors=False whatever was passed; it used to record the default True. The bins are unchanged. A 2-D grid one cell wide keeps the choice, because refining it creates diagonal neighbors.

  • Environment.from_graph, linear_track and maze accept a NumPy scalar edge_spacing (for example np.float32(2.0) read from a config array) as they accept a Python number. It used to raise IndexError or TypeError.

  • spatial_autocorrelation accepted 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 raises NotImplementedError on 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 names spatial_autocorrelation_radial, which works on every layout. Tracks, meshes and sparse grids raised ValueError before. Rate-map summary_table() reports a NaN grid_score on hexagonal environments.

  • spatial_autocorrelation reported lags in cell steps. On a grid whose two axes have different cell widths (a from_polygon grid of a 50 × 37 cm box with bin_size=3 has 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 with from_samples differ 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 through Environment.from_bins) has no single lag per step and raises the same NotImplementedError, naming spatial_autocorrelation_radial.

  • Grid scores change on every grid. grid_score rotated the autocorrelogram about the array center, (n - 1) / 2, while zero lag sits at n // 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 by grid_scale and grid_properties were 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 random from_samples arenas; 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 beyond n_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 same n_peaks nearest peaks as the scale and is within about 1-2°.

  • SpatialRateResult.grid_properties() converted pixels to distance with the smallest cell area, so its scale was in the wrong units (off by a factor of the cell width, e.g. 4× for 4 cm cells). It now uses autocorrelation_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_bin gives it bin -1 and distance NaN, and env.interpolate gives it NaN; both used to raise, nearest_bin with a SciPy error about finite values. A point with an infinite coordinate is outside: bin -1 and distance inf, or NaN from interpolate. env.interpolate also 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 precomputed bins= sequence that holds -1 is still refused.

  • Track environments (from_graph, linear_track, maze) store each connectivity edge's vector and angle_2d from the lower to the higher bin ID, as every other layout does. They pointed the other way, so env.get_edge_attributes() reported track vectors from target to source. On a track with gaps between segments (edge_spacing > 0), each node's source_grid_flat_index and original_grid_nd_index now name its cell of the linear grid described by env.grid_shape and env.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_id of each connectivity edge is its row in env.edge_pairs on 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 after subset(), used to keep IDs that did not match their rows.

  • env.boundary_bins on a hexagonal environment checks all six neighbors of each hexagon, so every hexagon next to a hole, a concave notch or a subset() 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 with boundary_source="analysis" and BoundaryCellModel firing 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 a TypeError about multiple values for fill_holes.

  • NWB reading and writing (Environment.from_nwb, env.to_nwb and the neurospatial.io.nwb functions) report "pynwb is required" only when pynwb itself is not installed. Any other import failure, such as a missing or broken pynwb dependency like hdmf, 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_crossings and dataframe_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 with Environment.from_bins without grid_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 later rebin(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: fit kind="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 rebin modes 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 at factor=2, and 56% of a 6-by-6 arena at bin_size=4.

  • rebin warns 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 in from_polygon at 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 a mean_position behind it, not straight ahead. This covers mean_position, DecodingResult.mean_position and to_dataframe(), and decode_position_summary; a centroid at the animal has a NaN angle.

  • Domain functions that read bin centers as Cartesian (x, y) coordinates now raise CartesianOnlyError on polar environments instead of returning wrong numbers. The refusals cover field_shape_metrics, field_shift_distance, spatial_autocorrelation, resample_field, map_probabilities, spatial_basis, graph_turn_sequence and detect_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 default center_method of the basis functions), path_progress and distance_to_region with metric="euclidean", trajectory_similarity with "hausdorff" or "dtw", and detect_runs_between_regions with min_speed.

  • Environment.from_bins and 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 use boundary_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 example OpenArena(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.day1 and rat1.day2 produces independent file pairs. Explicit .json and .npz paths remain accepted.

  • Preserve physical diffusion faces on grids with small coordinate units; exactly nonzero axis displacements no longer disappear below an absolute 1e-9 cutoff. 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_against now returns NaN for truth outside observed runs instead of clamping to endpoints or interpolating across dropouts. Plot overlays use the same max_gap rule; 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 hsv to 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_runs evaluates one candidate per observed sample run, and that success means sufficient directed progress rather than arrival. Point completed-traversal analyses to detect_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. SpikeTrains keeps read-only copies of its trains and labels, so editing the caller's labels can no longer create duplicate unit IDs. Every result's unit_ids is a read-only copy. A BayesianDecoder keeps read-only copies of its rate maps, labels and training support, so editing rates.firing_rates after BayesianDecoder.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, SpikeTrains or a pynapple TsGroup. This covers every population encoder and significance test, population_peri_event_histogram, decode_session, decode_session_summary and the BayesianDecoder methods. Previously, passing a plain list or SpikeTrains to BayesianDecoder.fit failed type checking.
  • Predicting with an unfitted BayesianDecoder now explains that fit() returns a new decoder and shows the fix, decoder = decoder.fit(spike_times, times, positions). The old message told users who had already called fit() 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_field accepts a matplotlib Colormap as 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() and linear_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_to refusal on polar environments no longer prints ()().
  • Type checkers accept calls such as env.smooth(field, bandwidth) and polar_env.to_dict() on Environment and EgocentricPolarEnvironment; mypy previously reported "Invalid self argument".
  • Polar boundary_bins treats 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_session no longer permutes rows of its environment's bin_centers while shuffling grid-cell phases; the returned environment's centers matched neither its graph nor bin_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_dict or read_environment on their layout. Gradients and divergence work on 4-connected grids read from NWB, and copy() 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_information now computes discrete mutual information from a joint position-response histogram in bits per observation. Old rate-map calls raise migration guidance; use information_per_second for 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. Older 2.x files remain readable with their existing defaults.
  • Return NaN path 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 turns 1 into 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_significance and egocentric_object_vector_cell_significance build 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 an inf or a divide-by-zero warning.
  • Kinematic speeds from 1-D (linear-track) positions are the track speed. pre_decision_speed_stats previously broadcast a 1-D difference against the time steps and returned an unrelated number.
  • Breaking: Environment.bin_center_of raises BinIndexOutOfRangeError for 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's IndexError.
  • DecodingSummary.plot breaks 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.
  • SimulationSession checks that times and positions have one row per sample and that ground_truth is keyed by exactly the unit_ids, so relabeling units with dataclasses.replace must re-key ground_truth too. Duplicate unit_ids are rejected, since two cells sharing a label would both be validated against one ground truth. NWBUnits checks that unit_ids and obs_intervals align with spike_times.
  • ObjectVectorRateResult and ObjectVectorRatesResult reject a direction_frame other than "allocentric" or "egocentric"; plots previously drew any other value as egocentric.
  • spatial_view_cell_significance computes the gaze geometry once per call instead of once per shuffle. With gaze_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 equals min_occupancy, as every other rate path does, and warn when min_occupancy masks every occupied bin.
  • ShuffleTestResult stores null_scores as 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 per env bin and that times has one entry per posterior row, instead of silently mapping columns to the wrong bin centers. The times checks that to_xarray() used to apply now fire at construction.
  • Smoothing, gradient, divergence and heat_kernel_wavelet_basis on 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) raise ValueError when the speed, gap, window and min_occupancy gates leave no occupied bin, instead of returning a model that decodes every time bin as a uniform posterior.
  • read_units reads files in which a unit has no obs_intervals rows (a unit that was never observed); that unit's coverage is an empty (0, 2) array, so the shared spike_window is empty unless it is excluded with unit_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_times keyword 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 SimulationSession constructor 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.mazes at 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.encoding while assembly and reactivation tools live in neurospatial.decoding. Its examples run as tests on simulated recordings with planted answers.
  • compute_turn_angles and compute_trajectory_curvature describe 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; pass positions[:, :2]. compute_turn_angles states 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_curvature states that it returns turning angles in radians per frame, not geometric curvature; use compute_planar_curvature for 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_session and neurospatial.recording, plus the unused PositionLike trajectory adapter and validate_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_size keyword shim and both view-result peak_view_location methods. Use pixel_size and the shared peak_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, SpikeOverlay and 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_cells and detect_ovcs methods; use label_cell_types or classify.

  • Remove the deprecated region-crossing positional order. Use detect_region_crossings(position_bins, times, env, region_name="home"); region_name is 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-test markers, 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_regions and handle missing names without a conversion error. Zero-rate posterior errors distinguish all-negative-infinity likelihoods from corrupt NaN inputs.

  • Warn visibly when bin_size covers every data axis or a peri-event window exceeds 60 seconds, with units and corrected values. Large-grid warnings are now UserWarning instead of hidden ResourceWarning; the existing hard memory ceiling is unchanged. Length-1 bin-index arrays explain how to unwrap bin_at output 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 RegionNotFoundError with the caller's region argument and available names; NWB readers and direction-label lookups name their corrected arguments.

  • Replace HOW: labels with Fix: 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 raises TypeError with a corrected call.

  • Add NeurospatialError as the common base for library exceptions while retaining their standard Python bases. RegionNotFoundError also supports except ValueError and prints an unquoted Fix: line with a suggested region name.

  • Breaking: raise BinIndexOutOfRangeError for graph-query bin indices (formerly IndexError; it subclasses ValueError, not IndexError, so except IndexError no longer catches it), LayoutNotBuiltError for unbuilt layouts, and IncompatibleEnvironmentError for 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.5 and epochs=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, and events.add_positions. Pre-decision and VTE composites use their existing window keywords for kinematics too. Examples and notebook pairs now pass timestamps to heading_from_velocity.
  • Speed statistics, approach rates, direction labels, head sweeps, trajectory curvature and home-range dwell respect max_gap and epochs; 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 scalar dt, 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_positions accepts max_gap and epochs, 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.5 and epochs=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, and Environment.bin_sequence, bin_sequence_with_runs, transitions. Use max_gap=None to disable the gap gate for intentionally coarse sampling; epochs still applies.
  • Position-only analyses warn when max_gap or epochs exclude every interval, for example tracking sampled more slowly than 2 Hz under the default max_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_gap and epochs. 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_gap and epochs and 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_crossings accepts max_gap and epochs, 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_sequence and bin_sequence_with_runs accept max_gap and epochs, 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. Empirical transitions gates 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-9 epsilon.
  • Conflicting epochs and explicit bounds in bin_spikes_in_time report the argument conflict with separate Why: and Fix: lines.
  • Single-unit and population peri-event histograms accept epochs and spike_window, keeping only events whose entire analysis window is observed. Means and SEM use retained events, and results, child results, plots and summaries carry n_events_dropped. With neither window supplied, all events remain eligible; partial drops add no warning.
  • Full and streamed decoding results record spike_window and spike_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-only epochs and spike_window. Training masks the original arrays instead of concatenating epoch slices, preserving aligned speed samples and unit identities. predict, predict_summary, and score forward the same window keywords and the decoder's configured max_gap to observed-run decoding.
  • decode_session and its streamed summary accept epochs and spike_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_time accepts epochs= 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 absolute 1e-9 epsilon (which lost a bin near 1e9 seconds). 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 epochs and spike_window diagnostics, including combined argument problems and Why:/Fix: guidance, instead of silently classifying malformed inputs as False. Each analysis still normalizes the windows once.
  • Directional, view and egocentric encoders record the normalized spike_window on 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, and compute_egocentric_rates share the population-silence heuristic: with no spike_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, and is_object_vector_cell accept keyword-only max_gap, epochs, and spike_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 epochs in milliseconds) or when more than half of the spikes fall outside the tracked time (for example spike_times in 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.5 excludes recording pauses instead of charging them to one bin, and their normalized epochs and spike_window keywords restrict the same intervals on both sides. Behavior change: a spike at times[-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: and Fix: together, without hiding invalid values in the other time-window argument.
  • compute_spatial_rates warns once when at least five units are all silent for at least 60 seconds of continuously tracked time and no spike_window was 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_window and the read-only spike_window_assumed flag in summary(). 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, and restrict_spike_trains share 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 (naming np.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, and epochs=None leaves data unrestricted. Point membership still uses the requested closed= setting on the normalized windows.
  • Spatial rate functions, is_place_cell, and compute_directional_place_fields accept epochs= and spike_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.occupancy accepts epochs= 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_stats now excludes samples below min_speed using 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_alignment now returns NaN for samples below min_speed, and goal_bias averages moving samples only. On an east/stop/north path, all 30 stopped samples are excluded and goal_bias is 0.5055 (formerly 0.5862 from interpolated stationary headings). Fully stationary trajectories still return NaN without raising.

  • Behavior change: heading_from_velocity and heading_from_body_orientation interpolate 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_basis now uses the finite-volume diffusion generator, scaled by squared bin spacing. On uniform Cartesian grids its standard deviation is sqrt(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 raise NotImplementedError with a fix. chebyshev_filter_basis keeps its graph-hop locality and distance-weighted, spectrally rescaled operator.

  • Behavior change: gradient, divergence, and compute_differential_operator now use the finite-volume cell geometry of env.smooth: gradient divides by edge length, and divergence weights face flux by cell volume with positive values at sources. For f=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 raise NotImplementedError with a fix instead of returning physically inconsistent derivatives.

  • decode_position (and every decoder built on normalize_to_posterior) floored the prior at 1e-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 posterior 0.999998). Zero-prior bins now get exactly zero posterior, small positive priors are no longer floored, and the handle_degenerate="uniform" fallback spreads mass only over bins with positive prior (NaN when the prior has none).
  • theta_phase used 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_correlation and circular_basis_metrics computed p-values as 1 - cdf, which cancels to exactly 0.0 for strong effects (below about 1e-16). They now use survival functions, so a strong effect reports its true tiny p-value (for example exp(-500) for a perfect circular-linear correlation over 1000 samples).
  • Environment.from_grid_mask (and every MaskedGridLayout, including env.subset) accepted nonuniform, decreasing, duplicate or non-finite grid_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 raises ValueError naming 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_summary and score passed spike trains to the encoding models by position even when both the fit and the predict input were labelled, so a reordered or different pynapple TsGroup silently paired each unit's spikes with another unit's model. When both the fit (a labelled group, or unit_ids= at construction) and the predict input carry labels, trains are now matched by label in any order, and a label mismatch raises ValueError listing the missing and unexpected labels. Otherwise trains are still paired by position, and a unit-count mismatch now raises a ValueError that says so (previously a Poisson likelihood shape error). A predict input that repeats a label raises.
  • compute_directional_rates, compute_view_rates and compute_egocentric_rates mis-read a pynapple TsGroup (it was coerced as one train, giving one unit labelled 0). They now accept it like compute_spatial_rates: one rate map per unit, with the group's index as unit_ids.
  • Behavior change: passing unit_ids= together with a labelled TsGroup silently replaced the group's labels, so in compute_spatial_rates a group keyed [10, 20] with unit_ids=[20, 10] attached unit 10's spikes to label
  • All four population encoders now raise ValueError listing both label sets unless unit_ids equals the group's index.
  • Behavior change: repeated unit_ids were accepted (for example compute_spatial_rates(..., unit_ids=[5, 5])). Every place that takes unit_ids now raises ValueError naming the repeated labels: the population encoders, population_peri_event_histogram, the BayesianDecoder constructor and fit (fitted labels) and predict input labels, and population result constructors (including summary_table(unit_ids=) relabelling). SpikeTrains already rejected them.
  • Behavior change: to_pynapple passed unsorted timestamps to pynapple, which sorts the times without reordering the values, so to_pynapple([0, 2, 1], [10, 20, 30]) returned t=[0, 1, 2], d=[10, 20, 30] (each value paired with another sample's time). It now raises ValueError for unsorted, repeated or non-finite timestamps, naming the first offending indices, and for a columns list whose length does not match the value columns (pynapple silently renamed them 0, 1, ...). These checks run before pynapple is imported.
  • DirectionalRatesResult.to_xarray() wrote attrs["bandwidth"] = None for unsmoothed results (bandwidth=None), so Dataset.to_netcdf() raised TypeError. The attribute is now omitted when no smoothing was applied, as for spatial results.
  • read_environment estimated 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_edges came back None, and every edge's direction vector was 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: DecodingResult kept a reference to the caller's posterior and cached map_estimate and the other derived properties, so editing that array in place (or reassigning result.posterior) left the cached values stale (for example a MAP of bin 0 while posterior.argmax was bin 1). DecodingResult is now frozen, and its constructor stores read-only copies of posterior and times. Use dataclasses.replace(result, posterior=new) for a modified result. decode_position hands over its freshly computed posterior without a copy.
  • Behavior change: to_linear returned wrong positions on graph environments whose edge_order differs from graph.edges() order, such as Environment.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 with bin_at. track_linearization looks a point's nearest segment up by its edge_id attribute but finds it by its position in graph.edges(). Graph layouts now linearize a copy numbered in graph.edges() order, so to_linear returns along-track distance for from_graph, maze, linear_track and reloaded environments. Any edge_id on 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_tuning and plot_circular_basis_tuning drew 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_tuning drew +π/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_direction and read_pose returned an NWB series' stored values and ignored its conversion and offset, which NWB defines as the map to unit (data * conversion + offset). A pixel series stored 0-500 with unit="meters", conversion=0.002, offset=0.1 came back as 0-500 instead of 0.1-1.1 m, and environment_from_position built a 500 × 500 environment. The readers now return values in the series' unit (head direction converts before the degree-to-radian step), and lazy=True raises ValueError for a series whose scaling is not the identity rather than returning unconverted handles. The NWB overlays inherit the fix. environment_from_position maps NWB unit names to the Environment.units registry, so pynwb's default "meters" becomes "m" (no unrecognized-units warning), and likewise cm, mm and px.
  • Behavior change: environment_from_position silently set env.units = "cm" when the position series declared no unit (unit=""). It now emits a UserWarning naming the series and saying to pass units=, then still assumes "cm". Passing units= skips the lookup and the warning.
  • Behavior change: population_peri_event_histogram raised AxisError: axis -1 is out of bounds for a pynapple TsGroup, 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 as unit_ids. A unit_ids= passed with a labelled group must equal its index, or the call raises ValueError listing both.
  • Behavior change: compute_vte_session cut 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, and window_start reports the clipped start. A trial left with fewer than 3 samples before its entry is skipped, now with a UserWarning (it was skipped silently). compute_vte_trial is unchanged.
  • Behavior change: method="diffusion_kde" (the default for compute_spatial_rate(s) and smooth_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), but bin_sizes holds 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 it simulate_session, open_field_session, linear_track_session and tmaze_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 is bin_size on a regular grid): 6 cm at 2 cm bins, 15 cm at 5 cm bins. A one-bin environment now raises ValueError asking for width=. Likewise validate_simulation's default max_center_error is 2 × the bin spacing (4 cm at 2 cm bins, previously 8 cm).
  • Behavior change: method="binned" with bandwidth > 0 had 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 as diffusion_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) and rank (requested basis rank; out-of-range values are clamped, not rejected, to the effective rank reported via result.rank). These are mutually exclusive with the ratio-method params bandwidth / min_occupancy / fill_value, which now default to None (resolving to their historical 5.0 / 0.0 / preserve-NaN behavior). Combining a param with the wrong method raises ValueError.
  • New result fields on SpatialRateResult / SpatialRatesResult: coefficients, penalty, penalty_weights, rank, deviance, converged, n_iter, reml_objective (all None for the ratio methods). bandwidth is None for 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-only method set (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_field now persist a glm SpatialRatesResult field-for-field: the scalars penalty / rank / n_iter / converged / reml_objective and the (rank,) penalty_weights vector in the table's JSON metadata blob, and deviance (n_units,) plus coefficients (stored (n_units, rank), transposed back to (rank, n_units) on read) as per-unit table columns. bandwidth is stored nullable (None for glm). Ratio-method results round-trip unchanged, with the GAM fields None. The spatial-rates schema version bumps 2.0 → 2.1 (glm-diagnostics addition); ratio tables are byte-identical to 2.0 apart from the version string. This removes the earlier NotImplementedError write_spatial_rates raised 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, and BayesianDecoder accept method="glm" and forward the glm knobs penalty / rank into the encoding step. bandwidth and min_occupancy are now nullable on all three (default None, resolving to the encoder defaults 5.0 / 0.0 for 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 with method="glm" (or a glm param with a ratio method, or an out-of-domain penalty / rank) raises the identical ValueError at 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 penalty the float32 fit matches the float64 core to ~1e-6 on 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-3 in 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=False runs 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_boundary widen to (n_units,) and a new penalty_selected_by_reml (n_units,) bool mask records provenance. A single-unit compute_spatial_rate call unwraps these back to scalars. summary_table() surfaces them; indexing a population result slices them.
  • Fixed penalty beats pooled. Supplying penalty=<float> skips REML and records one scalar λ regardless of pooled. pooled is likewise a no-op at penalty_rank == 0 (a shared-basis property, so λ stays a scalar None) 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, flagged penalty_selected_by_reml=False with reml_objective=nan (a documented sentinel); their field floors to the rate floor.
  • Boundary diagnostic. The new reml_at_boundary field (scalar for pooled=True, (n_units,) for pooled=False, None when REML did not run) flags a λ selected near a REML search bound (within 5·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 their unit_ids); the applied λ is the finite value returned near the bound (the interval is never expanded).
  • Validation. pooled is a strict bool (rejects "true" / 1 / 0 / arrays / None) and glm-only (pooled=False with a ratio method raises; pooled=True with one is the harmless default).
  • NWB. The spatial-rates schema bumps 2.1 → 2.2: pooled and a scalar reml_at_boundary join the GAM metadata blob, and the per-unit penalty / reml_objective / reml_at_boundary / penalty_selected_by_reml vectors are persisted as table columns when present. A schema-2.1 file with no pooled key reads back method-conditionally — pooled=True for a glm table, pooled=None for 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 → method on compute_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, and simulation.validate_simulation. Each encoder keeps its existing method value set and default.
  • Renamed field smoothing_method → method on SpatialRateResult, SpatialRatesResult, ViewRateResult, and ViewRatesResult.
  • NWB: write_spatial_rates now stores the estimator under the metadata key "method" (was "smoothing_method"); the spatial-rates schema version bumps to 2.0. This is a clean break with no back-compatibility shim — spatial-rates tables written by earlier versions (schema 1.x) no longer read.
  • decode_session_summary: because method now 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

env.diffuse(fields, bandwidth, *, mode="density", backend="numpy")

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.diffuse is a pure linear operator — no output clip, no renormalization. The always-retained per-component null mode makes mass conservation exact under truncation (mode="transition" preserves sum; mode="average" preserves a constant field), so smoothing a signed field is preserved. Positivity, where required, is the consumer's job.
  • env.smooth now routes through env.diffuse and gains a documented approximation contract: it reproduces the dense kernel to within a near-lossless truncation tolerance (dropped modes contribute ≤ tol (default 1e-6) in the M-weighted norm; the raw per-bin error carries a volume-conditioning factor κ(M) = sqrt(max vol / min vol), worst on polar r→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 to jnp), so jit / grad / GPU work through the smoothing. The JAX diffusion_kde encoders no longer round-trip through NumPy.
  • The diffusion_kde, binned, and diffuse-resample_field consumers route through env.diffuse; strict > 0 support 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 spurious NaN. diffusion_kde clips 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) with W[i, j] = A[i, j] / d[i, j] (A = shared-face measure, d = center distance) and M = 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, scale bandwidth by roughly √(bin_size) (rough, mode-dependent — prefer re-choosing bandwidth as a physical σ in your units).
  • Correct orientation per consumer. compute_kernel(mode="transition") returns Hᵀ (column-stochastic, mass-conserving smoothing of extensive quantities); mode="density" returns H·M⁻¹ (count → density, integrates to 1 under bin volumes); transitions(method="diffusion") now returns the row-stochastic H via transpose (correct on non-uniform bin volumes — polar, mesh — where it no longer assumes symmetry).
  • Breaking: public neurospatial.ops.compute_diffusion_kernels signature. The old Gaussian-weight form compute_diffusion_kernels(graph, bandwidth_sigma, bin_sizes=..., mode=...) is replaced by compute_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, and A == 0 means no diffusion across that edge. mode now also accepts "average" (row-stochastic H) at the low level.
  • min_occupancy caveat. min_occupancy is documented in seconds, but the KDE paths compare it to occupancy_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_edges inherit the uniform-cell approximation and are excluded from the physical-σ guarantee; performance (dense expm) is unchanged.

Added — mode="average" intensive-field smoother

  • New public mode="average" on Environment.compute_kernel and Environment.smooth (and the low-level compute_diffusion_kernels): the row-stochastic heat operator H that averages an intensive field (a rate map or probability density). Unlike density (H·M⁻¹), it carries no cell-volume bias on non-uniform bin volumes (polar, mesh), and unlike transition it is the correct choice for a rate map rather than a total. Discrete probability mass (a posterior summing to 1) still uses transition. env.smooth's default stays density (unchanged behavior for existing mode-less calls); average is recommended for intensive rate maps.
  • smooth_rate_map(method="binned") and resample_field(method="diffuse") now smooth their intensive fields through the row-stochastic average (masked / valid-bin-normalized), removing the volume bias on non-uniform M. For resample_field, this also fixes a down-bias where covered bins adjacent to an uncovered region were pulled toward zero, and makes a source NaN interpolate 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) recommended binned to avoid the dense (n_bins, n_bins) kernel, but binned smooths its rate map through the same diffusion kernel (via env.smooth), so all smoothing methods build a dense kernel. The only memory mitigation is reducing the bin count (a larger bin_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_positions is now add_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, and reactivation_strength now use the same time-first count convention as decode_position and bin_spikes_in_time, so a binned count matrix feeds them directly. Migration: pass the default bin_spikes_in_time(...) output as-is, or transpose an existing (n_neurons, n_time_bins) matrix.
  • Behavior result summary() returns a dict of scalar metrics (was a formatted string) on every behavior result class. Migration: use str(result) for the human-readable form.
  • Fail-loud instead of silent sentinels. bin_at on a graph/track environment raises on wrong-dimension points (was all -1); heading_from_velocity raises when every sample is below min_speed (opt out with allow_all_nan=True); graph queries reject a multi-point coordinate batch.

Added

  • units= and frame= 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, and reachable_from map a coordinate via bin_at (integers are still treated as indices).
  • ResultMixin on every result class — concise __repr__/_repr_html_ (summary metrics, not array dumps) and a scalar summary() dict.
  • dir(neurospatial.ops) now surfaces the lazily-exported ops (autocomplete).

Fixed

  • min_occupancy thresholds raw occupancy seconds, not smoothed density — fixes silent all-zero place fields on the documented golden path; applied identically across diffusion_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 in histogramdd/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_file refuses to overwrite by default (overwrite=True to allow); occupancy() checks shape before monotonicity (clear swapped-argument error); from_graph validates the edge distance attribute.
  • SpatialRateResult.plot() documents the real colorbar/colorbar_label kwargs and labels the colorbar "Firing Rate (Hz)"; batch plot() 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-visible styles, 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 viridis in 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 by decode_session / decode_session_summary / BayesianDecoder.fit) gained an optional context parameter that names the caller in its up-front timestamp-validation error. BayesianDecoder.fit passes context="BayesianDecoder.fit", so an epoch that selects too-few training samples now raises "At least 2 samples required for BayesianDecoder.fit, got N" instead of the misleading decode_session provenance. The default preserves every existing message.
  • decode_session / decode_session_summary now accept positions=None when encoding_models is 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 a PositionLike times). Every existing caller that passes positions is byte-for-byte unchanged. This enables the fitted-model decode path used by BayesianDecoder.predict / predict_summary.

Fixed

  • The public likelihood functions log_poisson_likelihood and poisson_likelihood now validate dt via the shared validate_dt helper, consistent with the decode entry points. A non-numeric dt (including a numeric string like "0.1"), a bool (dt=True), and a non-finite dt (nan/inf) now raise a clear ValueError instead of the weaker dt <= 0 guard (which leaked a raw TypeError on strings, silently accepted dt=True as 1, and let nan/inf slip through). The error message changed from "dt must be positive" to `"dt must be a finite number

    0"`.

  • bin_spikes_in_time now validates dt consistently with decode_session / decode_session_summary via a shared validate_dt helper: a non-numeric dt (including a numeric string like "0.1") and a bool (dt=True) now raise a clear ValueError ("dt must be a finite number > 0, ..."). Previously a numeric string leaked a raw TypeError from "0.1" <= 0 and dt=True was silently accepted and used as a chunk size of 1. decode_position routes through the same helper, so an invalid dt there also raises the clean message instead of a cryptic downstream error.
  • An unparseable dtype (e.g. dtype="bogus") now raises a clear ValueError naming dtype across the decode/encode entry points (decode_position, decode_position_summary, compute_spatial_rates, decode_session, decode_session_summary) instead of a raw NumPy TypeError: data type 'bogus' not understood.
  • decode_session and decode_session_summary now validate dt (finite, > 0) up front with a clear ValueError, matching bin_spikes_in_time's wording ("dt must be finite and > 0, got ..."). Previously the shared _build_encoding_model built the decode time grid directly, bypassing bin_spikes_in_time's guard, so an invalid dt leaked a cryptic error: dt=0 → ZeroDivisionError, dt=NaN → "cannot convert float NaN to integer", dt<0 → a misleading "span smaller than one bin" message, and dt=inf → a similar cryptic failure.
  • decode_position now preserves float32 when handed a rate-result object (anything exposing .firing_rates). Previously the friendly object path promoted a float32 .firing_rates to float64, silently losing part of the dtype=np.float32 memory win that the raw-array path already delivered. The object path now matches the raw-array path byte-for-byte: float32 stays float32, float64 stays float64, an integer rate map is promoted to float64, and a None / dict / non-2-D .firing_rates still raises the same clear ValueError.
  • time_chunk is now validated as a positive integer (not bool) across normalize_to_posterior, decode_position, decode_position_summary, and decode_session_summary, via a shared validator that raises a clear ValueError naming the value and its type. Previously a float (1.5) or string ("2") leaked a raw TypeError from range(...)/comparison, and True was silently accepted as a chunk size of 1.
  • The summary decoders decode_position_summary and decode_session_summary now reject time_chunk=None (raising a clear ValueError). Previously a None value 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_chunk must be a positive integer (default 1024); use decode_position / decode_session if you want the full posterior.

Added

  • NWB SpatialRatesResult round-trip + lazy reads (neurospatial.io.nwb). write_spatial_rates(nwbfile, result, *, name="spatial_rates", overwrite=False) stores a population result on a unit axis — a DynamicTable (one row per unit) in analysis/ with a unit_id column, a 2-D firing_rate column of shape (n_units, n_bins), and one column per unit_table field; occupancy is stored once as a companion TimeSeries, smoothing_method / bandwidth / bin counts ride in the table description, and bin centers are shared via the existing bin_centers dataset. The full Environment is now persisted alongside the rates (via write_environment under the derived name f"{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 without overwrite raises before any mutation and overwrite=True cleans every companion; a later add failure rolls back the partial write. firing_rates / occupancy are defensively copied so the NWB containers never alias the live result, and a unit_table column named unit_id / firing_rate (reserved) raises a clear ValueError. read_place_field(nwbfile, *, name="spatial_rates", env=None) is the inverse, reconstructing a SpatialRatesResult with firing_rates / occupancy / unit_ids / unit_table / smoothing_method / bandwidth all preserved (unit_ids and unit_table links survive non-default ids and non-trivial tables). When env= 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 an env= nor a persisted env is present. A mismatched or stale env= (its n_bins disagreeing with the stored rates) now raises a clear ValueError instead of silently attaching, the companion occupancy length is validated at read against the table's recorded n_bins, and a name pointing at a non-spatial-rates table raises a clear ValueError. Shape mismatches raise param-named ValueErrors and duplicate names honor overwrite. read_position / read_pose / read_units gain a keyword-only lazy=False: lazy=True returns h5py-backed handles (per-unit spike-time handles for read_units) that materialize only when sliced / np.asarray-ed — valid only while the backing NWBFile is open — while the default eager path stays byte-for-byte unchanged. The lazy read_position / read_pose paths 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). A test_nwb.yml CI job installs the nwb extra and runs the NWB tests (tests/nwb + tests/test_recording.py), which the default dev-only job skips. neurospatial still never imports pynwb (loaded lazily in io/nwb).
  • BayesianDecoder — an immutable (frozen) fit/predict/predict_summary/ score wrapper 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 reusing decode_session's internal encoder and returns a new fitted decoder (never mutates the original); an optional epoch restricts the training data first (via behavior.restrict / restrict_spike_trains) for train/test splits. predict / predict_summary delegate to decode_session / decode_session_summary with the fitted models, so a fitted decoder's posterior is byte-exact with decode_session on the same inputs; score reports decode error ("median_error" / "mean_error", lower is better) via DecodingResult.error_against. score gained a distance= option ("euclidean" default or "geodesic", forwarded to error_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, which nanmedian / nanmean would otherwise ignore) and raises a clear ValueError — instead of returning nan — 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. Invalid metric / distance are validated before decoding so a typo does not cost a full decode. Construction now validates config and fitted state: dt (via the shared validate_dt) and dtype are checked at build time, and a directly-injected fitted decoder (BayesianDecoder(env, encoding_models=..., unit_ids=...)) is checked for fitted-state coupling (unit_ids present, 2-D models, and a bin/unit-count match against env) instead of detonating later inside the core. A new read-only is_fitted property lets callers branch without catching RuntimeError, and a warn_on_drop config field (default True) threads to both the fit encode step and the predict / predict_summary decode steps as a single knob to silence the spikes-out-of-window warnings. Accepts SpikeTrainsLike / PositionLike inputs, and decodes through the Environment, so geodesic / linearized-track / graph-based decoding works (unlike pynapple decode_1d / decode_2d). Unfitted predict / predict_summary / score raise a clear RuntimeError.
  • Session — a frozen discoverability bundle (neurospatial.recording, also exported at the top level) grouping env / 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). position uniformly exposes .t / .values (arrays are wrapped in a small internal Position holder; a pynapple Tsd / TsdFrame already conforms). Constructors: Session.from_arrays(*, env=None, times, positions, spike_times, unit_ids=None, unit_table=None, epochs=None, metadata=None) (accepts arrays or a PositionLike, and a list / 2-D array / SpikeTrains / pynapple TsGroup for spikes, threading unit_ids / unit_table) and Session.from_nwb(path_or_file, *, environment_name=None, unit_ids=None, **read_kwargs). Frozen / immutable: with_environment(env) and restrict(epochs) return a new Session and never mutate the original. restrict slices the position and the spikes to the epochs and is identity-preserving — restriction trims spikes per unit (never drops units), so it rebuilds a SpikeTrains carrying the original unit_ids / unit_table unchanged, and records the epochs on the new session. A restrict that keeps zero position samples (epochs that miss the session entirely — often a seconds-vs-milliseconds unit mismatch) emits a UserWarning naming the likely cause and returns the (empty) session. A mismatched position.t / position.values length (also self-enforced by the internal Position holder, which additionally requires a 1-D t), a position that is not PositionLike (missing .t / .values), or a non-EnvironmentLike env raises a clear ValueError.
  • load_session(source, **kwargs) — dispatches an NWB file path (str / os.PathLike) or an open pynwb NWBFile to Session.from_nwb; any other source raises a clear TypeError directing you to Session.from_arrays. Session.from_nwb builds the bundle via the existing lazy neurospatial.io.nwb readers (read_units / read_position / read_environment), so neurospatial.recording never imports pynwb / pynapple and import neurospatial stays cheap. The environment is read by presence (membership in nwbfile.scratch), not by catching an error: a genuinely-absent environment maps to env=None, while an environment that is present but unreadable (malformed / wrong schema) now raises instead of being silently swallowed to None. The new environment_name= selector reads the standard spatial_environment scratch entry by default and can select an environment written under a custom name. (The lazily-materialized lazy=True NWB 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 new neurospatial.behavior.epochs. epochs accepts (start, end) scalars, two (starts, ends) 1-D arrays, an (n, 2) array, or a pynapple IntervalSet — the IntervalSet is 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 — raises ValueError; pass an (n, 2) NumPy array (interval rows) or explicit 1-D start/end arrays to disambiguate. Endpoints are inclusive by default (closed="both", matching behavior.segmentation and pynapple; also "left" / "right" / "neither"). restrict(times, *arrays, epochs=...) slices times and any arrays aligned to times (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 a SpikeTrains. restrict is also exported at the top level (neurospatial.restrict).
  • SpikeTrains — a frozen ragged-spike-train container (label access st[unit_id], filter("region == 'CA1'"), optional per-unit unit_table), the one justified new container (ragged per-unit spike times genuinely don't fit a rectangular array). Exported from both neurospatial (top level) and neurospatial.encoding. It duck-types as SpikeTrainsLike — it is not a Mapping, exposes a non-callable .index property (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 into compute_spatial_rates (and the other batch encoders/decoders) with unit_ids preserved. Label access (st[unit_id]) and the adapter's positional iteration coexist because they use different dunders (__getitem__ vs __iter__ / .index). Frozen and immutable: trains is stored as a tuple so in-place mutation raises, filter returns a new container and never mutates the original; duplicate unit_ids raise ValueError.
  • Input Protocol surface + pynapple ingress/egress (optional). A new neurospatial/_typing.py defines the structural Protocols that let third-party objects flow into the array-first scientific core without the core ever importing or isinstance-checking them:
  • PositionLike — a .t / .values time-series (pynapple Tsd / TsdFrame conform). compute_spatial_rate / compute_spatial_rates and decode_session / decode_session_summary now accept a PositionLike in the times slot (with positions omitted) and normalize it to plain float64 arrays at the boundary via as_times_positions. The plain-array path is unchanged byte-for-byte.
  • SpikeTrainsLike — the accepted spike-input union, plus a real pynapple TsGroup. A TsGroup is a collections.UserDict, so iterating it yields the unit-id keys, not the per-unit trains; SpikeTrainsLike is therefore the indexable-by-id surface (.index of unit ids + group[uid] returning a per-unit Ts with .t). A new encoding.as_spike_trains_with_ids extracts trains by indexing each id (never by iterating), so a raw TsGroup flows correctly into compute_spatial_rates / decode_session; it surfaces the ids without changing as_spike_trains's list[NDArray] contract, and when a group carries ids and the caller passes no unit_ids=, they now flow into SpatialRatesResult.unit_ids instead of being silently dropped.
  • EnvironmentLike — a public re-export of EnvironmentProtocol. The internal isinstance(env, Environment) check in CompositeEnvironment is replaced by a duck-typed is_environment_like check, fixing the surprise that the sibling EgocentricPolarEnvironment (not an Environment subclass) was rejected even though it is a legitimate environment.
  • pynapple I/O shim behind a new optional pynapple extra: neurospatial.io.from_pynapple (TsGroup → (trains, unit_ids); Tsd / TsdFrame → (times, positions); IntervalSet → (start, end)) and neurospatial.io.to_pynapple (a decoded MAP track / times+values → Tsd / TsdFrame). import pynapple is lazy inside these functions, so the package and the array path import and run with pynapple absent; calling them without it raises a clear ImportError naming neurospatial[pynapple]. The scientific modules never import pynapple.
  • Speed filtering on the encode path. compute_spatial_rate and compute_spatial_rates gain keyword-only speed / min_speed parameters (also forwarded by decode_session / decode_session_summary). When min_speed is set, low-speed periods are excluded using one shared per-interval speed gate applied to both the spike numerator and the occupancy denominator, so a min_speed knob can never silently bias firing rates by filtering only one side. The spike gate matches the occupancy gate exactly: occupancy keeps interval k iff speed[k] >= min_speed, and a spike at time t is kept iff speed[searchsorted(times, t, "right") - 1] >= min_speed — the same per-interval criterion, so identical intervals drop on both sides. When speed is omitted it is auto-derived with a forward difference (speed[k] = ||positions[k+1] - positions[k]|| / (times[k+1] - times[k]), with speed[n-1] = speed[n-2]) to match env.occupancy's time_allocation="start" interval semantics; pass an explicit speed for geodesic / linearized-track environments. min_speed=None (the default) applies no filtering and leaves output byte-for-byte unchanged.
  • Passing speed without min_speed now raises ValueError (instead of silently ignoring speed), mirroring env.occupancy, which raises on min_speed without speed.
  • 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_rates now emit one UserWarning naming the active gate(s) and suggesting fixes (check units; pass max_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 by warn_on_drop=False.
  • decode_session and decode_session_summary gain a keyword-only dtype parameter (np.float32 / np.float64, default np.float64). "Decode in this dtype": a single decode_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 to float64. On decode_session_summary it is now an explicit parameter rather than a decode_kwargs entry. Default np.float64 leaves every existing caller byte-for-byte unchanged; any other dtype raises ValueError.

Performance

  • decode_session(dtype=np.float32) / decode_session_summary(dtype=np.float32) now actually halve the decode working set on the beginner golden path: the float32 rate-map dtype is honored end-to-end (encoding-model working set + posterior) instead of being silently promoted back to float64 inside the session helpers. Values match float64 within float32 tolerance (the rate computation is done in float64 and only the result is cast, per compute_spatial_rates).

Breaking changes

  • to_xarray() now returns a labeled xarray.Dataset instead of an xarray.DataArray with integer coordinates. This is a clean break: there is no DataArray shim and no to_dataset() alias. Two distinct shapes are produced:
  • Population rate results (SpatialRatesResult, DirectionalRatesResult, ViewRatesResult, EgocentricRatesResult) return a Dataset with dims ("unit_id", "bin"). unit_id is the index coordinate holding the real per-unit identity labels (result.unit_ids), so units are selected by label. The bin dimension carries non-index bin_center_x / bin_center_y (/ bin_center_z) coordinates for Cartesian environments, or bin_center_distance / bin_center_angle for the polar egocentric result. The rate matrix is the firing_rate data var; occupancy is a ("bin",) data var. attrs carry units, bandwidth (where applicable), an env fingerprint, and software_version. Duplicate unit_ids now raise ValueError (label-based selection requires uniqueness).
  • Decode results (DecodingResult) return a Dataset with dims ("time", "bin") (a posterior over space per time bin; no unit_id axis). The posterior data var holds the posterior, with the same bin_center_* coordinate logic and units / env / software_version attrs.

    Before → after:

    # before (DataArray, integer coords)
    da = result.to_xarray()
    da.sel(neuron=0)
    # after (Dataset, real unit_id labels)
    ds = result.to_xarray()
    ds.sel(unit_id=result.unit_ids[0])
    
  • 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 per bin), always carrying a unit_id column plus the bin-center coordinate columns (bin_center_x/y/z for Cartesian, bin_center_distance/angle for polar egocentric, bin_center_angle for directional), firing_rate, and occupancy. The per-unit summary that to_dataframe() used to return (one row per neuron with peak_x, peak_rate, spatial_info, sparsity, grid_score, border_score, cell_type, etc.) has moved to the new summary_table(), which is unit_id-indexed. This is a clean break: there is no mode flag and no transition shim. The neuron_ids= keyword on the old per-unit to_dataframe() is replaced by unit_ids= on summary_table() (defaulting to the result's own unit_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_summary is a new sibling of decode_position that 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 new DecodingSummary frozen dataclass (alongside DecodingResult) carrying ResultMixin and the standard terminal verbs — to_dataframe() (one row per time bin), summary(), plot(), and to_xarray() (a track Dataset with a time dim and no bin posterior axis) — sharing accessor names and column conventions with DecodingResult so user code ports between the two. decode_session_summary is the matching one-call encode→bin→decode wrapper (sibling of decode_session). The summary reductions are bit-for-bit identical to reducing the full posterior for map_position / map_bin, and match to floating-point tolerance for mean_position / posterior_entropy / peak_prob.

  • Experiment-shaped factory presets on Environment that speak experiment vocabulary and delegate to the existing from_* factories:

  • Environment.open_field(positions, bin_size, ...) — the only positions-based preset; delegates to from_samples with fill_holes=True flipped 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 to from_graph.
  • Environment.maze(kind, *, track_graph=..., node_positions=..., bin_size) — assembles the standard W / plus / T track-graph topology (or accepts a ready networkx graph) and delegates to from_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() on DirectionalRatesResult, ViewRatesResult, and EgocentricRatesResult (the directional/view/egocentric population results previously had no xarray export). Each returns the labeled xr.Dataset described under Breaking changes above.

  • Durable unit identity on encoding/events results. Every population result (SpatialRatesResult, DirectionalRatesResult, ViewRatesResult, EgocentricRatesResult, PopulationPeriEventResult) now carries a unit_ids: np.ndarray field (plus an optional unit_table: pd.DataFrame | None), and every single-unit result (SpatialRateResult, DirectionalRateResult, ViewRateResult, EgocentricRateResult, PeriEventResult) carries a singular unit_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-only unit_ids= parameter that threads onto the result; a wrong-length value raises a clear ValueError. Fully back-compatible: unit_ids defaults to np.arange(n_units) and the new fields are compare=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 on PopulationPeriEventResult. 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 optional unit_ids= to relabel the index.

  • PSTH results now carry the uniform result surface. PeriEventResult and PopulationPeriEventResult inherit the canonical ResultMixin and 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 carrying unit_id), summary() (flat dict of headline scalars — peak rate/latency, baseline rate; population adds mean_peak_rate / population_peak_latency), PopulationPeriEventResult.summary_table() (one row per unit with peak_rate / peak_latency / baseline_rate), and plot() (delegates to plot_peri_event_histogram, returns the axis).

  • decode_session(env, spike_times, times, positions, *, dt, ...) — one-call encode→bin→decode golden path in neurospatial.decoding.session. Glues compute_spatial_rates, bin_spikes_in_time, and decode_position into a single function so beginners can decode position in ≤10 lines. Exported from neurospatial.decoding (from neurospatial.decoding import decode_session). Accepts an optional encoding_models= array to bypass the encoding step entirely. Extra keyword arguments are forwarded verbatim to decode_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 from neurospatial.encoding (and present in __all__); the implementation lives in neurospatial.encoding._spikes. It standardizes the container shape only — spike-time values are never shifted, rescaled, or aligned. (This is the previously-internal normalize_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 (returns NDArray[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), and SpatialRatesResult (place cell, min_spatial_info=0.5). These replace the per-domain detect_ovcs / detect_view_cells / detect_hd_cells detectors (now deprecated aliases).

  • label_cell_types(...) on SpatialRatesResult — the multi-class string labeler ("place"/"grid"/"border"/"unclassified", returns NDArray[np.str_]). This is the renamed detect_cell_types and is kept deliberately SEPARATE from the boolean classify (different return type).

  • is_place_cell(...) — a single-neuron place-cell predicate, both as a free function in neurospatial.encoding.spatial (exported from neurospatial.encoding) and as a SpatialRateResult.is_place_cell() method. Mirrors is_spatial_view_cell / is_object_vector_cell and agrees with detect_place_fields (returns True iff that detector finds ≥1 field).

  • decode_position(env, spike_counts, encoding_models, dt, ...) now accepts a population rate result object (anything exposing a firing_rates attribute, e.g. SpatialRatesResult) directly in place of the raw (n_neurons, n_bins) array, removing the np.stack([r.firing_rate ...]) glue between the encoding and decoding steps.

  • New keyword-only parameter warn_on_drop: bool = True on bin_spike_train, bin_spike_trains (encoding/_binning.py), compute_spatial_rate, and compute_spatial_rates (encoding/spatial.py). Set to False to intentionally silence all spike-drop warnings (e.g. when the caller handles the diagnostic themselves).

Performance

  • decode_session_summary now 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 as decode_position_summary. Peak memory is now O(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_position gains two keyword-only memory knobs with no change to its return contract (.posterior stays a fully-materialized ndarray):

  • dtype=np.float32 stores and computes the posterior in single precision, halving stored and transient memory (parity with float64 to ~1e-6 relative). Every DecodingResult method works unchanged on a float32 posterior.
  • time_chunk is 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 explicit time_chunk=k now 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×, since decode_position returns 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, use decode_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_rates gains a keyword-only dtype (np.float32 / np.float64, default np.float64). dtype=np.float32 halves 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_summary now accept their own dtype parameter (default float64) that honors float32 end-to-end — the encoding-model working set and the posterior — so decode_session(dtype=np.float32) halves the decode working set on the golden path (see the decode_session / decode_session_summary dtype entry above). Default np.float64 leaves every existing caller byte-for-byte unchanged; any other dtype raises ValueError.

  • 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 costs n_bins**2 * 8 bytes of float64 memory (≈ 3.2 GB at 20,000 bins). compute_diffusion_kernels and env.compute_kernel now emit a loud UserWarning (with a GB estimate) above 3,000 bins and then proceed — there is no hard limit and no allow_large opt-out. The warning names the size, the GB estimate, the dense O(n²) reason, and the fixes (reduce bins or use smoothing_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-peak expm_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 loud UserWarning (with a GB estimate) above the shared _LARGE_KERNEL_THRESHOLD (3,000 bins) and proceeds — no hard limit, no allow_large. One warn threshold is shared by both dense smoothing paths instead of a divergent copy. Also corrected stale encoding/_smoothing.py docs 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 the grid_score/border_score columns and again inside label_cell_types()); it now computes each once and forwards them. label_cell_types() gains optional keyword-only grid_scores= / border_scores= parameters to accept precomputed score arrays (validated to be 1-D and length n_neurons); when omitted it recomputes as before, so existing callers are unchanged.

  • population_coverage() gains a keyword-only n_jobs parameter (default 1) to parallelize per-neuron place-field detection via joblib (-1 uses all CPUs). n_jobs=1 keeps the sequential path with no joblib overhead; results are byte-for-byte identical regardless of n_jobs (returned data identical; per-neuron exclusion warnings are not surfaced under n_jobs != 1).

Changed

  • Default rate-map output changed for gappy / out-of-bounds data. With the new max_gap default of 0.5 s, compute_spatial_rate / compute_spatial_rates (and decode_session / decode_session_summary, which forward max_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 the max_gap=None opt-out.

  • detect_region_crossings argument 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 a DeprecationWarning) and will be removed in 0.7.

  • Docs/examples now teach decode_session as 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_decoding now leads with decode_session; a new "Workflow 2: Bayesian Decoding (one call)" section was added to docs/user-guide/workflows.md (with a workflows_decode_session_golden_path CI snippet in docs/snippets.yml); and README.md points to from neurospatial.decoding import decode_session as the position-decoding entry point.

  • Docs (Phase 0.6 sweep): rewrote Workflow 1 in docs/user-guide/workflows.md to use the canonical compute_spatial_rate(env, spike_times, times, positions, ...).firing_rate idiom with simulate_trajectory_ou + PlaceCellModel fixtures, replacing the hand-rolled np.histogram / scipy.ndimage.gaussian_filter approach. Added a CI snippet entry (workflows_place_field_canonical) to docs/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 into classify).
  • ViewRateResult.peak_view_location → ViewRateResult.peak_location.
  • ViewRatesResult.peak_view_location → ViewRatesResult.peak_locations.
  • detect_region_crossings old positional order (position_bins, times, region_name, env, ...) → new order (position_bins, times, env, *, region_name, ...).

Fixed

  • decode_position_summary now validates prior shape (was silently truncating over-long 2-D priors), matching decode_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 raises ValueError before 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.occupancy drops a trajectory interval k for three reasons — dt[k] > max_gap (large tracking gap), speed[k] < min_speed (low speed), and start_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 shared interval_valid_mask helper (the single source of truth that env.occupancy also consumes). compute_spatial_rate / compute_spatial_rates gain a keyword-only max_gap: float | None = 0.5 (matching env.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. Pass max_gap=None to 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_rates now emit a single UserWarning naming the active gate(s) (max_gap, min_speed, and/or the out-of-bounds-start possibility) and suggesting fixes (check units; pass max_gap=None). Previously only the min_speed gate warned — a too-large max_gap (or a wrong-units one) and the out-of-bounds-start rule emptied the map silently. Gated by warn_on_drop=True (the default); fires once per call (not per neuron in the batch path).

  • decode_session now warns loudly when most spikes fall outside the decode time window [times.min(), times.max()] instead of silently dropping them. Previously, when encoding_models= was passed (which skips compute_spatial_rates), spikes outside the window were silently discarded by bin_spikes_in_time's np.histogram, so a milliseconds-vs-seconds unit mismatch produced an all-zero count matrix and a plausible-but-wrong posterior with no warning. decode_session now owns a single UserWarning (naming the dropped count/total, percentage, decode window, spike range, and units hypothesis) that covers both the encoding_models-provided and encoding_models=None branches; the encoder's now-redundant duplicate is suppressed. A new keyword-only warn_on_drop: bool = True parameter 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 in docs/errors.md.

  • Environment.distance_to(region_name) now raises RegionNotFoundError (from neurospatial._exceptions) instead of a bare KeyError when the named region is absent. RegionNotFoundError subclasses KeyError, so all existing except KeyError blocks keep working without change.

  • Removed stale compute_firing_rate(...) calls (that function does not exist publicly) from docs/user-guide/workflows.md (Workflow 3) and docs/user-guide/spatial-analysis.md; replaced with the correct compute_spatial_rate(env, spike_times, times, positions, ...).firing_rate API and argument order.

  • Replaced 11 broken env.plot(field, ax=...) calls in docs/user-guide/spike-field-primitives.md and docs/user-guide/rl-primitives.md with env.plot_field(field, ax=...). The env.plot() method's first positional argument is ax, 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 batch compute_spatial_rates(env, spike_times_list, times, positions, ...).firing_rates for encoding models and the canonical bin_spikes_in_time(spike_trains, dt, t_start, t_stop) helper for time-binned spike counts; regenerated the notebook via jupytext --sync.

  • Bumped stale version strings from v0.4.0 to v0.5.0 in docs/index.md (status line and BibTeX entry) and README.md (dependency table header and BibTeX entry).

  • GraphValidationError messages in layout/validation.py and the wrapping ValueError in environment/core.py no longer reference the internal developer guide CLAUDE.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 under src/neurospatial/ contains this string, preventing the leak from regressing.

  • Environment.__init__ now raises a beginner-grade ValueError when called without arguments (i.e. bare Environment()). The old message "layout parameter is required" gave no actionable guidance; the new message explains that Environment must 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 by EnvironmentNotFittedError. The exception type remains ValueError so existing except ValueError callers are unaffected.

  • align_spikes_to_events in events/alignment.py now rejects event_times containing Inf values with a descriptive ValueError, matching the existing spike_times Inf check and fulfilling the docstring promise. Previously an Inf event time silently produced an empty-or-wrong result.

  • behavior.trajectory public functions (compute_turn_angles, compute_step_lengths, mean_square_displacement) and behavior.navigation.traveled_path_length now coerce positions (and times for MSD) with np.asarray at the public boundary before any .ndim/.shape access. Passing a plain Python list no longer raises a confusing AttributeError; valid list-of-lists inputs succeed, and malformed inputs raise a descriptive TypeError or ValueError.

  • bin_spike_train and bin_spike_trains in encoding/_binning.py no 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), a UserWarning is 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_coverage docstring example now shows the vectorized batch path (compute_spatial_rates(...).firing_rates) instead of the per-neuron compute_spatial_rate loop; the pre-existing arg-order bug (population_coverage(firing_rates, env)) has been corrected to population_coverage(env, firing_rates).
  • population_coverage shape-mismatch ValueError message now steers users to compute_spatial_rates (the batch function) rather than the slow compute_spatial_rate + np.stack recipe.
  • compute_spatial_rate (singular) docstring now includes a short note pointing many-neuron users to compute_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 → metric across all physical-distance APIs. Legal values are {"euclidean", "geodesic"}. Affects Environment.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 → bandwidth across smoothing APIs (compute_spatial_rate(s), compute_view_rate(s), compute_egocentric_rate(s), Environment.smooth, KDE helpers).
  • velocity_threshold / speed_threshold / threshold → min_speed across 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_rate is now a cached attribute, not a method. Replace result.firing_rate() with result.firing_rate.
  • DecodingResult.uncertainty → posterior_entropy (matches the free function in decoding/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_cell method 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?, *, ...). Affects compute_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_cell keep 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 and CLAUDE.md "Canonical Argument Order".
  • Egocentric ops allocentric_to_egocentric / egocentric_to_allocentric reorder to (positions, headings, targets).
  • Behavioural segmentation functions reordered to (position_bins, times, env, *, ...).
  • distance_to_reward in events.regressors reordered to (env, times, positions, reward_times, ...).
  • fit_isotonic_trajectory / fit_linear_trajectory reordered to (env, posterior, times, *, ...) with a standardized method keyword.
  • * 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_coords is now (x_offset, y_offset) instead of (row_offset, col_offset). Swap peak_coords[:, 0] (was row) for peak_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 between speed_units and env.units raises rather than silently rescaling.

Removed (no aliases, no deprecation)

  • Environment.save / Environment.load. The pickle path is gone. Use Environment.to_file / Environment.from_file (JSON metadata plus npz arrays).
  • Environment.mask_for_region. Use Environment.region_mask.
  • from_image / from_mask factory aliases. Replaced by from_pixel_mask(image_mask, pixel_size, ...) and from_grid_mask(active_mask, grid_edges, ...).
  • path_efficiency (float-returning). Use compute_path_efficiency which returns a PathEfficiencyResult.
  • Cross-domain re-exports. Each public symbol now has exactly one canonical import path; the top-level neurospatial namespace no longer re-exports symbols from encoding, decoding, etc.

Added

  • PlaceFieldsResult dataclass. detect_place_fields returns a frozen dataclass with fields, excluded_reason, and n_excluded fields. Still iterable / sized / indexable, so existing for f in detect_place_fields(...) and len(...) patterns keep working. Closes the "silent drop when mean rate too high" failure mode.
  • BinSequenceWithRuns dataclass + new method. Environment.bin_sequence always returns an ndarray; Environment.bin_sequence_with_runs returns a dataclass with bins, run_starts, run_lengths.
  • MSDResult and friends. Misc result-type cleanup in trajectory analysis: MSDResult, SpatialAutocorrelationResult, PathEfficiencyResult.
  • Environment.is_polar property and coordinate_kind attribute. from_polar_egocentric sets coordinate_kind="polar". Methods that assume Cartesian (distance_to, distance_between, Environment.contains, apply_transform, bin_at on (x, y) input) raise on polar environments with a clear error. plot_field switches 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; added RegionNotFoundError, RegionAlreadyExistsError, and three more in _exceptions.py.
  • Environment.from_pixel_mask and Environment.from_grid_mask factories (replacing from_image / from_mask).
  • Environment._state_version invalidation 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__ returns info() for quick inspection.
  • Glossary page at docs/glossary.md defining 14 core terms. Linked from docs/getting-started/core-concepts.md and the README.
  • docs/api/index.md expansion. Structured sections for encoding, decoding, behavior, events, ops.egocentric, ops.visibility, ops.basis, stats, animation, io.nwb.
  • docs/examples/index.md rewrite. 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, and compute_spatial_rate.
  • CI doc-snippet test. scripts/test_doc_snippets.py plus .github/workflows/test_docs.yml re-executes a curated manifest of doc snippets on every PR.
  • CI notebook regen test. .github/workflows/test_notebooks.yml re-executes 11_place_field_analysis.ipynb per PR to catch silent regressions in the example surface.
  • Shared example styling. examples/_style.py Wong / 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 a MaskedGrid instead of a one-off subset layout kind, so the result is fully serializable.
  • bin_at vs map_points_to_bins standardize on -1 for out-of-environment samples in trajectory contexts.
  • detect_place_fields returns a PlaceFieldsResult with excluded_reason set instead of silently returning [].
  • batch_grid_scores / batch_border_scores use 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_position raise immediately instead of failing deep in the call stack.
  • spike_times validation 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 in Regions.__setitem__ now raise.
  • Errors carry units and stack context. Length-mismatch errors from _binning include a context arg so messages say "in compute_spatial_rate: ..."; magnitude errors include the offending unit.
  • Warning hygiene. UserWarning for data-quality, RuntimeWarning for numerical fallbacks, stacklevel=2 everywhere.
  • Production print() calls replaced with module-level logger.info / logger.debug.
  • Environment.bin_attributes, edge_attributes, differential_operator converted from @cached_property to methods (get_bin_attributes(), etc.) so the cost is visible.
  • Environment.units validated against a small registry ({"cm", "m", "mm", "px", None}) with a UserWarning for unknown values. Documented as advisory.
  • Heading convention documented explicitly in every function that takes a headings argument (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=None bug for empty-string names. Now uses repr(self.name) so empty strings are visible as ''.
  • Environment._state_version cache 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 by to_file / from_file.
  • Environment.mask_for_region. Use region_mask.
  • from_image / from_mask factory aliases. Replaced by from_pixel_mask / from_grid_mask.
  • path_efficiency float-returning function. Use compute_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 / batch
  • ViewRateResult frozen dataclass (firing_rate, occupancy, env, plus view_spatial_information(), is_spatial_view_cell(), sparsity(), selectivity() methods)
  • is_spatial_view_cell() free-function classifier
  • SpatialViewCellModel simulation model with three gaze models (fixed_distance, ray_cast, boundary)

  • Visibility and Gaze: Ray-casting visibility for view cells

  • FieldOfView frozen dataclass with species presets (FieldOfView.rat(), FieldOfView.primate())
  • ViewshedResult frozen dataclass (visible bins + visibility fraction)
  • compute_viewed_location() — gaze-directed location projection
  • compute_viewshed() / compute_viewshed_trajectory() / compute_view_field() — observer-position visibility analysis
  • visible_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 / EgocentricRatesResult frozen dataclasses (firing_rate, occupancy, env, plus preferred_distance(), preferred_direction(), egocentric_spatial_information(), is_object_vector_cell() methods)
  • object_vector_score() — distance × direction selectivity metric
  • is_object_vector_cell() free-function classifier
  • plot_object_vector_tuning() — polar heatmap visualization
  • ObjectVectorCellModel simulation model with von Mises directional tuning
  • ObjectVectorOverlay for animation with object–animal vectors

  • Egocentric Reference Frames: Foundation for object-vector and view cells

  • EgocentricFrame dataclass with to_egocentric() / to_allocentric()
  • allocentric_to_egocentric() / egocentric_to_allocentric() — batch frame transforms
  • compute_egocentric_bearing() — angle to targets relative to heading (egocentric convention: 0 = ahead, +π/2 = left)
  • compute_egocentric_distance() — Euclidean and geodesic distance
  • heading_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 AffineND class for N-dimensional affine transforms using (N+1)×(N+1) homogeneous matrices
  • Affine3D type alias for convenience (equivalent to AffineND with n_dims=3)
  • 3D factory functions: translate_3d(), scale_3d(), from_rotation_matrix()
  • Integration with scipy.spatial.transform.Rotation for 3D rotations
  • estimate_transform() now auto-detects dimensionality (2D or 3D) from input points
  • apply_transform_to_environment() supports N-dimensional environments with validation
  • 45 new tests for 3D transforms including scipy integration
  • Backward compatible: Affine2D unchanged for existing 2D workflows

  • N-D Probability Mapping: alignment.py now 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 Environment continues 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.md with 3D transform examples and feature matrix
  • Updated docs/user-guide/alignment.md with 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 smoothing
  • Environment.bin_sequence() - Convert trajectories to bin sequences with run-length encoding
  • Environment.transitions() - Compute empirical transition matrices with adjacency filtering and normalization
  • Environment.components() - Find connected components in environment graph
  • Environment.reachable_from() - Compute reachable bins via BFS or geodesic distance

Smoothing & Resampling (P1)

  • Environment.smooth() - Apply diffusion kernel smoothing to arbitrary fields
  • Environment.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.py module:
  • normalize_field() - Normalize to probability distribution
  • clamp() - Bound field values
  • combine_fields() - Weighted combination (mean/max/min)
  • divergence() - KL/JS divergence and cosine distance

Utilities & Polish (P3)

  • Environment.region_membership() - Vectorized bin-to-region containment checks
  • Environment.distance_to() - Compute distances to target bins or regions (Euclidean/geodesic)
  • Environment.rings() - K-hop neighborhoods via BFS layers
  • Environment.copy() - Deep/shallow copying with cache invalidation
  • spatial.map_points_to_bins() - Enhanced with max_distance and max_distance_factor thresholds for deterministic boundary decisions

Diffusion Kernel Infrastructure

  • kernels.py module:
  • compute_diffusion_kernels() - Matrix-exponential heat kernel on graphs with volume correction
  • Environment.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 context
  • docs/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, dynamic dimension_ranges)
  • KDTree operations: Now deterministic by default using tie_break="lowest_index"
  • All environment operations: Use @check_fitted decorator 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_2d computation for 1D graphs (was unconditionally assuming 2D)
  • GraphLayout dimension_ranges now 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(), and load() 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.py module with NullHandler by default, enabling optional logging for debugging and workflow tracing
  • Centralized numerical constants: New _constants.py module 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.md clarifying 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_TOLERANCE constant for consistent geometric comparisons
  • Enhanced error messages: CompositeEnvironment now provides detailed diagnostics for dimension mismatches and type errors
  • Clarified 2D-only transforms: Updated transforms.py docstring to explicitly state 2D-only status and suggest scipy for 3D

Fixed

  • Removed unused type: ignore comment in regular_grid.py
  • Fixed potential KeyError in logging by renaming name parameter to env_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 Environment class 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)