From 41dd61d5a43d8d68f8a3d2f84544668635f396ed Mon Sep 17 00:00:00 2001 From: Vincent Date: Fri, 21 Aug 2026 16:59:33 -0400 Subject: [PATCH] Add Figure 2 notebook for Dandiset 001687 (peri-head distance coding in PrV) --- .../wanglab/figure2-distance-tuning/README.md | 32 + .../figure2_distance_tuning.ipynb | 594 ++++++++++++++++++ .../figure2-distance-tuning/requirements.in | 7 + 3 files changed, 633 insertions(+) create mode 100644 001687/wanglab/figure2-distance-tuning/README.md create mode 100644 001687/wanglab/figure2-distance-tuning/figure2_distance_tuning.ipynb create mode 100644 001687/wanglab/figure2-distance-tuning/requirements.in diff --git a/001687/wanglab/figure2-distance-tuning/README.md b/001687/wanglab/figure2-distance-tuning/README.md new file mode 100644 index 0000000..cad5ff3 --- /dev/null +++ b/001687/wanglab/figure2-distance-tuning/README.md @@ -0,0 +1,32 @@ +# Figure 2: peri-head distance coding in the mouse brainstem + +Reproduces the Figure 2 panels of Xiao, Severson, et al. (2026), +*Peri-Head Distance Coding in the Mouse Brainstem*, from +[Dandiset 001687](https://dandiarchive.org/dandiset/001687). + +The notebook streams six processed session files with `remfile`, so it never +downloads a whole asset, and draws three panels per unit: + +- **Rasters** aligned to wall-pass onset, coloured by wall distance +- **PETHs** recomputed from spike times, one trace per distance, mean +/- SEM +- **Tuning curves** read from `processing/wall_tuning` + +The six example units span the three response classes described in the paper: +one *proximity* unit whose firing rises monotonically as the wall nears, four +*map* units tuned to a preferred distance, and one *suppressed* unit. + +## Data + +| | | +|---|---| +| Dandiset | [001687](https://dandiarchive.org/dandiset/001687) | +| Version | `0.260805.1529` (pinned, so the figure is reproducible) | +| Assets read | 6 processed NWB files, ~7 MB each | + +Each session also has a companion raw asset (`_ecephys+image`) carrying the +30 kHz broadband, 1 kHz LFP, digital TTLs and the behaviour video. The notebook +does not need those. + +## Related + +- Manuscript code: diff --git a/001687/wanglab/figure2-distance-tuning/figure2_distance_tuning.ipynb b/001687/wanglab/figure2-distance-tuning/figure2_distance_tuning.ipynb new file mode 100644 index 0000000..9291b33 --- /dev/null +++ b/001687/wanglab/figure2-distance-tuning/figure2_distance_tuning.ipynb @@ -0,0 +1,594 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f85ea8d7", + "metadata": {}, + "source": [ + "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/dandi/example-notebooks/blob/master/001687/wanglab/figure2-distance-tuning/figure2_distance_tuning.ipynb)" + ] + }, + { + "cell_type": "markdown", + "id": "462c4d97", + "metadata": {}, + "source": [ + "## Installing requirements\n", + "\n", + "The cell below installs every Python package needed to run this notebook, at fully pinned versions, using [`uv`](https://github.com/astral-sh/uv) for fast resolution. In Colab the cell is collapsed by default — click the ▶ button to run it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "662da2b6", + "metadata": { + "cellView": "form" + }, + "outputs": [], + "source": [ + "#@title Installing requirements (click ▶ to run) { display-mode: \"form\" }\n", + "# Colab provides Python 3.12. We install with `uv --system` because Colab's\n", + "# kernel runs outside a virtualenv. All versions (direct + transitive) are\n", + "# pinned below so the notebook is reproducible regardless of resolver drift.\n", + "!pip install -q uv\n", + "!uv pip install --system \\\n", + " \"acres==0.5.0\" \\\n", + " \"aiohappyeyeballs==2.6.1\" \\\n", + " \"aiohttp==3.13.5\" \\\n", + " \"aiosignal==1.4.0\" \\\n", + " \"annotated-types==0.7.0\" \\\n", + " \"arrow==1.4.0\" \\\n", + " \"attrs==26.1.0\" \\\n", + " \"bids-validator-deno==3.0.1\" \\\n", + " \"bidsschematools==1.2.7\" \\\n", + " \"blessed==1.38.0\" \\\n", + " \"certifi==2026.4.22\" \\\n", + " \"cffi==2.0.0\" \\\n", + " \"charset-normalizer==3.4.7\" \\\n", + " \"ci-info==0.4.0\" \\\n", + " \"click==8.3.3\" \\\n", + " \"click-didyoumean==0.3.1\" \\\n", + " \"contourpy==1.3.3\" \\\n", + " \"cryptography==43.0.3\" \\\n", + " \"cycler==0.12.1\" \\\n", + " \"dandi==0.77.0\" \\\n", + " \"dandischema==0.14.0\" \\\n", + " \"deno==2.9.5\" \\\n", + " \"dnspython==2.8.0\" \\\n", + " \"donfig==0.8.1.post1\" \\\n", + " \"email-validator==2.3.0\" \\\n", + " \"etelemetry==0.3.1\" \\\n", + " \"fasteners==0.20\" \\\n", + " \"fonttools==4.62.1\" \\\n", + " \"fqdn==1.5.1\" \\\n", + " \"frozenlist==1.8.0\" \\\n", + " \"fscacher==0.4.4\" \\\n", + " \"fsspec==2025.3.0\" \\\n", + " \"google-crc32c==1.8.0\" \\\n", + " \"h5py==3.16.0\" \\\n", + " \"hdmf==6.2.0\" \\\n", + " \"humanize==4.15.0\" \\\n", + " \"idna==3.13\" \\\n", + " \"interleave==0.3.0\" \\\n", + " \"isodate==0.7.2\" \\\n", + " \"isoduration==20.11.0\" \\\n", + " \"jaraco-classes==3.4.0\" \\\n", + " \"jaraco-context==6.1.2\" \\\n", + " \"jaraco-functools==4.4.0\" \\\n", + " \"jeepney==0.9.0\" \\\n", + " \"joblib==1.5.3\" \\\n", + " \"jsonpointer==3.1.1\" \\\n", + " \"jsonschema==4.26.0\" \\\n", + " \"jsonschema-specifications==2025.9.1\" \\\n", + " \"keyring==25.7.0\" \\\n", + " \"keyrings-alt==5.0.2\" \\\n", + " \"kiwisolver==1.5.0\" \\\n", + " \"matplotlib==3.10.0\" \\\n", + " \"ml-dtypes==0.5.4\" \\\n", + " \"more-itertools==10.8.0\" \\\n", + " \"multidict==6.7.1\" \\\n", + " \"natsort==8.4.0\" \\\n", + " \"numcodecs==0.16.5\" \\\n", + " \"numpy==2.0.2\" \\\n", + " \"nwbinspector==0.7.2\" \\\n", + " \"packaging==26.1\" \\\n", + " \"pandas==2.2.2\" \\\n", + " \"pillow==11.3.0\" \\\n", + " \"platformdirs==4.9.6\" \\\n", + " \"propcache==0.4.1\" \\\n", + " \"pycparser==3.0\" \\\n", + " \"pycryptodomex==3.23.0\" \\\n", + " \"pydantic==2.12.3\" \\\n", + " \"pydantic-core==2.41.4\" \\\n", + " \"pydantic-settings==2.14.0\" \\\n", + " \"pynwb==4.1.0\" \\\n", + " \"pyout==0.8.1\" \\\n", + " \"pyparsing==3.3.2\" \\\n", + " \"python-dateutil==2.9.0.post0\" \\\n", + " \"python-dotenv==1.2.2\" \\\n", + " \"pytz==2025.2\" \\\n", + " \"pyyaml==6.0.3\" \\\n", + " \"referencing==0.37.0\" \\\n", + " \"remfile==0.1.14\" \\\n", + " \"requests==2.32.4\" \\\n", + " \"rfc3339-validator==0.1.4\" \\\n", + " \"rfc3987==1.3.8\" \\\n", + " \"rpds-py==0.30.0\" \\\n", + " \"ruamel-yaml==0.19.1\" \\\n", + " \"secretstorage==3.5.0\" \\\n", + " \"semantic-version==2.10.0\" \\\n", + " \"six==1.17.0\" \\\n", + " \"tenacity==9.1.4\" \\\n", + " \"tensorstore==0.1.82\" \\\n", + " \"tqdm==4.67.3\" \\\n", + " \"typing-extensions==4.15.0\" \\\n", + " \"typing-inspection==0.4.2\" \\\n", + " \"tzdata==2026.1\" \\\n", + " \"uri-template==1.3.0\" \\\n", + " \"urllib3==2.5.0\" \\\n", + " \"wcwidth==0.6.0\" \\\n", + " \"webcolors==25.10.0\" \\\n", + " \"yarl==1.23.0\" \\\n", + " \"zarr==3.1.5\" \\\n", + " \"zarr-checksum==0.4.7\"" + ] + }, + { + "cell_type": "markdown", + "id": "3a6c6ffb", + "metadata": {}, + "source": [ + "> **⚠️ Restart runtime after install**\n", + ">\n", + "> The install may upgrade packages already loaded in the kernel. Go to **Runtime → Restart session**, then **Run all cells below** (skip this install cell on re-run)." + ] + }, + { + "cell_type": "markdown", + "id": "3980ab31", + "metadata": {}, + "source": [ + "# Figure 2: peri-head distance coding in the mouse brainstem\n", + "\n", + "This notebook reproduces the Figure 2 panels of Xiao, Severson, et al. (2026),\n", + "*Peri-Head Distance Coding in the Mouse Brainstem*, directly from\n", + "[Dandiset 001687](https://dandiarchive.org/dandiset/001687), streaming the data\n", + "rather than downloading it.\n", + "\n", + "For each of six example PrV units it draws three panels:\n", + "\n", + "| Panel | Content |\n", + "|---|---|\n", + "| **A. Raster** | Spike times aligned to wall-pass onset, one row per trial, grouped and coloured by wall distance |\n", + "| **B. PETH** | Peri-event time histogram, one trace per wall distance, mean +/- SEM across trials |\n", + "| **C. Tuning curve** | The stored z-scored firing rate versus wall distance from `processing/wall_tuning` |\n", + "\n", + "**The experiment.** A head-fixed mouse sits while a motorised wall approaches and\n", + "passes its head at a controlled distance. Second-order trigeminal (PrV) neurons\n", + "respond to whiskers touching the wall, and report how far away that wall is.\n", + "Two coding schemes appear in the data: *proximity* units that increase firing\n", + "monotonically as the wall nears, and *map* units tuned to a preferred distance.\n", + "\n", + "**Citation.** \n", + "Xiao, Severson, et al. (2026). Peri-head distance coding in the mouse brainstem,\n", + ". \n", + "Dandiset 001687. ." + ] + }, + { + "cell_type": "markdown", + "id": "34365419", + "metadata": {}, + "source": [ + "## 1. What we stream, and why\n", + "\n", + "The Dandiset holds two files per recording session:\n", + "\n", + "- a **processed** file (no modality suffix) with sorted spike times, trials,\n", + " tuning curves and PSTHs. These are small, a few MB each.\n", + "- a **raw** file (`_ecephys+image`) with the 30 kHz broadband, LFP, TTLs and a\n", + " reference to the behaviour video. These are multi-GB.\n", + "\n", + "This notebook only needs the processed files. It reads six of them, one per\n", + "example unit, and never downloads a whole file: `remfile` fetches only the byte\n", + "ranges that `h5py` actually asks for.\n", + "\n", + "We pin the **published version** `0.260805.1529` rather than `draft`, so the\n", + "figure is reproducible even if the Dandiset is later revised." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bdce768b", + "metadata": {}, + "outputs": [], + "source": [ + "import warnings\n", + "\n", + "import h5py\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import pandas as pd\n", + "import remfile\n", + "from dandi.dandiapi import DandiAPIClient\n", + "from matplotlib.colors import Normalize\n", + "from pynwb import NWBHDF5IO\n", + "\n", + "warnings.filterwarnings(\"ignore\", message=\".*namespace.*already exists.*\")\n", + "\n", + "DANDISET_ID = \"001687\"\n", + "DANDISET_VERSION = \"0.260805.1529\" # published snapshot; use \"draft\" for the latest" + ] + }, + { + "cell_type": "markdown", + "id": "dda8aad3", + "metadata": {}, + "source": [ + "## 2. The six example units\n", + "\n", + "Each row below identifies one Figure 2 unit by its DANDI asset path and its\n", + "0-based row in that file's `units` table. This mapping is also distributed as\n", + "`figure_unit_manifest.csv` in the [manuscript repository](https://github.com/wanglab-neuro/2026_Xiao-Severson_Peri-head-distance), reproduced inline here\n", + "so the notebook stands alone.\n", + "\n", + "`tuning_type` is the classification carried in the NWB `units` table itself." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f40d1a4f", + "metadata": {}, + "outputs": [], + "source": [ + "FIGURE2_UNITS = pd.DataFrame(\n", + " [\n", + " # display, asset path, unit row, label, tuning\n", + " (\"Unit 1\", \"sub-Ephys2/sub-Ephys2_ses-20230208-sp-session-1.nwb\", 4, \"20230208_sp_session_1_unit_5\", \"proximity\"),\n", + " (\"Unit 2\", \"sub-Ephys40/sub-Ephys40_ses-20230626-sp-session-1.nwb\", 7, \"20230626_sp_session_1_unit_8\", \"map\"),\n", + " (\"Unit 3\", \"sub-Ephys88/sub-Ephys88_ses-20240905-sp-session-1.nwb\", 3, \"20240905_sp_session_1_unit_4\", \"map\"),\n", + " (\"Unit 4\", \"sub-Ephys81/sub-Ephys81_ses-20240703-sp-session-1.nwb\", 9, \"20240703_sp_session_1_unit_10\", \"map\"),\n", + " (\"Unit 5\", \"sub-Ephys22/sub-Ephys22_ses-20221212-sp-session-2.nwb\", 17, \"20221212_sp_session_2_unit_18\", \"map\"),\n", + " (\"Unit 6\", \"sub-Ephys106/sub-Ephys106_ses-20240924-sp-session-1.nwb\", 6, \"20240924_sp_session_1_unit_7\", \"suppressed\"),\n", + " ],\n", + " columns=[\"display_unit\", \"asset_path\", \"unit_index\", \"unit_label\", \"tuning_type\"],\n", + ")\n", + "FIGURE2_UNITS" + ] + }, + { + "cell_type": "markdown", + "id": "a2a60110", + "metadata": {}, + "source": [ + "## 3. Stream each session\n", + "\n", + "`load_session` opens one asset over HTTP and pulls out the three things the\n", + "figure needs: the trials table, the units table, and the stored tuning curves." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e65d8a93", + "metadata": {}, + "outputs": [], + "source": [ + "def load_session(dandiset, asset_path):\n", + " \"\"\"Stream one NWB file and return (trials, units, tuning, distance_axis).\"\"\"\n", + " asset = dandiset.get_asset_by_path(asset_path)\n", + " url = asset.get_content_url(follow_redirects=1, strip_query=True)\n", + "\n", + " store = remfile.File(url)\n", + " with h5py.File(store, \"r\") as h5, NWBHDF5IO(file=h5, load_namespaces=True) as io:\n", + " nwb = io.read()\n", + " trials = nwb.trials.to_dataframe()\n", + " units = nwb.units.to_dataframe()\n", + "\n", + " tuning = None\n", + " distances = None\n", + " if \"wall_tuning\" in nwb.processing:\n", + " module = nwb.processing[\"wall_tuning\"]\n", + " if \"tuning_curves\" in module.data_interfaces:\n", + " tuning = module[\"tuning_curves\"].to_dataframe()\n", + " if \"distance_axis\" in module.data_interfaces:\n", + " distances = module[\"distance_axis\"].to_dataframe()[\"distance_mm\"].to_numpy(float)\n", + "\n", + " return {\n", + " \"session_id\": nwb.session_id or nwb.identifier,\n", + " \"trials\": trials,\n", + " \"units\": units,\n", + " \"tuning\": tuning,\n", + " \"distances\": distances,\n", + " }\n", + "\n", + "\n", + "sessions = {}\n", + "with DandiAPIClient() as client:\n", + " dandiset = client.get_dandiset(DANDISET_ID, DANDISET_VERSION)\n", + " for asset_path in FIGURE2_UNITS[\"asset_path\"].unique():\n", + " sessions[asset_path] = load_session(dandiset, asset_path)\n", + " info = sessions[asset_path]\n", + " print(f\"{info['session_id']:26s} {len(info['trials']):3d} trials {len(info['units']):3d} units\")" + ] + }, + { + "cell_type": "markdown", + "id": "777d5a1a", + "metadata": {}, + "source": [ + "Each session has 200 wall passes. The wall distance for every pass is stored per\n", + "trial in `trials.wall_distance_mm`, and the tuning analysis groups those into\n", + "2 mm bins spanning roughly 7 to 23 mm." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a4bb84d5", + "metadata": {}, + "outputs": [], + "source": [ + "example = sessions[FIGURE2_UNITS.loc[0, \"asset_path\"]]\n", + "print(\"trials columns :\", list(example[\"trials\"].columns))\n", + "print(\"distance axis :\", np.round(example[\"distances\"], 2))\n", + "example[\"trials\"].head()" + ] + }, + { + "cell_type": "markdown", + "id": "98551324", + "metadata": {}, + "source": [ + "## 4. Plotting helpers\n", + "\n", + "Rasters and PETHs are computed here from the raw spike times rather than read\n", + "from the stored PSTHs, so you can see exactly how they are built and change the\n", + "window or binning. The tuning curves in panel C are the stored values.\n", + "\n", + "Colour encodes wall distance throughout, near (red) to far (blue)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "02f442e0", + "metadata": {}, + "outputs": [], + "source": [ + "WINDOW = (0.0, 12.0) # seconds relative to wall-pass onset\n", + "BIN_WIDTH = 0.2 # PETH bin, seconds\n", + "SMOOTH_BINS = 3 # boxcar smoothing width, in bins\n", + "TUNING_SCALE = 2.0 # matches the manuscript figure\n", + "PETH_TICK_STEPS = [10, 10, 20, 10, 2, 10] # y-axis step per row\n", + "\n", + "\n", + "def distance_colors(distances):\n", + " cmap = plt.get_cmap(\"turbo_r\")\n", + " norm = Normalize(vmin=float(np.nanmin(distances)), vmax=float(np.nanmax(distances)))\n", + " return {float(d): cmap(norm(float(d))) for d in distances}\n", + "\n", + "\n", + "def smooth(values, width):\n", + " if width <= 1:\n", + " return values\n", + " return np.convolve(values, np.ones(width) / width, mode=\"same\")\n", + "\n", + "\n", + "def spikes_in_trial(spike_times, start_time, window):\n", + " \"\"\"Spike times relative to trial onset, using binary search on sorted times.\"\"\"\n", + " lo = np.searchsorted(spike_times, start_time + window[0], side=\"left\")\n", + " hi = np.searchsorted(spike_times, start_time + window[1], side=\"right\")\n", + " return spike_times[lo:hi] - start_time\n", + "\n", + "\n", + "def trials_at_distance(trials, distance):\n", + " return trials[np.isclose(trials[\"wall_distance_mm\"].to_numpy(float), distance)]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4e5820eb", + "metadata": {}, + "outputs": [], + "source": [ + "def plot_raster(ax, spike_times, trials, distances, colors, window):\n", + " y = 0\n", + " for distance in distances:\n", + " for _, trial in trials_at_distance(trials, distance).iterrows():\n", + " rel = spikes_in_trial(spike_times, float(trial[\"start_time\"]), window)\n", + " if rel.size:\n", + " ax.vlines(rel, y + 0.08, y + 0.92,\n", + " color=colors[float(distance)], linewidth=0.25, alpha=0.85)\n", + " y += 1\n", + " ax.axvspan(3.0, 11.0, color=\"0.88\", zorder=-10) # wall in motion\n", + " ax.set_xlim(window)\n", + " ax.set_ylim(-1, max(y, 1))\n", + " ax.set_yticks([])\n", + " ax.tick_params(labelsize=8, length=2)\n", + " for side in (\"top\", \"right\"):\n", + " ax.spines[side].set_visible(False)\n", + "\n", + "\n", + "def plot_peth(ax, spike_times, trials, distances, colors, window, bin_width, smooth_bins):\n", + " edges = np.arange(window[0], window[1] + bin_width, bin_width)\n", + " centers = edges[:-1] + bin_width / 2\n", + " for distance in distances:\n", + " rows = trials_at_distance(trials, distance)\n", + " if rows.empty:\n", + " continue\n", + " rates = np.asarray(\n", + " [np.histogram(spikes_in_trial(spike_times, float(t[\"start_time\"]), window),\n", + " bins=edges)[0] / bin_width for _, t in rows.iterrows()],\n", + " dtype=float,\n", + " )\n", + " mean = smooth(np.nanmean(rates, axis=0), smooth_bins)\n", + " sem = smooth(np.nanstd(rates, axis=0, ddof=1) / np.sqrt(max(len(rates), 1)), smooth_bins)\n", + " color = colors[float(distance)]\n", + " ax.plot(centers, mean, color=color, linewidth=1.1)\n", + " ax.fill_between(centers, mean - sem, mean + sem, color=color, alpha=0.15, linewidth=0)\n", + " ax.axvspan(3.0, 11.0, color=\"0.88\", zorder=-10)\n", + " ax.set_xlim(window)\n", + " ax.set_ylabel(\"sp/s\", fontsize=8)\n", + " ax.tick_params(labelsize=8, length=2)\n", + " for side in (\"top\", \"right\"):\n", + " ax.spines[side].set_visible(False)\n", + "\n", + "\n", + "def plot_tuning(ax, session, unit_index, colors, scale):\n", + " tuning, distances = session[\"tuning\"], session[\"distances\"]\n", + " if tuning is None or distances is None:\n", + " ax.text(0.5, 0.5, \"no tuning\", ha=\"center\", va=\"center\",\n", + " transform=ax.transAxes, fontsize=8)\n", + " ax.set_axis_off()\n", + " return\n", + "\n", + " unit_label = str(session[\"units\"].iloc[unit_index].get(\"unit_label\", \"\"))\n", + " match = tuning[tuning[\"unit_label\"].astype(str) == unit_label] if \"unit_label\" in tuning else tuning.iloc[0:0]\n", + " row = match.iloc[0] if not match.empty else tuning.iloc[unit_index]\n", + "\n", + " y = np.asarray(row[\"zfr_mean\"], dtype=float) * scale\n", + " yerr = np.asarray(row[\"zfr_sem\"], dtype=float) * scale if \"zfr_sem\" in row else np.full_like(y, np.nan)\n", + " x = distances[: len(y)]\n", + "\n", + " ax.plot(x, y, color=\"black\", linewidth=0.8, zorder=1)\n", + " for xi, yi, ei in zip(x, y, yerr):\n", + " color = colors.get(float(xi), \"black\")\n", + " ax.errorbar(xi, yi, yerr=None if np.isnan(ei) else ei, fmt=\"o\",\n", + " color=color, ecolor=color, elinewidth=0.7, capsize=0,\n", + " markersize=3.5, zorder=2)\n", + " ax.set_xlabel(\"Wall distance (mm)\", fontsize=8)\n", + " ax.set_ylabel(\"Firing rate (z-score)\", fontsize=8)\n", + " ticks = x[np.linspace(0, len(x) - 1, min(5, len(x)), dtype=int)]\n", + " ax.set_xticks(ticks)\n", + " ax.set_xticklabels([f\"{v:.0f}\" for v in ticks])\n", + " ax.tick_params(labelsize=8, length=2)\n", + " for side in (\"top\", \"right\"):\n", + " ax.spines[side].set_visible(False)" + ] + }, + { + "cell_type": "markdown", + "id": "20bb4cb9", + "metadata": {}, + "source": [ + "## 5. Build the figure\n", + "\n", + "One row per unit, ordered as in the manuscript. The shaded band marks the\n", + "interval during which the wall is in motion past the animal's head." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b8bf8739", + "metadata": {}, + "outputs": [], + "source": [ + "def add_distance_legend(fig, distances, colors):\n", + " ordered = np.array(sorted(distances), dtype=float)\n", + " left, bottom, width, height = 0.10, 0.945, 0.20, 0.016\n", + " ax = fig.add_axes([left, bottom, width, height])\n", + " for i, distance in enumerate(ordered):\n", + " ax.bar(i, 1, color=colors[float(distance)], width=1.0, align=\"edge\")\n", + " ax.set_xlim(0, len(ordered))\n", + " ax.set_ylim(0, 1)\n", + " ax.set_axis_off()\n", + " fig.text(left, bottom + 0.020, \"Wall distance (mm)\", fontsize=8, ha=\"left\")\n", + " fig.text(left, bottom - 0.011, f\"{ordered[0]:.0f}\", fontsize=8, ha=\"left\")\n", + " fig.text(left + width, bottom - 0.011, f\"{ordered[-1]:.0f}\", fontsize=8, ha=\"right\")\n", + "\n", + "\n", + "# Distances are shared across sessions; order far-to-near to match the manuscript.\n", + "all_distances = sorted({float(d) for s in sessions.values() for d in s[\"distances\"]})\n", + "distances_desc = np.array(all_distances)[::-1]\n", + "colors = distance_colors(np.array(all_distances))\n", + "\n", + "n_rows = len(FIGURE2_UNITS)\n", + "fig, axes = plt.subplots(\n", + " n_rows, 3,\n", + " figsize=(7.0, max(2.2, 1.45 * n_rows + 0.8)),\n", + " sharex=\"col\",\n", + " gridspec_kw={\"width_ratios\": [1.0, 1.0, 0.9], \"wspace\": 0.35, \"hspace\": 0.18},\n", + ")\n", + "\n", + "for row_idx, unit in FIGURE2_UNITS.iterrows():\n", + " session = sessions[unit[\"asset_path\"]]\n", + " spike_times = np.asarray(session[\"units\"].iloc[unit[\"unit_index\"]][\"spike_times\"], dtype=float)\n", + " row_distances = np.array(sorted(session[\"distances\"]))[::-1]\n", + "\n", + " plot_raster(axes[row_idx, 0], spike_times, session[\"trials\"], row_distances, colors, WINDOW)\n", + " plot_peth(axes[row_idx, 1], spike_times, session[\"trials\"], row_distances, colors,\n", + " WINDOW, BIN_WIDTH, SMOOTH_BINS)\n", + "\n", + " step = PETH_TICK_STEPS[row_idx] if row_idx < len(PETH_TICK_STEPS) else 10\n", + " upper = max(step, float(np.ceil(axes[row_idx, 1].get_ylim()[1] / step) * step))\n", + " axes[row_idx, 1].set_ylim(0, upper)\n", + " axes[row_idx, 1].set_yticks(np.arange(0, upper + step * 0.5, step))\n", + "\n", + " plot_tuning(axes[row_idx, 2], session, unit[\"unit_index\"], colors, TUNING_SCALE)\n", + "\n", + " axes[row_idx, 0].set_ylabel(f\"{unit['display_unit']}\\n{unit['tuning_type']}\",\n", + " fontsize=8, rotation=90, labelpad=12)\n", + " axes[row_idx, 0].text(0.02, 0.94, unit[\"unit_label\"],\n", + " transform=axes[row_idx, 0].transAxes,\n", + " fontsize=7, va=\"top\", ha=\"left\")\n", + "\n", + "axes[0, 0].set_title(\"Rasters\", fontsize=10)\n", + "axes[0, 1].set_title(\"PETHs\", fontsize=10)\n", + "axes[0, 2].set_title(\"Tuning curves\", fontsize=10)\n", + "axes[-1, 0].set_xlabel(\"Time (s)\", fontsize=8)\n", + "axes[-1, 1].set_xlabel(\"Time (s)\", fontsize=8)\n", + "fig.suptitle(\"Figure 2: peri-head distance coding in PrV\", fontsize=11, x=0.99, y=0.988, ha=\"right\")\n", + "add_distance_legend(fig, distances_desc, colors)\n", + "fig.subplots_adjust(top=0.915, left=0.10, right=0.98, bottom=0.06)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "07de9a48", + "metadata": {}, + "source": [ + "## 6. Reading the figure\n", + "\n", + "- **Unit 1** is a *proximity* unit: firing climbs steadily as the wall gets\n", + " closer, so the whole tuning curve slopes in one direction.\n", + "- **Units 2 to 5** are *map* units, each peaking at a preferred distance. Their\n", + " PETH traces separate by colour, and the tuning curve has an interior maximum.\n", + "- **Unit 6** is *suppressed*: the wall drives firing down rather than up.\n", + "\n", + "Together these illustrate the paper's claim that PrV carries not one but two\n", + "distance codes, a monotonic proximity signal and a distributed map.\n", + "\n", + "## 7. Going further\n", + "\n", + "- **Other units.** Every session's `units` table carries `tuning_type`,\n", + " `wall_responsive`, `pref_dist_mm` and `trial_include`, so you can select units\n", + " by classification instead of using the fixed list above.\n", + "- **All sessions.** `client.get_dandiset(...).get_assets()` lists all 191 assets.\n", + " The processed files are the ones without a modality suffix.\n", + "- **Raw data.** The `_ecephys+image` assets hold the 30 kHz broadband, the 1 kHz\n", + " LFP, the digital TTL record, and an `ImageSeries` pointing at the behaviour\n", + " video, if you want to work from the unprocessed signals." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/001687/wanglab/figure2-distance-tuning/requirements.in b/001687/wanglab/figure2-distance-tuning/requirements.in new file mode 100644 index 0000000..7442c01 --- /dev/null +++ b/001687/wanglab/figure2-distance-tuning/requirements.in @@ -0,0 +1,7 @@ +dandi +h5py +matplotlib +numpy +pandas +pynwb +remfile