diff --git a/.github/workflows/tag_and_publish.yml b/.github/workflows/tag_and_publish.yml index 7c6ba1c..dd822ab 100644 --- a/.github/workflows/tag_and_publish.yml +++ b/.github/workflows/tag_and_publish.yml @@ -24,7 +24,7 @@ jobs: with: python-version: '3.10' - name: Install dependencies - run: uv sync + run: uv sync --extra full - name: Get Python version and Update README.md run: | python_version=$(grep "requires-python" pyproject.toml | grep -o ">=[^\"]*") diff --git a/.github/workflows/test_and_lint.yml b/.github/workflows/test_and_lint.yml index e2d22e9..ed0e022 100644 --- a/.github/workflows/test_and_lint.yml +++ b/.github/workflows/test_and_lint.yml @@ -4,13 +4,16 @@ on: pull_request: branches: - main + - dev jobs: ci: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: + fail-fast: true matrix: - python-version: [ '3.10', '3.11', '3.12' ] + os: [ ubuntu-latest, macos-latest, windows-latest ] + python-version: [ '3.12' ] steps: - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} @@ -18,8 +21,8 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install dependencies - run: uv sync + run: uv sync --extra full - name: Run linter checks - run: uv run flake8 . && uv run interrogate --verbose . + run: uv run ruff check . && uv run ruff format --check . && uv run interrogate --verbose . - name: Run tests and coverage - run: uv run coverage run -m unittest discover && uv run coverage report + run: uv run coverage run -m pytest && uv run coverage report diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c23d24..b8035bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,33 +2,43 @@ ### Linters and testing -There are several libraries used to run linters, check documentation, and run tests. +This project uses [uv](https://docs.astral.sh/uv/) to manage its environment and +[ruff](https://docs.astral.sh/ruff/) for linting and formatting. -- Please test your changes using the **coverage** library, which will run the tests and log a coverage report: +- Install dependencies (including the `dev` group): ```bash -coverage run -m unittest discover && coverage report +uv sync +``` + +- Run tests with **pytest** under **coverage**: + +```bash +uv run coverage run -m pytest && uv run coverage report ``` - Use **interrogate** to check that modules, methods, etc. have been documented thoroughly: ```bash -interrogate . +uv run interrogate . ``` -- Use **flake8** to check that code is up to standards (no unused imports, etc.): +- Use **ruff** to lint the code (catches unused imports, style issues, sorts imports, etc.): + ```bash -flake8 . +uv run ruff check . ``` -- Use **black** to automatically format the code into PEP standards: +- Use **ruff** to automatically format the code: + ```bash -black . +uv run ruff format . ``` -- Use **isort** to automatically sort import statements: +- To apply ruff's autofixes in one shot: + ```bash -isort . +uv run ruff check --fix . && uv run ruff format . ``` ### Pull requests diff --git a/README.md b/README.md index fe5431d..215c215 100644 --- a/README.md +++ b/README.md @@ -5,44 +5,179 @@ [![semantic-release: angular](https://img.shields.io/badge/semantic--release-angular-e10079?logo=semantic-release)](https://github.com/semantic-release/semantic-release) ![Interrogate](https://img.shields.io/badge/interrogate-100.0%25-brightgreen) ![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen) -![Python](https://img.shields.io/badge/python->=3.10-blue?logo=python) - -## Usage - - To use this template, click the green `Use this template` button and `Create new repository`. - - After github initially creates the new repository, please wait an extra minute for the initialization scripts to finish organizing the repo. - - To enable the automatic semantic version increments: in the repository go to `Settings` and `Collaborators and teams`. Click the green `Add people` button. Add `svc-aindscicomp` as a collaborator with "write" role. Modify the file in `.github/workflows/tag_and_publish.yml` by removing the if statement in line 67. The semantic version will now be incremented every time a code is committed into the main branch. - - To publish to PyPI, first submit a request to the [Scientific Computing issue tracker](https://github.com/AllenNeuralDynamics/aind-scientific-computing/issues) - to have the repository added as a Trusted Publisher for PyPI. Please specify the repository name (and desired PyPI package name if different). - After the repository is added as a Trusted Publisher, enable semantic versioning and remove the if statement in line 73 in `.github/workflows/tag_and_publish.yml`. The code will now be published to PyPI every time the code is committed into the main branch. - - The `.github/workflows/test_and_lint.yml` file will run automated tests and style checks every time a Pull Request is opened. If the checks are undesired, the `test_and_lint.yml` can be deleted. The strictness of the code coverage level, etc., can be modified by altering the configurations in the `pyproject.toml` file and the `.flake8` file. - - Please make any necessary updates to the README.md and CITATION.cff files +![Python](https://img.shields.io/badge/python->=3.12-blue?logo=python) + +Tools for processing raw [dynamic foraging](https://github.com/AllenNeuralDynamics/dynamic-foraging-task) +acquisition data into derived containers for NWB. The package loads raw acquisition +streams through the +[aind-behavior-dynamic-foraging](https://github.com/AllenNeuralDynamics/Aind.Behavior.DynamicForaging) +data contract and assembles higher-level tables (currently the NWB `trials` table) +from those streams. + +> **Note:** Data must be acquired in the `aind-behavior-dynamic-foraging` data +> contract format to be compatible with these tools. + +## Inputs and outputs +**Input** — a raw acquisition directory in the `aind-behavior-dynamic-foraging` +data contract format (Harp device registers, software events, and the task-logic / +rig / session input schemas), loaded via `RawDataLoader`. + +**Outputs** — the NWB-ready containers assembled from those streams: + +- **Acquisition.** Built by `AcquisitionBuilder` and written to the NWB acquisition + module: + - 4 derived event series: + - `left_reward_delivery_time` / `right_reward_delivery_time` — reward delivery + times per lick port, with each event annotated as `earned`, `manual`, or + `automatic`. + - `left_lick_time` / `right_lick_time` — detected lick times per lick port. + - All raw contract streams — every stream returned by the loader + (`get_all_raw_data()`) is additionally packaged verbatim as a `DynamicTable`, + so the full raw dataset travels alongside the derived series. +- **Trials table.** Built by `TrialTableBuilder`, one row per trial written to the + NWB `trials` table (see [`docs/trials_table_mapping.md`](docs/trials_table_mapping.md)). ## Level of Support -Please indicate a level of support: - - [ ] Supported: We are releasing this code to the public as a tool we expect others to use. Issues are welcomed, and we expect to address them promptly; pull requests will be vetted by our staff before inclusion. + - [x] Supported: We are releasing this code to the public as a tool we expect others to use. Issues are welcomed, and we expect to address them promptly; pull requests will be vetted by our staff before inclusion. - [ ] Occasional updates: We are planning on occasional updating this tool with no fixed schedule. Community involvement is encouraged through both issues and pull requests. - [ ] Unsupported: We are not currently supporting this code, but simply releasing it to the community AS IS but are not able to provide any guarantees of support. The community is welcome to submit issues, but you should not expect an active response. -## Release Status -GitHub's tags and Release features can be used to indicate a Release status. +## Modules +The package (`src/dynamic_foraging_processing`) is organized into the following +modules: - - Stable: v1.0.0 and above. Ready for production. - - Beta: v0.x.x or indicated in the tag. Ready for beta testers and early adopters. - - Alpha: v0.x.x or indicated in the tag. Still in early development. +### `raw_data_loader` +Loads raw acquisition data via the data contract. + - **`RawDataLoader`** — wraps a `contraqctor` `Dataset` built from an acquisition + directory. Individual streams are accessed lazily through the tree-like contract + (`loader.dataset.at("Behavior").at("SoftwareEvents").at("TrialOutcome")`), or all + at once: + - `get_all_raw_data()` → `dict` mapping `parent.stream` to its loaded data + (`pandas.DataFrame` or `dict`). + - `raw_data_stream_descriptions` → `dict` mapping each stream to its description + (used when building NWB). + +### `nwb` +Builds the NWB acquisition entries (requires the `nwb` extra). + - **`AcquisitionBuilder`** — assembles the acquisition module from the raw + streams: the four derived event series (`left`/`right_reward_delivery_time`, + `left`/`right_lick_time`) plus every raw contract stream packaged as a + `DynamicTable`. Returns `AcquisitionSeries` / `AcquisitionTable` models that a + writer translates into `pynwb` objects. + +### `processing` +Builds derived NWB containers from the raw streams. + - **`TrialTableBuilder`** — assembles the NWB `trials` table from a `Dataset`. + `build()` returns one `pandas.DataFrame` row per trial, aligning the per-trial + software events, task-logic configuration, and Harp hardware streams. Pass + `raise_on_error=True` to fail loudly on missing/misaligned streams instead of + warning and continuing. + - **`TrialConfig`** (`processing.models.trial_config`) — a Pydantic model + describing one row of the trials table. Each field's `description` is the source + of truth for the corresponding NWB trial-column description; use + `TrialConfig.column_descriptions()` to retrieve them. The column-by-column source + mapping is documented in [`docs/trials_table_mapping.md`](docs/trials_table_mapping.md). + +### `qc` +Assembles an `aind-data-schema` `QualityControl` object (requires the `qc` extra). + - **`RawQC`** / **`ProcessedQC`** — the raw (contract QA) and processed (behavior + metrics) QC stages. `build_quality_control(metrics)` collects their metrics into + a single `QualityControl`. + +### `pipeline` +Ties the building blocks into two Code Ocean capsule entry points (requires the +`full` extra). + - **`Pipeline`** — over a `RawDataLoader`: + - `run_nwb(output_path)` — assembles the NWB file (base metadata + acquisition + entries + trials table), writes it to `output_path / "behavior.nwb.zarr"`, and + writes a `processing.json` alongside it. + - `run_qc(output_path)` — runs the QC stages and writes `quality_control.json`. + + Both entry points build and return their objects in memory; `output_path` is + optional and only written to when provided. + +## Repository structure +``` +src/dynamic_foraging_processing/ +├── raw_data_loader/ # RawDataLoader: load raw streams via the data contract +│ └── loader.py +├── nwb/ # NWB acquisition builder + models +│ ├── acquisition/ # AcquisitionBuilder, AcquisitionSeries/Table +│ └── utils.py # clean_for_nwb: make frames safe to write to NWB +├── processing/ # Derived NWB containers +│ ├── _trial_table.py # TrialTableBuilder +│ └── models/ +│ └── trial_config.py # TrialConfig (one trials-table row + NWB descriptions) +├── qc/ # QualityControl assembly (raw + processed stages) +└── pipeline/ # Pipeline: run_nwb / run_qc capsule entry points + +docs/ # Source mapping and NWB notes (trials_table_mapping.md, ...) +examples/ # Runnable notebooks (raw_data_loader_example, trial_table_example) +tests/ # Unit tests +``` + +## Quickstart +```python +from pathlib import Path + +from dynamic_foraging_processing.raw_data_loader import RawDataLoader +from dynamic_foraging_processing.pipeline import Pipeline + +# Point at the root of an acquisition directory. +loader = RawDataLoader(path=Path("/path/to/acquisition")) +pipeline = Pipeline(loader) + +# Write the NWB file (+ processing.json) and the QC (+ quality_control.json). +pipeline.run_nwb("/path/to/output") +pipeline.run_qc("/path/to/output") + +# Or use the building blocks directly. +trials = pipeline.build_trials() # NWB trials table, one row per trial +raw = loader.get_all_raw_data() # inspect raw streams +``` +See [`examples/`](examples) for end-to-end notebooks. ## Installation -To use the software, in the root directory, run +This project uses [uv](https://docs.astral.sh/uv/). From the root directory, run ```bash -pip install -e . +uv sync ``` +> [!IMPORTANT] +> The base install includes **only** the raw data loader. The optional modules +> pull in their own dependencies, so importing them will fail on a base install: +> - **`qc`** — `aind-data-schema`, `matplotlib` (for `dynamic_foraging_processing.qc`). +> - **`nwb`** — `aind-nwb-utils`, `hdmf-zarr`, `pynwb` (for the `nwb` builder). +> - **`full`** — everything above (`qc` + `nwb`); required by the `pipeline` module. +> +> Install the extra you need, e.g.: +> ```bash +> pip install -e ".[full]" +> ``` + To develop the code, run +to create the environment and install the package. To include the development +dependencies (linting, tests, docs), run ```bash -pip install -e . --group dev +uv sync --group dev ``` -Note: --group flag is available only in pip versions >=25.1 -Alternatively, if using [uv](https://docs.astral.sh/uv/), run +## Development +Tests and style checks run on every pull request via +`.github/workflows/test_and_lint.yml`. To run them locally with `uv`: ```bash -uv sync +uv run ruff check . && uv run ruff format --check . # lint + format +uv run interrogate -v . # docstring coverage +uv run coverage run -m pytest && uv run coverage report ``` +To include the optional-module dependencies (qc + nwb) with uv, run +```bash +uv sync --extra full +``` + +## Release Status +GitHub's tags and Release features can be used to indicate a Release status. + + - Stable: v1.0.0 and above. Ready for production. + - Beta: v0.x.x or indicated in the tag. Ready for beta testers and early adopters. + - Alpha: v0.x.x or indicated in the tag. Still in early development. diff --git a/docs/nwb_contents.md b/docs/nwb_contents.md new file mode 100644 index 0000000..0876ba6 --- /dev/null +++ b/docs/nwb_contents.md @@ -0,0 +1,55 @@ +# Final NWB Contents + +This document describes the contents of the NWB file produced by this +repository. It is a companion to issue +[#12](https://github.com/AllenNeuralDynamics/dynamic-foraging-processing/issues/12), +which serves as the authoritative changelog for content decisions. + +When the NWB contents change, update both the [Changelog](#changelog) below +and the relevant section in this document. Each entry should record at minimum +the date, what changed, and why. + +## Acquisition + +The `acquisition` container holds the HARP streams from the rig (e.g. +VR Foraging) along with four behavior-derived series carried over from the +NWB produced by the combined dynamic foraging + FIP pipeline: + +- `left_lick_time` +- `right_lick_time` +- `left_reward_delivery_time` +- `right_reward_delivery_time` + +Each series stores both timestamps and a parallel `data` array. For the +reward delivery series, `data` annotates each reward as `earned`, `manual`, +or `automatic`. + +See [`trials_table_mapping.md`](trials_table_mapping.md#acquisition-container) +for the raw sources backing each of these four series. + +## Events + +The `events` container follows the conventions in +[aind-physio-arch#1072](https://github.com/AllenNeuralDynamics/aind-physio-arch/issues/1072). + +The events sidecar will be version-controlled in this repository for now so that +changes can be tracked alongside the code. + +Events are on pause pending validation by the HED team. See the +[Changelog](#changelog) for details. + +## Trials + +The `trials` table is built from the raw acquisition streams. The full +column-by-column mapping is documented in +[`trials_table_mapping.md`](trials_table_mapping.md), and the source-of-truth +discussion lives in issue +[#5](https://github.com/AllenNeuralDynamics/dynamic-foraging-processing/issues/5). + +## Changelog + +| Date | Section | Change | Reason | +| --- | --- | --- | --- | +| 2026-06-03 | acquisition / trials | Initial scope confirmed: HARP streams + `{left,right}_lick_time` and `{left,right}_reward_delivery_time` in `acquisition`; trials mapping per issue #5. | Meeting with Alex. | +| 2026-06-05 | events | Events on pause. | Pending validation by the HED team. | +| 2026-06-08 | acquisition | Documented `data` arrays alongside timestamps; reward delivery series annotate each reward as `earned`, `manual`, or `automatic`. | Clarify acquisition contents. | diff --git a/docs/qc_upgrade_plan.md b/docs/qc_upgrade_plan.md new file mode 100644 index 0000000..47d43dc --- /dev/null +++ b/docs/qc_upgrade_plan.md @@ -0,0 +1,216 @@ +# QC Upgrade Plan + +Plan for upgrading +[`aind-dynamic-foraging-qc/code/run_capsule.py`](https://github.com/AllenNeuralDynamics/aind-dynamic-foraging-qc/blob/main/code/run_capsule.py) +to: + +1. Conform to the current + [`aind_data_schema.core.quality_control`](https://github.com/AllenNeuralDynamics/aind-data-schema/blob/dev/src/aind_data_schema/core/quality_control.py) + schema (v2.4.1). +2. Operate on primitive structures (numpy arrays, pandas DataFrames). QC + functions are agnostic to where the data came from — the caller is free + to load from + [`RawDataLoader`](../src/dynamic_foraging_processing/raw_data_loader/loader.py), + an NWB file, or anything else, as long as the primitives match the + expected shape. + +This document is a design reference only. Implementation will happen on a +separate branch. + +## 1. Schema changes + +The schema removed `QCEvaluation`. The new `QualityControl` object holds a flat +`metrics: List[QCMetric | CurationMetric]` and groups metrics via per-metric +`tags`. Each `QCMetric` now requires `modality` and `stage` directly (these +moved off `QCEvaluation`), and `QualityControl` requires `default_grouping`. + +### Field-by-field migration + +| Old (capsule) | New (schema v2.4.1) | +| --- | --- | +| `QCEvaluation(name, modality, stage, metrics, description, allow_failed_metrics)` | Removed. Replace each evaluation with one or more `QCMetric`s sharing a tag. | +| `QCMetric(name, value, status_history, description?, reference?)` | `QCMetric(name, modality, stage, value, status_history, description?, reference?, tags={}, evaluated_assets?)` | +| `QualityControl(evaluations=[...])` | `QualityControl(metrics=[...], default_grouping=[...], key_experimenters?, notes?, allow_tag_failures?)` | +| n/a | `Status.PENDING` is now a valid third state alongside `PASS` / `FAIL`. | +| `allow_failed=True` on an evaluation | `allow_tag_failures=[""]` on the top-level `QualityControl`. | + +### Tag convention + +Each ported behavior metric is tagged with `{"behavior": ""}` — +the key is the group, the value is the metric's name. Contraqctor results +use a fixed `"test_suite"` key plus a dynamic per-suite key (see +[Contraqctor-based QA suites](#contraqctor-based-qa-suites-per-meeting-with-alex-2026-06-03)). + +### Helper rewrites + +`Bool2Status` keeps its shape but must produce timezone-aware timestamps +(schema uses `AwareDatetimeWithDefault`). The existing `datetime.now(seattle_tz)` +already satisfies this. + +`create_evaluation(...)` is deleted. Replace with a small `make_metric(...)` +helper that stamps `modality`, `stage`, and `tags` onto each `QCMetric`. + +## 2. Data inputs + +The old capsule consumed a single `behavior.json` (e.g. `B_Bias`, +`B_LeftLickTime`, `B_RightLickTime`, `B_StagePositions`, `drop_frames_tag`, +`Experimenter`, `dirty_files`, ...). The new pipeline does not produce this +file. + +QC functions now take primitive structures directly. The entry point is +responsible for producing those primitives — whether it pulls them from +`RawDataLoader.get_all_raw_data()`, an NWB file, or any other source is +out of scope for the QC module. This keeps the QC logic testable without +any dataset on disk. + +### Primitive inputs per metric + +| Primitive | Type | Old `behavior.json` analogue | +| --- | --- | --- | +| `left_lick_times` | `np.ndarray` of seconds | `B_LeftLickTime` | +| `right_lick_times` | `np.ndarray` of seconds | `B_RightLickTime` | +| `animal_response` | `np.ndarray` of `{0,1,2}` per trial | `B_AnimalResponseHistory` | +| `side_bias` | `np.ndarray` per trial (right minus left, `nan` on no-response) | `B_Bias` | +| `go_cue_times` | `np.ndarray` of seconds | `B_GoCueTimeSoundCard` | +| `rewarded_history` | `pd.DataFrame` with `left`/`right` boolean columns | `B_RewardedHistory` | +| `stage_positions` | `pd.DataFrame` with `x`/`y`/`z` columns per trial | `B_StagePositions` | + +### Out-of-scope (no equivalent in the new data, drop the check) + +- `drop_frames_tag`, `frame_num`, `trigger_length` — dropped-frames check. +- `Experimenter`, `dirty_files`, `repo_dirty_flag` — basic-configuration check. +- `B_Bias_CI` — side-bias confidence interval; dropped (the bias trace plots + the per-trial `side_bias` column directly, with no CI band). + +## 3. Metrics in the new capsule + +Keep only what maps cleanly. All metrics get `stage=Stage.RAW` and +`modality=Modality.BEHAVIOR` unless noted. + +### Side bias (`tags={"behavior": "average side bias"}`) + +- Input: `side_bias: np.ndarray` — the per-trial side bias read directly from + the trial table (right minus left; `nan` on no-response trials). It is *not* + recomputed from `animal_response`. +- Average bias = `nanmean(side_bias)` over the session. +- Metric: `"average side bias"`, pass when `abs(mean_bias) < 0.5`. An empty or + all-`nan` column yields `nan`, which fails. +- `reference="side_bias.png"`. + +### Lick intervals + +Port `calculate_lick_intervals` verbatim. Inputs are +`left_lick_times: np.ndarray` and `right_lick_times: np.ndarray`, extracted +from the `Behavior.Lickometer` stream at the entry point. + +Emit the same four metrics, each tagged with its own name under the +`behavior` key: + +| Metric | Tag | Pass rule | +| --- | --- | --- | +| `Left Lick Interval (%)` | `{"behavior": "Left Lick Interval (%)"}` | `< 10` | +| `Right Lick Interval (%)` | `{"behavior": "Right Lick Interval (%)"}` | `< 10` | +| `Cross Side Lick Interval (%)` | `{"behavior": "Cross Side Lick Interval (%)"}` | `< 10` | +| `Artifact Percent (%)` | `{"behavior": "Artifact Percent (%)"}` | `< 1` | + +All carry `reference="lick_intervals.png"`. + +### Plots to keep + +- `lick_intervals.png` — five-panel histogram of inter-lick intervals + (`left licks`, `right licks`, `left to right licks`, `right to left licks`, + `all licks`); inputs are `left_lick_times` and `right_lick_times`. +- `side_bias.png` — four-panel figure: + - Side bias trace — the per-trial `side_bias` column read from the trial + table (no confidence-interval band). + - Lickspout position over trials — `stage_positions` (x / y1 / y2 / z, + relative to session start, in mm). + - Behavior event raster — `animal_response` (L/R choice, ignore), + `rewarded_history` (L/R earned water), manual water times, and + `auto_water` (L/R) per trial. + - Reward probabilities — `reward_probabilityL` / `reward_probabilityR` + per trial. + +### Contraqctor-based QA suites (per meeting with Alex, 2026-06-03) + +Same approach as VR foraging QA. + +The runner is provided by +[`aind_behavior_dynamic_foraging.data_qc.suite.make_qc_runner(dataset)`](https://github.com/AllenNeuralDynamics/Aind.Behavior.DynamicForaging/blob/main/src/aind_behavior_dynamic_foraging/data_qc/suite.py), +so just needs to call it on `loader.dataset` and convert +the results. `make_qc_runner` already wires up: + +- `ContractTestSuite` (dataset loading errors, excluding Harp command streams) +- `HarpDeviceTestSuite` for every `HarpDevice` under `Behavior` +- `HarpHubTestSuite` +- `HarpLicketySplitTestSuite` for the left and right lickometers +- `HarpSniffDetectorTestSuite` / `HarpEnvironmentSensorTestSuite` (conditional on the rig) +- `CameraTestSuite` for every camera in `BehaviorVideos` (uses `rig.triggered_camera_controller.frame_rate`) +- `CsvTestSuite` for every CSV stream +- `DynamicForagingQcSuite` (currently `test_end_session_exists`) + +#### Result → `QCMetric` conversion + +Map contraqctor statuses onto schema statuses: + +```python +status_converter = { + qc.Status.PASSED: Status.PASS, + qc.Status.SKIPPED: Status.PASS, + qc.Status.WARNING: Status.PENDING, + qc.Status.FAILED: Status.FAIL, + qc.Status.ERROR: Status.FAIL, +} +``` + +For each `qc.Result`: + +- `name = f"{result.suite_name}::{result.test_name}"` +- `description = f"Test: {result.description} // Message: {result.message}"` +- `value = convert_numpy_to_python_data_type(result.result)` +- `status_history = [QCStatus(evaluator="Automated", status=..., timestamp=now_utc)]` +- `modality = Modality.BEHAVIOR`, `stage = Stage.RAW` +- `tags = {"test_suite": result.suite_name, result.suite_name: group_name}` + — one fixed `"test_suite"` key whose value is the suite name, plus a + dynamic key (the suite name) whose value is the runner group (defaulting + to `"NoGroup"`). +- `reference`: if `result.context["asset"]` is a `matplotlib.figure.Figure`, + save it under the results folder and store the relative path. + +#### Updated tag / grouping plan + +| Tag key | Values | +| --- | --- | +| `behavior` | metric name (e.g. `average side bias`, `Left Lick Interval (%)`) | +| `test_suite` | only on contraqctor metrics; suite name (e.g. `HarpEnvironmentSensorTestSuite`) | + +`default_grouping` tells the QC portal which tag *keys* to use when +laying out the metrics hierarchically (see the schema field's +[description](https://github.com/AllenNeuralDynamics/aind-data-schema/blob/dev/src/aind_data_schema/core/quality_control.py)). +Each entry is a tag key (or a list of tag keys at the same level); the +portal walks them in order and groups metrics by the values it finds for +those keys. + +So `behavior` and `test_suite` are siblings at the top level; a metric +ends up under whichever one its tags match. They don't overlap because +the two groups of metrics carry disjoint tag keys. + +Sample portal layout: + +``` +behavior + Metric... + Metric... + +test_suite + Metric... + Metric... +``` + +## Changelog + +| Date | Section | Change | Reason | +| --- | --- | --- | --- | +| 2026-06-03 | metrics | Confirmed kept QC metrics: side bias, lick intervals, and Harp/contract QA via `make_qc_runner`. Dropped checks tied to old `behavior.json` (dropped frames, basic configuration). | Meeting with Alex. | +| 2026-06-03 | qa | Adopt contraqctor `qc.Runner` output (`make_qc_runner(dataset)`) as the source for Harp / camera / contract / DynamicForaging QA, converted into `QCMetric`s. | Meeting with Alex. | +| 2026-06-22 | metrics, data inputs, plots | Side bias is read from the precomputed per-trial `side_bias` column (averaged via `nanmean`) instead of being recomputed from `animal_response`; dropped the `B_Bias_CI` confidence-interval band. | Reflect implemented `side_bias_result` / `plot_side_bias`. | diff --git a/docs/trials_table_mapping.md b/docs/trials_table_mapping.md new file mode 100644 index 0000000..d53a059 --- /dev/null +++ b/docs/trials_table_mapping.md @@ -0,0 +1,157 @@ +# Mapping of Raw Acquisition Streams to the NWB Trials Table + +This document describes how the NWB `acquisition` container and the `trials` table +are constructed from the raw dynamic foraging acquisition streams. + +Reference asset used while mapping: +[behavior_836626_2026-05-20_14-19-10_processed_2026-05-21_17-40-47](https://codeocean.allenneuraldynamics.org/data-assets/49d1b596-c1a0-4c52-a3dd-26181f4b2b55/behavior_836626_2026-05-20_14-19-10_processed_2026-05-21_17-40-47). + +Trial column descriptions are derived from +[`nwb_trial_column_info.json`](https://github.com/AllenNeuralDynamics/aind-fip-nwb-base-capsule/blob/main/code/util/nwb_trial_column_info.json) +in the combined pipeline. + +> **Note:** Any column related to `autoTrain` can be disregarded (per meeting with +> Alex on June 3rd, 2026). + +## Acquisition Container + +The NWB `acquisition` container holds four behavior-related time series: + +| Acquisition series | Source stream | Notes | +| --- | --- | --- | +| `left_lick_time` | `Behavior/Lickometer` | | +| `right_lick_time` | `Behavior/Lickometer` | | +| `left_reward_delivery_time` | `Behavior/HarpBehavior` `OutputSet` (`SupplyPort0`, `WRITE` messages) | The left valve open timestamp. | +| `right_reward_delivery_time` | `Behavior/HarpBehavior` `OutputSet` (`SupplyPort1`, `WRITE` messages) | The right valve open timestamp. | + +Earlier mapping used `Response.json` (`SoftwareEvents`) for lick times (where +`Item1` is the time and `Item2` is `left`/`right`) and `TrialOutcome.json` +(filtered on `is_rewarded`, then `left`/`right`) for reward delivery times. +Lick times now come from the `Behavior/Lickometer` stream, and reward delivery +times use the Harp valve open times. + +## Trials Table + +Columns are grouped by the raw source they map from. + +### From `task_logic_input` (under `Logs`, `trial_generator` key) + +| Trials column | Source field | +| --- | --- | +| `ITI_beta`, `ITI_min`, `ITI_max`, `ITI_duration` | `inter_trial_interval_duration` | +| `block_beta`, `block_duration`, `block_min`, `block_max` | `block_length` | +| `delay_beta`, `delay_duration`, `delay_min`, `delay_max` | `quiescent_duration_key` (scalar distribution, so no beta/min/max) | + +### From `task_logic_input` (under `task_parameters`) + +| Trials column | Source field | +| --- | --- | +| `reward_size_left` | `task_parameters.reward_size.left_value_volume` — the reward volume (uL) at the left port. | +| `reward_size_right` | `task_parameters.reward_size.right_value_volume` — the reward volume (uL) at the right port. | + +> **Note:** `reward_size` is read from the task parameters, not the trial +> generator, so it is populated even when no summarising generator is resolved. +> The acquisition system can in principle vary reward size per trial, but the +> current data format only exposes a single session-level value, so these +> columns are constant across trials. They are **required** (non-nullable): a +> missing `TaskLogic` stream raises rather than silently producing null reward +> sizes when there are trials to build. + +### From `TrialMetrics.json` (`SoftwareEvents` stream) + +| Trials column | Mapping | +| --- | --- | +| `side_bias` | Per-trial `bias` field from the `TrialMetrics` event (negative → left bias, positive → right bias). `None` when not recorded. Aligned by position with `TrialOutcome`. | + +### From `Response.json` (`SoftwareEvents` stream) + +| Trials column | Mapping | +| --- | --- | +| `animal_response` | From the event payload `{ "Item1":