A top-down pixel-art animation of a mouse running a VR Foraging session, yoked to the trial table of a real asset. Reference asset for this investigation:
s3://aind-open-data/841314_2026-06-03_12-38-21_processed_2026-06-09_02-27-52/
- The trial table in
behavior.nwb.zarr/intervals/trialsis everything we need for the animation; we don't need the raw Harp time-series. - The world is 1-D (a linear VR corridor along
start_positioncm). "Mouse runs north" is just a vertical scroll of the world past a fixed mouse sprite at screen-centre. - A new biodata-cache table
vrf_sites(1 row per site, 805 rows for the example session) holds everything; a thinvrf_sessionstable holds per-session roll-ups. - Pixel-art sprites have already been generated and committed to web/public/images/vrf.
- Estimated implementation: one new view module (
web/src/vr_foraging/animation.js), one CSS section, one new cache table — no new framework dependencies.
The asset is small (~3 MB of metadata; trial table is 805 rows). Loaded via zarr.open_group(..., use_consolidated=False) (zarr-v3 chokes on this NWB's consolidated metadata block, but per-array access works fine).
The "trials" table is per-site, not per-decision. Each row is one of three site types that the mouse traverses sequentially as it runs down the linear corridor:
site_label |
Meaning | Typical length |
|---|---|---|
InterPatch |
Long empty corridor between patches | ~200 – 600 cm |
InterSite |
Short corridor between two reward sites inside one patch | ~20 – 90 cm |
RewardSite |
The actual odor zone — fixed length, mouse can stop & lick | exactly 50 cm |
Sites flow in a strict pattern within each patch:
InterPatch → InterSite → RewardSite → InterSite → RewardSite → … → next InterPatch
| Field | Type | Description |
|---|---|---|
start_time, stop_time |
float (s) | Harp clock; subtract start_time.min() for a session-relative timeline. |
start_position, length |
float (cm) | Position along the 1-D corridor. Monotonic across the session (max ≈ 489 m here). |
site_label |
str | InterPatch / InterSite / RewardSite. |
patch_label |
str | Categorical odor identity. In this session: odor_0, odor_60, odor_90. |
patch_index, block_index |
int | Patch and block IDs within the session. |
site_in_patch_index |
int | 0-based site number inside its patch (mixes InterSite + RewardSite). |
site_by_type_in_patch_index |
int | 0-based site number among the same type — for RewardSite this is the depletion step. |
odor_concentration |
float[3] | One-hot in this session: (1,0,0), (0,1,0), or (0,0,1) — three odor channels. |
odor_onset_time |
float (s) | When the valve opened on entry; null on non-reward sites. |
reward_probability |
float | Reward probability at the time of the choice (already decremented by depletion). |
has_choice |
bool | Mouse stopped in the site → a decision was registered. |
choice_cue_time |
float (s) | When the stop / decision happened. |
has_reward |
bool | Reward actually delivered (subset of has_choice). |
reward_onset_time |
float (s) | Valve open time for the reward (null when no reward). |
reward_amount |
float | Configured volume (µL); not necessarily what was delivered — pair with has_reward. |
reward_delay_duration |
float (s) | reward_onset_time − choice_cue_time. |
has_waited_reward_delay |
bool | Mouse waited long enough at the lick port to collect. |
reward_available |
float | Reward volume still in patch (sentinel 9999.0 here = effectively infinite). |
friction |
float (%) | Treadmill friction at the site. |
- Duration: 53.0 min (
start_timespans 3 177 s). - Sites: 805 total → 54
InterPatch+ 402InterSite+ 349RewardSite. - Patches: 54 in 1 block.
- Patch label mix:
odor_90(most common, also highest baseline reward prob),odor_60(medium),odor_0(control — mouse never licks; 1 reward site per patch, 0 choices). - Decisions: 296 stops; 147 rewards delivered.
- Depletion demonstration (patch 0,
odor_60):0.60 → 0.528 → 0.464 → 0.408 → 0.359 → 0.359 → 0.359 → 0.359 → 0.316 → 0.316 → 0.316 → 0.278 → …→ depletion steps down with the number of consecutive successful licks, not raw site count (which matches the underlying Beta-distribution depletion model in the task).
- Smooth top-down scrolling world keyed to real timestamps.
- Distinct visual blocks for
InterPatch(dark void) and patches (lit ground). - Discrete odor-site markers, coloured by
patch_label/odor_concentration. - Run-cycle animation while the mouse is moving; idle/lick pose when stopped.
- Lick-burst sparkle at
choice_cue_time; reward drop pop atreward_onset_timewhenhas_reward=true. - Live HUD: patch # / site # / cumulative rewards / current reward probability / running foraging efficiency.
- Per-patch depletion bar (sub-window in the corner showing the next-N reward probabilities as a bar chart).
- A scrubbable timeline at the bottom that maps to
start_time → stop_time. - Playback speed control (default 10×) since 53 min real time would be tedious to watch.
- Continuous mouse velocity & licking trace. The trial table only has start/stop and choice/onset events — not per-lick timestamps or per-sample wheel velocity. The animation interpolates position linearly inside each site, which is plausible (≈25 cm/s average) but not literally true (the mouse decelerates into a stop, sits for ~2 s, accelerates out).
- If we ever want true velocity, it's in
acquisition/Behavior.HarpBehavior.AnalogData/Encoder(treadmill encoder), and licks are inacquisition/Behavior.HarpLickometer.LickState. Out of scope for v1 — too much data, no cache table for it yet.
- If we ever want true velocity, it's in
- Camera / video frames. Cameras 0/1 are recorded but not in the cache pipeline; we never want to ship video.
- "Turning" when the mouse exits a patch. The corridor is genuinely 1-D, the mouse never turns. We can fake a small camera-shake / fade-to-void transition for visual flair, but no real turn-to-next-patch motion exists.
┌───────────────────────────────────────────────────────────────────────┐
│ Patch 12/54 · odor_90 · Site 5/7 · Reward 67/147 · η = 0.71 │ ← HUD
├──────────────────────┬────────────────────────────────────────────────┤
│ │ ▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌ │
│ │ depletion bar for current patch │
│ ├────────────────────────────────────────────────┤
│ [scrolling │ ▼ current site │
│ world canvas] │ │
│ mouse │ ⬤ ⬤ ⬤ ⬤ ⬤ ◯ ◯ ◯ ◯ ◯ ◯ │
│ fixed at │ past rewards ↑ ↑ future sites │
│ centre │ │
│ ├────────────────────────────────────────────────┤
│ │ [▶ ⏸ ] speed 10× ━━━━━●━━━━━━━━━ 00:23 / 53:00 │
└──────────────────────┴────────────────────────────────────────────────┘
The main canvas is the world panel (left ~⅔ width). The right column is a HUD/dashboard (depletion, future-sites strip, transport controls).
- Single
<canvas>element, width 200 logical px × height 360 logical px, then upscaled withimage-rendering: pixelatedto whatever the layout gives us. Keeps the pixel-art feel and is dirt-cheap to draw (~72 000 logical pixels). - The mouse sprite stays pinned at, say, y = 256 (near bottom). The world scrolls upward (north) past it.
worldY = scrollY − mousePosCm * PX_PER_CM. PX_PER_CM ≈ 1.0so a 50 cm reward site is ~50 px tall, a 400 cm InterPatch is one whole screen tall.- Background =
void_tilerepeated. Inside anInterPatchsegment, draw the void. Inside a patch segment (InterSite+RewardSiterows), tilepatch_tile. - For each
RewardSite, draw theodor_swirl_{60,90,0}.svgsprite centred on the site, with the swirl tinted perpatch_label. Add a faint glow halo whose alpha =reward_probability(so depleted sites visibly fade).
- State machine with four states:
running,stopping,licking,leaving. running→ cycle framesmouse_run_a↔mouse_run_bevery ~120 ms (visual cadence; not yoked to actual stride frequency).stopping→ swap tomouse_idlefor a short pre-choice window (~0.3 s).licking→ swap tomouse_lickfor the choice window. Spawn alick_burstsprite at the nose for 100 ms each lick beat (3–5 beats insidereward_delay_duration).- After
reward_onset_timeifhas_reward, spawn areward_dropsprite that rises from the nose and dissolves. leaving→ back tomouse_run_*cycle; small "puff" particle would be nice but isn't strictly needed.
Each site row gives us [start_time, stop_time] and [start_position, start_position+length]. For a given session wall-clock t, find the active site (binary search on start_time), then:
const frac = (t - site.start_time) / (site.stop_time - site.start_time);
let pos = site.start_position + frac * site.length;Override this for stop events: when choice_cue_time is in [start_time, stop_time], hold position at start_position + 0.5 * length (centre of the reward site) for the window [choice_cue_time, choice_cue_time + reward_delay_duration + 0.4] — that's the "stopped & licking" window.
Playback speed is just t = realtime * speedMultiplier; speedMultiplier defaults to 10×, slider goes from 1× to 60×. Pause/play/scrub all operate on t.
- Future-sites strip — vertical column of dots, top = far ahead, bottom = current site. Filled discs for past RewardSites coloured by
has_reward; outlined discs for upcoming. Scrolls down with playback. - Depletion bar — small horizontal bar chart of
reward_probabilityfor every RewardSite in the current patch, with the active one highlighted. Trivial Observable Plot call (≤ 30 bars). - Counters — patch index, site index in patch, cumulative rewards, cumulative volume (
5 µL × has_reward.sum()), running foraging efficiency.
- Subject + session selector at the top of the page (existing
asset_basicsquery, filtered toacquisition_type = 'AindVrForaging'). - Play / pause button, speed slider, scrub-bar with mm:ss readout.
- Keyboard:
space= play/pause,←/→= jump ±1 patch (handy for QA),,/.= ±0.5× speed.
Mirrors the existing foraging_sessions acorn pattern (see web/src/lib/behaviors/foraging-metadata.js). Two tables published as parquet by biodata-cache and registered as DuckDB tables via cache_registry.json.
Used for the page's session selector and to gate the heavier query on vrf_sites.
| Column | Type | Source |
|---|---|---|
name |
varchar | asset_basics.name (join key) |
subject_id |
varchar | |
session_date |
date | derived from acquisition_start_time |
session_duration_s |
double | max(stop_time) - min(start_time) |
n_patches |
integer | max(patch_index) + 1 |
n_blocks |
integer | max(block_index) + 1 |
n_reward_sites |
integer | count(site_label = 'RewardSite') |
n_choices |
integer | sum(has_choice) |
n_rewards |
integer | sum(has_reward) |
total_reward_volume_ul |
double | sum(reward_amount * has_reward) (assuming µL) |
foraging_efficiency |
double | computed; or NULL if not derivable from trials alone |
patch_labels |
varchar[] | distinct patch_label values |
corridor_length_cm |
double | max(start_position) + last_site_length |
nwb_zarr_path |
varchar | S3 URI to the source zarr (so we can deep-link) |
This is essentially a flattened copy of intervals/trials. With ~600 cached sessions × ~800 rows each ≈ 500 K rows total — small parquet, easy.
| Column | Type | Notes |
|---|---|---|
name |
varchar | FK to vrf_sessions.name and asset_basics.name. |
site_index |
integer | Primary ordering within a session. |
block_index |
integer | |
patch_index |
integer | |
patch_in_block_index |
integer | |
site_in_patch_index |
integer | |
site_by_type_in_patch_index |
integer | Depletion step number for RewardSite rows. |
site_label |
varchar | InterPatch / InterSite / RewardSite. |
patch_label |
varchar | Categorical odor identity. |
start_time_s |
double | Use session-relative time: subtract min(start_time) before caching, so the player doesn't have to. Store the offset in vrf_sessions.session_t0. |
stop_time_s |
double | |
start_position_cm |
double | |
length_cm |
double | |
friction_pct |
double | |
odor_onset_time_s |
double | NULL on non-reward sites. |
odor_channel_0 |
double | Unrolled from odor_concentration[0..2] — easier |
odor_channel_1 |
double | than handling list columns in DuckDB. |
odor_channel_2 |
double | |
reward_amount_ul |
double | |
reward_probability |
double | |
reward_available |
double | |
has_reward |
boolean | |
choice_cue_time_s |
double | |
has_choice |
boolean | |
reward_delay_duration_s |
double | |
has_waited_reward_delay |
boolean | |
reward_onset_time_s |
double |
Pre-bake decisions worth making in the cache builder
- Subtract
start_time.min()sostart_time_sis 0-based; saves a normalisation step in every query. - Drop fully-empty sentinel rows. A few tail rows in this session show
stop_timeslightly past the actual session end; clip them to a sane bound. - Sentinel hygiene.
reward_available = 9999.0is "infinite"; replace with NULL so the chart doesn't blow up its y-axis. - Sort by
(name, site_index)before writing parquet so a single-asset query reads one contiguous row-group.
Sample queries the page would issue
-- Session selector dropdown (cheap, joins on asset_basics)
SELECT s.name, s.subject_id, s.session_date,
s.n_patches, s.n_rewards, s.session_duration_s
FROM vrf_sessions s
JOIN asset_basics a USING (name)
WHERE a.acquisition_type = 'AindVrForaging'
ORDER BY s.session_date DESC;
-- All sites for the selected session (≤ 1000 rows; <100 KB)
SELECT * FROM vrf_sites
WHERE name = ?
ORDER BY site_index;| Path | Purpose |
|---|---|
| scripts/build_vrf_sprites.py | ✅ already added — regenerates SVG sprites from pixel grids. |
| web/public/images/vrf/*.svg | ✅ already generated (11 sprites, ~90 KB total). |
web/src/vr_foraging/animation.js |
New view module: canvas renderer + state machine + HUD. |
web/src/vr_foraging/data.js |
Query helpers: loadSessionList(), loadSites(name), parsing. |
web/src/vr_foraging/view.js |
Modified — add a "Session animation" subview above the existing platform overview. |
web/styles/app.css |
Append .vrf-animation, .vrf-canvas, .vrf-hud-* rules. |
web/src/__tests__/vrf-animation.test.js |
Pure-function tests for the time→position interpolator and site-finder. |
Plus on the cache side (separate biodata-cache repo):
| Path / change | Purpose |
|---|---|
biodata_cache/builders/vrf_sites.py |
Read behavior.nwb.zarr/intervals/trials per asset, flatten odor_concentration, normalise times, write parquet. |
biodata_cache/builders/vrf_sessions.py |
Aggregate from vrf_sites → vrf_sessions. |
cache_registry.json entries |
Two new acorns: vrf_sessions (metadata), vrf_sites (metadata). |
- Cache builder (Python, in
biodata-cache): implementvrf_sites.py+vrf_sessions.pyusing the same zarr-without-consolidated-metadata trick this investigation used. Publish a draft acorn to the dev S3 prefix. data.js+ tests: registry/query layer, mirrorsforaging-metadata.js.- Static world rendering:
animation.jsv0 — given a sites array, draw the world panel att=0and let the user scrub with the slider. No mouse animation yet. - Mouse + run cycle: implement the state machine. Interpolation only; no lick burst / reward drop.
- Lick + reward events, HUD, future-sites strip, depletion bar.
- Polish: dark-theme support (the sprites are already mid-tone so they work on both), keyboard shortcuts, smooth scrubbing.
- One redraw per
requestAnimationFrame(~60 fps). Each frame: ≤ 200×360 = 72 000 pixels written; only visible tiles drawn; everything else is a small sprite blit. Trivial. - Loading a session = one DuckDB SELECT returning ≤ 1 000 rows. The whole experience never re-queries during playback.
Generated by scripts/build_vrf_sprites.py and written to web/public/images/vrf. All sprites are tiny SVGs (1-px-per-<rect> grids on a fixed viewBox) so they upscale crisply at any size and dark-/light-theme don't need rebuilds.
| File | Size | What it is |
|---|---|---|
mouse_run_a.svg |
16×16 | Run cycle frame A (forepaws splayed). |
mouse_run_b.svg |
16×16 | Run cycle frame B (hindlegs back). |
mouse_idle.svg |
16×16 | Standing still pre-stop. |
mouse_lick.svg |
16×16 | Pink tongue extended north. |
reward_drop.svg |
8×10 | Water drop popping up after a reward. |
lick_burst.svg |
12×12 | Yellow radial sparkle at the choice moment. |
odor_swirl_0.svg |
16×16 | Odor-marker ring, grey (control / no reward). |
odor_swirl_60.svg |
16×16 | Odor-marker ring, amber (odor_60). |
odor_swirl_90.svg |
16×16 | Odor-marker ring, azure (odor_90). |
patch_tile.svg |
16×16 | Tileable lit-green patch ground. |
void_tile.svg |
16×16 | Tileable dark inter-patch void. |
To regenerate or iterate on the art, edit the string-art grids in scripts/build_vrf_sprites.py and rerun .venv/bin/python scripts/build_vrf_sprites.py.
- Mapping
patch_label→ colour. I picked amber forodor_60and azure forodor_90arbitrarily. If there's an established colour convention from the lab (e.g. reflecting the literal odor chemistry), let me know and I'll swap. Answer: the colors are fine for now - µL or mL for reward.
reward_amountis5.0per row; almost certainly 5 µL (the standard VRF reward), but the column description doesn't disambiguate. I'll assume µL unless told otherwise. Answer: Yes it's 5 microliters - What sits next to the animation on the existing
/vr_foragingplatform page? Options: (a) replace the current overview, (b) put it in a new collapsible card above the overview, (c) make it a separate/vr_foraging/playroute. My default is (b). Answer: Don't worry about implementing the animation on the page just make a standalone page for now - How many sessions per page load? I assume the user selects one session at a time. If we want a "compare two animations side-by-side" mode it's the same components × 2 — easy but not specced. Answer: one session, but for testing we're going to use the exact session we worked with.