Skip to content

Example: query local export

Query the export output on disk with pandas and DuckDB.

Run with uv (dependencies declared inline via PEP 723 — no project install needed):

uv run docs/examples/query_export.py

# /// script
# dependencies = [
#     "pandas",
#     "pyarrow>=14.0.0",
#     "duckdb>=1.0",
# ]
# requires-python = ">=3.11"
# ///
"""Querying the experiment export with pandas and DuckDB.

The export pipeline writes two kinds of output:

  session.parquet          — flat catalogue, one row per session
  {table}.parquet           — flat file, all sessions concatenated (small cross-session tables)
  sessions/{session_id}/    — per-session directory (large per-session tables)
      position_position_velocity.parquet
      position.parquet
      ...

Small tables (e.g. ``sites``) are aggregated into a single flat parquet at the
export root. Large tables (e.g. ``position_velocity``, ``position``) stay per-session —
they are typically accessed one session at a time and are too large to concatenate.

Generating the export
---------------------
Run the CLI against a directory of raw session folders::

    vr-foraging-packaging --input-dir /data/raw --output-dir /data/export

Or with the helper script (dev / scratch)::

    .\\scratch\\run_export.ps1

Dependencies
------------
Declared inline (PEP 723) — ``uv`` resolves them per-run, so this script needs no
project install.

Run from the project root::

    uv run vr-foraging-packaging --input-dir /data/raw --output-dir ./export ` --log-file   ./export/run.log --workers 4
    uv run examples/query_export.py
"""

from pathlib import Path

import pandas as pd

# ── Configure this path to point at your export root ─────────────────────────
EXPORT_DIR = Path("scratch/export")

if not EXPORT_DIR.exists():
    raise SystemExit(
        f"Export directory not found: {EXPORT_DIR.resolve()}\n"
        "Generate it first with: vr-foraging-packaging --input-dir <raw> --output-dir scratch/export"
    )


# ─────────────────────────────────────────────────────────────────────────────
# 1. Session catalogue
# ─────────────────────────────────────────────────────────────────────────────

sessions = pd.read_parquet(EXPORT_DIR / "session.parquet")
print("=== sessions catalogue ===")
print(sessions[["session_id", "subject_id", "date"]].to_string(index=False))

first_animal = sessions["subject_id"].iloc[0]
animal_session_ids = sessions[sessions["subject_id"] == first_animal]["session_id"].tolist()
print(f"\nAnimal '{first_animal}' has {len(animal_session_ids)} session(s)")


# ─────────────────────────────────────────────────────────────────────────────
# 2. Cross-session flat table  (pandas)
#
# sites.parquet is a single file — all sessions in one place.
# Filter by session_id to get one animal's sites.
# ─────────────────────────────────────────────────────────────────────────────

sites_path = EXPORT_DIR / "sites.parquet"
if not sites_path.exists():
    raise SystemExit(
        f"sites.parquet not found under {EXPORT_DIR}.\nRe-run the export (aggregation phase) to generate it."
    )

all_sites = pd.read_parquet(sites_path)
print(f"\n=== all sites: {len(all_sites)} rows across {all_sites['session_id'].nunique()} sessions ===")

animal_sites = all_sites[all_sites["session_id"].isin(animal_session_ids)]
print(f"=== sites for '{first_animal}': {len(animal_sites)} rows ===")


# ─────────────────────────────────────────────────────────────────────────────
# 3. Per-session large table  (pandas)
#
# Large tables (position_velocity, position, licks, …) are never aggregated cross-session.
# Access them directly by session path — fast, no unnecessary data loaded.
# ─────────────────────────────────────────────────────────────────────────────

first_session = animal_session_ids[0]
pos_vel_path = EXPORT_DIR / "sessions" / first_session / "position_position_velocity.parquet"

if pos_vel_path.exists():
    position_velocity = pd.read_parquet(pos_vel_path)
    print(f"\n=== position_velocity [{first_session}]: {len(position_velocity)} rows ===")
else:
    print(f"\n(position_velocity not found for {first_session} — skipping)")


# ─────────────────────────────────────────────────────────────────────────────
# 4. DuckDB  (declared in this script's inline PEP 723 block)
#
# Flat parquet files work as first-class DuckDB sources.
# For large per-session tables, build a file list from the catalogue and pass
# it to read_parquet() — DuckDB reads only those files, nothing else.
# ─────────────────────────────────────────────────────────────────────────────

try:
    import duckdb
except ImportError:
    print("\nDuckDB not installed — skipping section 4.\nRun this script with `uv run` to pick up its inline deps.")
    raise SystemExit(0)

con = duckdb.connect()

# Register flat tables as views — single-file scan, full predicate pushdown
con.execute(f"CREATE VIEW session AS SELECT * FROM read_parquet('{EXPORT_DIR / 'session.parquet'}')")
con.execute(f"CREATE VIEW sites  AS SELECT * FROM read_parquet('{EXPORT_DIR / 'sites.parquet'}')")

print("\n=== DuckDB: site counts per session ===")
counts = con.execute("""
    SELECT session_id, COUNT(*) AS n_sites
    FROM sites
    GROUP BY session_id
    ORDER BY session_id
""").df()
print(counts.to_string(index=False))

print(f"\n=== DuckDB: sites for '{first_animal}' (join with catalogue) ===")
result = con.execute(f"""
    SELECT t.*, s.date
    FROM sites t
    JOIN session s USING (session_id)
    WHERE s.subject_id = '{first_animal}'
    ORDER BY s.date
""").df()
print(f"  {len(result)} rows, {result['session_id'].nunique()} session(s)")

# Large per-session table: build file list from catalogue, pass to DuckDB
pos_vel_files = [
    str(EXPORT_DIR / "sessions" / sid / "position_position_velocity.parquet")
    for sid in animal_session_ids
    if (EXPORT_DIR / "sessions" / sid / "position_position_velocity.parquet").exists()
]
if pos_vel_files:
    print(f"\n=== DuckDB: position_velocity for '{first_animal}' across {len(pos_vel_files)} session(s) ===")
    result = con.execute(f"SELECT * FROM read_parquet({pos_vel_files})").df()
    print(f"  {len(result)} rows")
else:
    print(f"\n(no position_velocity files found for '{first_animal}' — skipping)")

con.close()
print("\nDone.")