Skip to content

Complete Workflows

This page demonstrates end-to-end analysis workflows that integrate multiple neurospatial features.

Workflow 1: Place Field Analysis

A complete workflow for analyzing spatial firing patterns of neurons during navigation.

Overview

Goal: Compute spatial firing rate maps from position tracking and spike data

Steps: Simulate trajectory → Create environment → Generate spikes → Compute place field → Visualize

Complete Example

import numpy as np
import matplotlib.pyplot as plt
from shapely.geometry import box

from neurospatial import Environment
from neurospatial.encoding import compute_spatial_rate
from neurospatial.simulation import (
    PlaceCellModel,
    generate_poisson_spikes,
    simulate_trajectory_ou,
)

# Step 1: Simulate an open-field session in a 100x100 cm arena.
# We seed the trajectory simulator with a polygon environment so the animal
# explores the full arena from the start (avoids a degenerate seed from
# sparse random points). Duration=300 s gives ~90% arena coverage at 15 cm/s
# while keeping CI runtime well under 60 s.
env_seed = Environment.from_polygon(box(0, 0, 100, 100), bin_size=5.0)
env_seed.units = "cm"  # Required by simulate_trajectory_ou
positions, times = simulate_trajectory_ou(
    env_seed,
    duration=300.0,   # 5-minute session — full arena coverage
    dt=1 / 30.0,      # 30 Hz tracking
    speed_mean=15.0,  # cm/s
    seed=0,
    speed_units="cm",
)

# Step 2: Create a finer environment from the recorded trajectory
# (2.5 cm bins → ~1 600 active bins for a 100×100 cm open field)
env = Environment.from_samples(
    positions,
    bin_size=2.5,             # 2.5 cm bins for a 100×100 cm arena
    bin_count_threshold=5,
    dilate=True,
    fill_holes=True,
    name="OpenFieldSession1",
)
env.units = "cm"

print(f"Created environment with {env.n_bins} active bins")
print(f"Spatial extent: {env.dimension_ranges}")
assert env.bin_at([50.0, 50.0]) != -1, "Place cell center must be inside env!"

# Step 3: Generate spike train for a simulated place cell
# (In real experiments, load your spike timestamps here.)
cell = PlaceCellModel(env, center=np.array([50.0, 50.0]), width=12.0, max_rate=20.0)
rates = cell.firing_rate(positions, times)
spike_times = generate_poisson_spikes(rates, times, seed=1)

print(f"Total spikes: {len(spike_times)}")

# Step 4: Compute the place field with the canonical one-liner
result = compute_spatial_rate(
    env,
    spike_times,
    times,
    positions,
    method="diffusion_kde",  # boundary-aware graph-based KDE
    bandwidth=5.0,                     # smoothing bandwidth in cm
    # min_occupancy threshold is applied to the *smoothed* occupancy density,
    # not raw seconds. Low-coverage bins are excluded by bin_count_threshold
    # when creating the environment; leave min_occupancy at its default (0.0)
    # unless you have a specific density threshold in mind.
)
firing_rate = result.firing_rate

print(f"Peak firing rate: {np.nanmax(firing_rate):.2f} Hz")
print(f"Mean firing rate: {np.nanmean(firing_rate):.2f} Hz")

# Step 5: Visualize results
fig, axes = plt.subplots(1, 3, figsize=(15, 5))

# Plot 1: Trajectory overlaid on environment layout
ax1 = axes[0]
env.plot(ax=ax1)
ax1.plot(positions[:, 0], positions[:, 1], "r-", alpha=0.3, linewidth=0.5)
ax1.set_title("Trajectory")

# Plot 2: Occupancy map (seconds per bin)
ax2 = axes[1]
env.plot_field(result.occupancy, ax=ax2, cmap="viridis")
ax2.set_title("Occupancy (s)")

# Plot 3: Place field (smoothed firing rate)
ax3 = axes[2]
env.plot_field(firing_rate, ax=ax3, cmap="hot")
ax3.set_title("Place Field (Hz)")

plt.tight_layout()
plt.show()

print(f"Spatial information: {result.spatial_information():.3f} bits/spike")

Key Considerations

Bin Size Selection: - Too large: Lose spatial resolution - Too small: Insufficient occupancy, noisy firing rates - Rule of thumb: 2-5 cm for rat open field (100x100 cm arena)

Occupancy Threshold (min_occupancy): - For the default KDE methods (diffusion_kde/gaussian_kde), min_occupancy is a threshold on the smoothed occupancy density (the firing-rate denominator), not raw seconds — leave it at the default 0.0 unless you have a specific density threshold in mind. Low-coverage bins are already excluded at environment creation via bin_count_threshold. - Only the legacy method="binned" thresholds raw per-bin occupancy in seconds. - Bins below the threshold are set to NaN in result.firing_rate.

Smoothing: - "diffusion_kde" (default) is boundary-aware and works on any graph layout - "gaussian_kde" gives comparable results in the interior of regular rectangular grids (diffusion_kde is boundary-aware, so they differ near boundaries) - Increase bandwidth for noisier data or coarser bins

Workflow 2: Bayesian Decoding (one call)

Reconstruct an animal's position from population spike trains in a single call.

Overview

Goal: Decode position over time from a population of place cells

Steps: Simulate or load (env, spike_times, times, positions) → decode_session(...) → inspect result.map_position / plot

decode_session is the one-call golden path: it builds the encoding models (place fields), bins the spikes onto a regular time grid, and runs the Bayesian decoder for you. The whole encode → bin → decode pipeline fits in one line.

Complete Example

import numpy as np
import matplotlib.pyplot as plt

from neurospatial import Environment
from neurospatial.decoding import decode_session, decoding_error
from neurospatial.simulation import (
    PlaceCellModel,
    generate_population_spikes,
    simulate_trajectory_ou,
)

# Step 1: Simulate a population of place cells on a 100 cm linear track.
# (In a real analysis, load your env, spike_times, times, and positions here.)
env = Environment.from_samples(
    np.linspace(0.0, 100.0, 51).reshape(-1, 1), bin_size=2.0
)
env.units = "cm"
positions, times = simulate_trajectory_ou(
    env, duration=600.0, dt=0.02, speed_mean=15.0, seed=0, speed_units="cm"
)
cells = [
    PlaceCellModel(env, center=np.array([c]), width=10.0, max_rate=20.0, seed=i)
    for i, c in enumerate(np.linspace(5.0, 95.0, 25))
]
spike_times = generate_population_spikes(
    cells, times, positions, seed=0, show_progress=False
)

# Step 2: Decode position in a single call (encode -> bin -> decode).
result = decode_session(env, spike_times, times, positions, dt=0.1)

print(f"Decoded {result.posterior.shape[0]} time bins over {env.n_bins} bins")

# Step 3: Evaluate — the decoded MAP position should track the trajectory.
actual = np.interp(result.times, times, positions[:, 0]).reshape(-1, 1)
median_err = np.nanmedian(decoding_error(result.map_position, actual))
print(f"Median decoding error: {median_err:.1f} cm")

# Step 4: Plot decoded vs. actual position.
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(result.times, actual[:, 0], label="Actual", linewidth=1)
ax.plot(result.times, result.map_position[:, 0], label="Decoded (MAP)", linewidth=1)
ax.set_xlabel("Time (s)")
ax.set_ylabel("Position (cm)")
ax.set_title("Bayesian decoding with decode_session")
ax.legend()
plt.tight_layout()
plt.show()

result is a DecodingResult with .posterior (n_time, n_bins), .map_position (n_time, n_dims), .mean_position, .posterior_entropy, and .times. See decoding_error and related helpers for accuracy metrics.

Overlaying actual position on a posterior

DecodingResult.plot(show_map=True) uses spatial-bin indices on its y-axis. Actual positions and result.map_position carry physical coordinates, such as centimeters. Convert actual positions with env.bin_at for a posterior overlay; keep their physical coordinates for error metrics and position-versus-time plots.

On a uniform continuous decoder clock, image columns center on the returned timestamps: image edges extend half a bin past the first and last center. Two timestamps use their actual spacing; one timestamp uses a 1-second display width centered on that timestamp. Explicit extent= overrides these edges. Posterior values, MAP positions and timestamps are unchanged.

On a continuous decoder clock, the plot's x-axis uses seconds. Across recording gaps it uses time-bin indices and marks the breaks with dashed lines. Reusing the MAP line's x coordinates keeps the actual overlay on the same axis in both cases. Supply actual positions aligned to the returned decoder rows; do not create or interpolate observations inside a tracking pause.

This exact four-row posterior uses 5 cm bins. It represents perfect decoding for both clocks below, so the actual and MAP overlays must coincide. The gapped example contains no decoder rows between 10.15 and 20.05 seconds.

import matplotlib.pyplot as plt
import numpy as np

from neurospatial import Environment
from neurospatial.decoding import DecodingResult, median_decoding_error

env = Environment.from_samples(
    np.linspace(0.0, 100.0, 21)[:, None], bin_size=5.0, units="cm"
)
actual = np.array([[20.0], [80.0], [40.0], [60.0]])
actual_bins = env.bin_at(actual)
posterior = np.zeros((len(actual), env.n_bins))
posterior[np.arange(len(actual)), actual_bins] = 1.0
clocks = {
    "Continuous": 10.05 + np.arange(4) * 0.1,
    "Gapped": np.array([10.05, 10.15, 20.05, 20.15]),
}
fig, axes = plt.subplots(1, 2, figsize=(10, 3), constrained_layout=True)
for ax, (name, decoder_times) in zip(axes, clocks.items(), strict=True):
    result = DecodingResult(posterior, env, decoder_times)
    result.plot(ax=ax, show_map=True, colorbar=True)
    map_line = ax.lines[0]  # The MAP line precedes recording-gap markers.
    plot_times = map_line.get_xdata()
    actual_line = ax.plot(plot_times, actual_bins, "c--", label="Actual spatial bin")[0]
    expected_x = decoder_times if name == "Continuous" else np.arange(len(actual))
    np.testing.assert_array_equal(actual_line.get_xdata(), expected_x)
    np.testing.assert_array_equal(actual_line.get_ydata(), map_line.get_ydata())
    np.testing.assert_array_equal(actual_line.get_ydata(), [4, 16, 8, 12])
    assert median_decoding_error(result.map_position, actual) == 0.0
    ax.set_title(name)
    ax.legend()
plt.show()

Long sessions / thousands of units: stream the decode

decode_session materializes the full (n_time, n_bins) posterior. For long recordings or large populations that array can be too big to hold in memory. Use the memory-safe summary decoder instead:

from neurospatial.decoding import decode_session_summary

summary = decode_session_summary(env, spike_times, times, positions, dt=0.1)
df = summary.to_dataframe()  # per-time MAP / mean / entropy / peak
print(summary.summary())     # headline scalar metrics
print(summary.map_position)  # (n_time, n_dims) MAP estimate

decode_session_summary streams the decode in time chunks and returns a DecodingSummary carrying per-time MAP / mean / entropy / peak via .to_dataframe() and .summary() (plus .map_position) — without ever materializing the full posterior. If you already have binned spike counts and encoding models, decode_position_summary(env, spike_counts, encoding_models, dt, *, time_chunk=...) is the array-level equivalent.

When to reach for the manual path

decode_session covers the common case. When you need custom control — passing your own encoding_models, reusing fitted place fields across sessions, or inspecting the binned spike counts — use the manual three-call path (compute_spatial_rates → bin_spikes_in_time → decode_position). That walk-through, plus trajectory analysis and shuffle-based significance testing for replay detection, is covered in example 20.

Reusing rate maps on a recording with gaps

decode_session requires tracking and computes its own maps. For maps already computed on a training epoch, use BayesianDecoder.from_rates(rates); prediction takes spike times and a plain timestamp array. An explicit array handoff uses rates.firing_rates with binned counts. This example decodes the second recording run through both routes. It bins only that run and uses each decoder's returned timestamps, so the 10–20 s pause is never represented by a decode bin.

import numpy as np
from neurospatial import Environment, compute_spatial_rates, decode_position
from neurospatial.decoding import BayesianDecoder, bin_spikes_in_time

times = np.r_[np.arange(300) / 30, 20 + np.arange(300) / 30]
phase = np.arange(600) / 30
positions = 10 + 5 * np.c_[np.sin(phase), np.cos(phase)]
env = Environment.from_samples(positions, bin_size=2.0)
spike_times = [times[::10], times[::15]]
recording_windows = np.array([[0.0, 10.0], [20.0, 30.0]])
train, test = (0.0, 10.0), (20.0, 30.0)
dt = 0.1

rates = compute_spatial_rates(
    env, spike_times, times, positions, epochs=train,
    spike_window=recording_windows, fill_value=0.0, unit_ids=[101, 202],
)
decoder = BayesianDecoder.from_rates(rates, dt=dt)
result = decoder.predict(
    spike_times, times, epochs=test, spike_window=recording_windows,
)

# Clip the analysis epoch to the observed run, then bin that epoch alone.
counts, centers = bin_spikes_in_time(
    spike_times, dt=dt, epochs=(test[0], min(test[1], times[-1])),
)
array_result = decode_position(env, counts, rates.firing_rates, dt, times=centers)
np.testing.assert_array_equal(result.times, array_result.times)
np.testing.assert_array_equal(result.posterior, array_result.posterior)
assert np.all((result.times >= 20.0) & (result.times < 30.0))
print("Decode bins:", len(result.times), "unit order:", rates.unit_ids.tolist())

Use BayesianDecoder(env).fit(spike_times, times, positions, unit_ids=...) when the decoder should build the maps. A labelled spike group and explicit unit_ids must agree in order and value. Prediction matches labels only when both inputs carry caller-supplied identity; generated row numbers retain positional pairing. The training spike window carried on the decoder is provenance. Supply the prediction recording's observation windows explicitly to predict.

The count-array route has already applied its time selection upstream. For multiple recording runs, bin each run independently and retain the corresponding centers; a single start/stop grid would span the pauses.

Workflow 3: Region-Based Analysis

Analyzing behavior across experimentally-defined spatial zones.

Overview

Goal: Compare neural activity and behavior across different regions of the environment

Steps: Define regions → Compute metrics per region → Statistical comparison

Complete Example

from neurospatial import Environment
from shapely.geometry import Point
import numpy as np

# Create environment from position data
env = Environment.from_samples(position_data, bin_size=3.0)

# Define experimental regions
# Center zone (15 cm radius circle)
center_point = Point(50.0, 50.0)  # Arena center
env.regions.add("Center", polygon=center_point.buffer(15.0))

# Corner zones (10x10 cm squares)
corners = {
    "TopLeft": [(0, 90), (10, 90), (10, 100), (0, 100)],
    "TopRight": [(90, 90), (100, 90), (100, 100), (90, 100)],
    "BottomLeft": [(0, 0), (10, 0), (10, 10), (0, 10)],
    "BottomRight": [(90, 0), (100, 0), (100, 10), (90, 10)],
}

for name, coords in corners.items():
    from shapely.geometry import Polygon
    env.regions.add(name, polygon=Polygon(coords))

# Find which bins belong to each region
region_bins = {}
for region_name in env.regions.list_names():
    region_polygon = env.regions[region_name].polygon
    bins_in_region = []

    for bin_idx in range(env.n_bins):
        bin_point = Point(env.bin_centers[bin_idx])
        if region_polygon.contains(bin_point):
            bins_in_region.append(bin_idx)

    region_bins[region_name] = np.array(bins_in_region)
    print(f"{region_name}: {len(bins_in_region)} bins")

# Compute occupancy per region
position_bins = env.bin_at(position_data)
sampling_rate = 30.0  # Hz

region_occupancy = {}
for region_name, bins in region_bins.items():
    time_in_region = np.sum(np.isin(position_bins, bins)) / sampling_rate
    region_occupancy[region_name] = time_in_region
    print(f"Time in {region_name}: {time_in_region:.2f} seconds")

# Compute firing rate per region
spike_positions = interpolate_position(position_data, spike_times)
spike_bins = env.bin_at(spike_positions)

region_firing_rates = {}
for region_name, bins in region_bins.items():
    spikes_in_region = np.sum(np.isin(spike_bins, bins))
    time_in_region = region_occupancy[region_name]

    if time_in_region > 0.5:  # Require 0.5s minimum
        firing_rate = spikes_in_region / time_in_region
        region_firing_rates[region_name] = firing_rate
    else:
        region_firing_rates[region_name] = np.nan

    print(f"{region_name} firing rate: {firing_rate:.2f} Hz")

# Statistical comparison
# Example: Is firing rate higher in center vs. corners?
center_rate = region_firing_rates["Center"]
corner_rates = [region_firing_rates[name] for name in corners.keys()]
corner_rates = [r for r in corner_rates if not np.isnan(r)]

print(f"\nCenter: {center_rate:.2f} Hz")
print(f"Corners: {np.mean(corner_rates):.2f} ± {np.std(corner_rates):.2f} Hz")

# Visualize regions
fig, ax = plt.subplots(figsize=(8, 8))
env.plot(ax=ax)

# Color-code regions
colors = plt.cm.Set3(np.linspace(0, 1, len(env.regions)))
for idx, region_name in enumerate(env.regions.list_names()):
    region = env.regions[region_name]
    if region.polygon:
        x, y = region.polygon.exterior.xy
        ax.fill(x, y, alpha=0.3, color=colors[idx], label=region_name)

ax.legend()
ax.set_title('Experimental Regions')
plt.show()

Workflow 4: Multi-Session Alignment

Comparing environments across recording sessions.

Overview

Goal: Align spatial representations from different sessions to track stability

Steps: Create environments for each session → Align using transforms → Compare firing patterns

Complete Example

import numpy as np
import matplotlib.pyplot as plt

from neurospatial import Environment
from neurospatial.encoding import compute_spatial_rate
from neurospatial.ops import map_probabilities

# Session 1 (reference)
env1 = Environment.from_samples(
    session1_position,
    bin_size=2.5,
    name="Session1",
)
firing_rate1 = compute_spatial_rate(
    env1, session1_spikes, session1_times, session1_position,
    method="diffusion_kde", bandwidth=5.0, min_occupancy=0.5,
).firing_rate

# Session 2 (may have slight camera shift or animal positioning differences)
env2 = Environment.from_samples(
    session2_position,
    bin_size=2.5,
    name="Session2",
)
firing_rate2 = compute_spatial_rate(
    env2, session2_spikes, session2_times, session2_position,
    method="diffusion_kde", bandwidth=5.0, min_occupancy=0.5,
).firing_rate

# Align session 2 to session 1 coordinate frame
firing_rate2_aligned = map_probabilities(
    source_env=env2,
    target_env=env1,
    source_probabilities=firing_rate2
)

# Compute spatial correlation
valid_bins = ~np.isnan(firing_rate1) & ~np.isnan(firing_rate2_aligned)
correlation = np.corrcoef(
    firing_rate1[valid_bins],
    firing_rate2_aligned[valid_bins]
)[0, 1]

print(f"Spatial correlation: {correlation:.3f}")

# Visualize comparison
fig, axes = plt.subplots(1, 3, figsize=(15, 5))

# Session 1
axes[0].scatter(env1.bin_centers[:, 0], env1.bin_centers[:, 1],
                c=firing_rate1, s=50, cmap='hot')
axes[0].set_title('Session 1')

# Session 2 (aligned)
axes[1].scatter(env1.bin_centers[:, 0], env1.bin_centers[:, 1],
                c=firing_rate2_aligned, s=50, cmap='hot')
axes[1].set_title('Session 2 (aligned)')

# Difference
difference = firing_rate2_aligned - firing_rate1
axes[2].scatter(env1.bin_centers[:, 0], env1.bin_centers[:, 1],
                c=difference, s=50, cmap='RdBu_r',
                vmin=-np.nanmax(np.abs(difference)),
                vmax=np.nanmax(np.abs(difference)))
axes[2].set_title(f'Difference (r={correlation:.3f})')

plt.tight_layout()
plt.show()

Workflow 5: Track Linearization

Analyzing maze experiments with branching structures.

Overview

Goal: Convert 2D maze positions to 1D linearized coordinates for sequential analysis

Steps: Define track graph → Create 1D environment → Map positions → Analyze

See the complete example in examples/05_track_linearization.ipynb.

Direction-specific fields on a graph track

Linearization supplies coordinates along track geometry. On a single-edge track, a return traversal reuses the same coordinates and bins; it does not automatically become a separate directional map. For a branched graph, assignment also depends on the graph and projection settings.

Use explicit trial labels to condition firing on direction. This complete 60-second example plants a field at 60 cm outbound and 40 cm inbound, then checks each recovered peak against its own ground truth within one 5 cm bin. Replace the synthetic positions and spikes with your recorded arrays for an experiment. The two panels share the same environment.

import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
from shapely.geometry import Polygon

from neurospatial import Environment
from neurospatial.behavior import goal_pair_direction_labels, segment_trials
from neurospatial.encoding import compute_directional_place_fields
from neurospatial.simulation import generate_poisson_spikes

# A 100 cm track: graph coordinates describe geometry, not running direction.
graph = nx.Graph()
graph.add_node(0, pos=(0.0, 0.0))
graph.add_node(1, pos=(100.0, 0.0))
graph.add_edge(0, 1, distance=100.0)
env = Environment.from_graph(graph, edge_order=[(0, 1)], edge_spacing=0.0, bin_size=5.0)
env.units = "cm"
repeated = np.array([[25.0, 0.0], [50.0, 0.0], [75.0, 0.0], [50.0, 0.0], [25.0, 0.0]])
np.testing.assert_allclose(env.to_linear(repeated), repeated[:, 0])
repeated_bins = env.bin_at(repeated)
assert repeated_bins[0] == repeated_bins[-1] and repeated_bins[1] == repeated_bins[-2]

# Six out-and-back cycles in 60 seconds, with different planted field centers.
times = np.arange(0.0, 60.0, 0.05)
phase = (times % 10.0) / 10.0
x = 10.0 + 80.0 * (1.0 - np.abs(2.0 * phase - 1.0))
positions = np.column_stack([x, np.zeros_like(x)])
planted_center = np.where(phase < 0.5, 60.0, 40.0)
intensity = 0.5 + 25.0 * np.exp(-0.5 * ((x - planted_center) / 10.0) ** 2)
spikes = generate_poisson_spikes(intensity, times, seed=7)

# Explicit trials supply the direction labels; both maps use the same graph.
env.regions.add("home", polygon=Polygon([(-1, -5), (15, -5), (15, 5), (-1, 5)]))
env.regions.add("goal", polygon=Polygon([(85, -5), (101, -5), (101, 5), (85, 5)]))
position_bins = env.bin_sequence(times, positions, dedup=False)
outbound = segment_trials(position_bins, times, env, start_region="home", end_regions=["goal"])
inbound = segment_trials(position_bins, times, env, start_region="goal", end_regions=["home"])
labels = goal_pair_direction_labels(times, outbound + inbound)
fields = compute_directional_place_fields(env, spikes, times, positions, labels)

fig, axes = plt.subplots(1, 2, figsize=(10, 3), constrained_layout=True)
for ax, label, truth in zip(axes, ["home→goal", "goal→home"], [60.0, 40.0], strict=True):
    field = fields.firing_rates[label]
    recovered = env.bin_centers[np.nanargmax(field), 0]
    assert abs(recovered - truth) <= 5.0  # Recover each planted center within one bin.
    env.plot_field(field, ax=ax, colorbar_label="Firing rate (Hz)")
    ax.set_title(f"{label}: peak {recovered:.1f} cm")
    print(f"{label}: planted {truth:.1f} cm, recovered {recovered:.1f} cm")
plt.show()

Workflow 6: Shared Units for Decoding and Population Statistics

Simulate a labeled population, estimate spatial maps from four raw arrays, and use one unit selection for both downstream branches. The earlier period is the baseline control, the middle period is the encoding/statistical reference (template), and the last period is the match. These are synthetic navigation periods, not a claim of sleep replay. All models here are place cells; covariance patterns can reflect their shared spatial drive.

Branch Inputs Output
Position decoding Template rate maps and match spike counts, in the same unit order Posterior over spatial bins
Population statistics Control, template and match count matrices, in the same unit order Patterns, standardized activations and EV/controlled REV effect sizes

A posterior is not an input to assembly or EV analysis. Remove constant units once across all periods, then apply the same mask to spike trains, map rows, count columns and unit IDs. The silent unit below demonstrates the common nonzero-variance selection. Do not independently filter each period: equal column counts alone would not ensure that they represent the same neurons.

import numpy as np
from shapely.geometry import box

from neurospatial import Environment, compute_spatial_rates, decode_position
from neurospatial.decoding import (
    assembly_activation,
    bin_spikes_in_time,
    detect_assemblies,
    explained_variance_reactivation,
    pairwise_correlations,
    reactivation_strength,
)
from neurospatial.simulation import (
    PlaceCellModel,
    generate_poisson_spikes,
    simulate_trajectory_ou,
)

env = Environment.from_polygon(box(0, 0, 100, 100), bin_size=5.0)
env.units = "cm"
positions, times = simulate_trajectory_ou(
    env, duration=180.0, dt=1 / 30, speed_units="cm", seed=7
)
centers = np.array(
    [
        [30, 35],
        [32, 37],
        [34, 33],
        [70, 70],
        [72, 68],
        [68, 72],
        [50, 50],
        [53, 50],
        [50, 53],
    ]
)
trains = [
    generate_poisson_spikes(
        PlaceCellModel(
            env, center=center, width=12, max_rate=15, baseline_rate=0.5
        ).firing_rate(positions, times),
        times,
        seed=10 + i,
    )
    for i, center in enumerate(centers)
]
trains.append(np.empty(0, dtype=np.float64))
unit_ids = np.r_[np.arange(101, 110), 999]
dt = 0.1
windows = {"control": (0.0, 60.0), "template": (60.0, 120.0), "match": (120.0, 180.0)}
counts, centers_by_period = {}, {}
for period, (start, stop) in windows.items():
    counts[period], centers_by_period[period] = bin_spikes_in_time(
        trains, dt, t_start=start, t_stop=stop
    )
# Select once across every period, preserving original unit order.
keep = np.logical_and.reduce([np.var(matrix, axis=0) > 0 for matrix in counts.values()])
selected_ids = unit_ids[keep]
selected_trains = [
    train for train, retained in zip(trains, keep, strict=True) if retained
]
counts = {period: matrix[:, keep] for period, matrix in counts.items()}
assert len(selected_ids) >= 3 and 999 not in selected_ids
# Four-array encoding on the template; metadata carries the selected IDs.
rates = compute_spatial_rates(
    env,
    selected_trains,
    times,
    positions,
    unit_ids=selected_ids,
    epochs=[windows["template"]],
    spike_window=[(0.0, 180.0)],
    bandwidth=5.0,
    fill_value=0.0,
)
np.testing.assert_array_equal(rates.unit_ids, selected_ids)
# Branch 1: maps + count columns in the same selected order.
decoded = decode_position(
    env, counts["match"], rates, dt, times=centers_by_period["match"]
)
assert decoded.posterior.shape == (len(centers_by_period["match"]), env.n_bins)
np.testing.assert_allclose(decoded.posterior.sum(axis=1), 1.0)
# Branch 2: counts, not the decoded posterior.
assemblies = detect_assemblies(counts["template"], algorithm="pca", rng=0)
print("Retained unit IDs:", selected_ids.tolist())
print("Dimensions above Marchenko-Pastur reference:", assemblies.n_significant)
for pattern in assemblies.patterns:
    print("Thresholded core member IDs:", selected_ids[pattern.member_indices].tolist())
    activation = assembly_activation(counts["match"], pattern)
    assert len(activation) == len(centers_by_period["match"])
    strength = reactivation_strength(counts["template"], counts["match"], pattern)
    print("Standardized activation mean/SD:", activation.mean(), activation.std())
    print("Activation magnitude ratio (effect size):", strength)
correlations = {
    period: pairwise_correlations(matrix) for period, matrix in counts.items()
}
reactivation = explained_variance_reactivation(
    correlations["template"],
    correlations["match"],
    control_correlations=correlations["control"],
)
print(
    "EV, controlled REV (effect sizes):",
    reactivation.explained_variance,
    reactivation.reversed_ev,
)
assert np.isfinite(reactivation.explained_variance) and np.isfinite(
    reactivation.reversed_ev
)

n_significant counts dimensions above a random-matrix reference, not neurons with calibrated p-values. If no dimension passes, the algorithm may still return an exploratory pattern. Pattern member_indices are positions in the selected unit order; mapping them through selected_ids recovers the original labels. A pattern can have no core members at the default absolute-weight z-score cutoff, even when its dimension passes the reference.

assembly_activation standardizes the projection within each period, removing absolute projection scale; its output does not give a tail probability. In contrast, reactivation_strength normalizes both count matrices with the template's neuron means/standard deviations and projects onto the same pattern without separate projection standardization. Its ratio preserves relative magnitude on that shared template scale, but remains an effect size rather than a calibrated tail probability.

Controlled EV is r(template, match | control)^2, while controlled REV is r(control, match | template)^2. EV > REV is an effect-size comparison, not calibrated reactivation significance. Report control choice, preprocessing, unit selection and a separately justified null when a significance claim is needed.

Workflow 7: One Event Cohort Across a Gapped Recording

Choose events whose entire peri-event window fits both the spike recording and the analysis epochs. Keep the original identifiers and selection mask in a table, then reuse the selected timestamps for every view of those events. A PSTH can otherwise retain fewer events than a raster or positioned-event table.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from neurospatial.events import (
    add_positions, align_spikes_to_events, peri_event_histogram,
    event_count_in_window, event_indicator,
)

times = np.r_[np.arange(100) / 10, 20 + np.arange(100) / 10]
positions = np.c_[times, np.zeros(len(times))]
spike_times = np.array([4.0, 5.0, 6.0, 24.0, 25.0, 26.0])
events = pd.DataFrame({
    "event_id": ["reward-a", "reward-b", "reward-c", "reward-d"],
    "timestamp": [5.0, 9.5, 25.0, 29.5],
})
spike_window = np.array([[0.0, 10.0], [20.0, 30.0]])
epochs = np.array([[1.0, 9.0], [21.0, 29.0]])
window = (-1.0, 1.0)
starts = events["timestamp"].to_numpy() + window[0]
stops = events["timestamp"].to_numpy() + window[1]

fits_recording = (
    (starts[:, None] >= spike_window[:, 0])
    & (stops[:, None] <= spike_window[:, 1])
).any(axis=1)
fits_analysis = (
    (starts[:, None] >= epochs[:, 0]) & (stops[:, None] <= epochs[:, 1])
).any(axis=1)
events["retained"] = fits_recording & fits_analysis
selected = events.loc[events["retained"]].copy()
event_times = selected["timestamp"].to_numpy()

psth = peri_event_histogram(
    spike_times, event_times, window=window, bin_size=0.2,
    epochs=epochs, spike_window=spike_window,
)
aligned = align_spikes_to_events(spike_times, event_times, window=window)
fig, ax = plt.subplots()
ax.eventplot(aligned, lineoffsets=np.arange(len(event_times)))
ax.set_yticks(np.arange(len(event_times)), selected["event_id"])
ax.set_xlabel("Time from event (s)")

regressors = pd.DataFrame({
    "timestamp": times,
    "event_count": event_count_in_window(times, event_times, window=window),
    "event_present": event_indicator(times, event_times, window=window),
})
positioned = add_positions(selected, times=times, positions=positions, epochs=epochs)
table = events.join(positioned[["x", "y"]])
assert psth.n_events == len(aligned) == len(positioned) == 2
assert selected["event_id"].tolist() == ["reward-a", "reward-c"]
assert regressors["event_count"].dtype == np.int64
assert regressors["event_present"].dtype == np.bool_
print(table.to_string(index=False))

# The cohort is shared; the helpers keep their documented edge rules.
assert psth.histogram.sum() == 2.0  # Average of two spikes per event.
assert [len(row) for row in aligned] == [3, 3]  # Includes the +1 s spike.
assert event_count_in_window(
    np.array([4.0, 6.0]), np.array([5.0]), window=window,
).tolist() == [1, 1]  # Both adjacent windows include the boundary event.
plt.close(fig)

PSTHs count spikes on [event + start, event + stop) and exclude the stop edge. Raster alignment includes that stop edge. Event-count and indicator windows include both edges, so neighboring sample windows can count the same boundary event twice. Sharing the event cohort preserves event identity and inclusion; it does not make these spike-edge rules identical. Regressors describe the selected events at each sample; restrict their sample rows to your modeling epochs when fitting a model.

Common Patterns

Pattern: Handling Edge Cases

# Always check for valid bins
bin_indices = env.bin_at(positions)
valid = bin_indices != -1  # -1 indicates point outside environment

# Use only valid data
valid_positions = positions[valid]
valid_bins = bin_indices[valid]

# Or handle invalid gracefully
firing_rate = np.full(env.n_bins, np.nan)
valid_occupancy = occupancy_time > min_threshold
firing_rate[valid_occupancy] = spike_counts[valid_occupancy] / occupancy_time[valid_occupancy]

Pattern: Batch Processing

from neurospatial.encoding import compute_spatial_rates

# Process multiple units efficiently with the batch API.
spike_trains_by_unit_id = load_all_neurons()
unit_ids = list(spike_trains_by_unit_id.keys())
spike_times = list(spike_trains_by_unit_id.values())

result = compute_spatial_rates(
    env, spike_times, times, positions, unit_ids=unit_ids
)
firing_rate_maps = result.firing_rates  # Shape: (n_units, n_bins)

compute_spatial_rates handles spike-to-position interpolation, occupancy normalization, and smoothing for the whole population in one call, returning a SpatialRatesResult whose unit_ids line up with firing_rates. Prefer it over a manual per-neuron loop.

Pattern: Progressive Refinement

# Start with coarse binning for quick overview
env_coarse = Environment.from_samples(positions, bin_size=10.0)
# ... analyze ...

# Refine in regions of interest
env_fine = Environment.from_samples(
    positions,
    bin_size=2.0,
    infer_active_bins=True,
    dilate=True
)
# ... detailed analysis ...

See Also