aind-behavior-vr-foraging-packaging 0.0.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aind_behavior_vr_foraging_packaging-0.0.3/PKG-INFO +156 -0
- aind_behavior_vr_foraging_packaging-0.0.3/README.md +125 -0
- aind_behavior_vr_foraging_packaging-0.0.3/pyproject.toml +83 -0
- aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/__init__.py +30 -0
- aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/_base.py +98 -0
- aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/acquisition/__init__.py +94 -0
- aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/acquisition/helper.py +85 -0
- aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/cli.py +2 -0
- aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/models.py +84 -0
- aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/nwb_file/__init__.py +144 -0
- aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/pipeline.py +163 -0
- aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/processing/__init__.py +20 -0
- aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/processing/_events.py +88 -0
- aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/processing/_helper.py +131 -0
- aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/processing/_legacy_position_and_velocity.py +123 -0
- aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/processing/_legacy_trial_table.py +167 -0
- aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/processing/_licks.py +117 -0
- aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/processing/_position_and_velocity.py +119 -0
- aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/processing/_sniffing.py +97 -0
- aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/processing/_software_events.py +125 -0
- aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/processing/_trial_table.py +394 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aind-behavior-vr-foraging-packaging
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: Parses and packages AIND VR Foraging behavioral sessions into tabular (parquet) and NWB outputs.
|
|
5
|
+
Author: Bruno Cruz
|
|
6
|
+
Author-email: Bruno Cruz <bruno.cruz@alleninstitute.org>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
12
|
+
Requires-Dist: aind-behavior-vr-foraging[data]>=1
|
|
13
|
+
Requires-Dist: aind-data-schema>2.7
|
|
14
|
+
Requires-Dist: hdmf-zarr
|
|
15
|
+
Requires-Dist: numpy>=2
|
|
16
|
+
Requires-Dist: pandas
|
|
17
|
+
Requires-Dist: pydantic-settings>=2.10.1
|
|
18
|
+
Requires-Dist: pydantic>=2.10
|
|
19
|
+
Requires-Dist: pynwb
|
|
20
|
+
Requires-Dist: scipy
|
|
21
|
+
Requires-Dist: pydantic-settings
|
|
22
|
+
Requires-Dist: aind-nwb-utils
|
|
23
|
+
Requires-Dist: ndx-events
|
|
24
|
+
Requires-Dist: aind-data-access-api>=1.9.2
|
|
25
|
+
Requires-Python: >=3.11
|
|
26
|
+
Project-URL: Documentation, https://allenneuraldynamics.github.io/Aind.Behavior.VrForaging.Packaging/
|
|
27
|
+
Project-URL: Repository, https://github.com/AllenNeuralDynamics/Aind.Behavior.VrForaging.Packaging/
|
|
28
|
+
Project-URL: Issues, https://github.com/AllenNeuralDynamics/Aind.Behavior.VrForaging.Packaging/issues
|
|
29
|
+
Project-URL: Changelog, https://github.com/AllenNeuralDynamics/Aind.Behavior.VrForaging.Packaging/releases
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# Aind.Behavior.VrForaging.Packaging
|
|
33
|
+
|
|
34
|
+

|
|
35
|
+
[](LICENSE)
|
|
36
|
+
[](https://github.com/astral-sh/ruff)
|
|
37
|
+
[](https://github.com/astral-sh/uv)
|
|
38
|
+
|
|
39
|
+
Parses raw AIND VR-foraging behavioral sessions into analysis-ready **parquet** tables and an **NWB** file.
|
|
40
|
+
|
|
41
|
+
## Architecture
|
|
42
|
+
|
|
43
|
+
A session is loaded once (via `contraqctor`), then a set of independent
|
|
44
|
+
**processors** fan out over it. Each processor owns one output and knows how to
|
|
45
|
+
express it in two targets:
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
raw session dir
|
|
49
|
+
│
|
|
50
|
+
▼
|
|
51
|
+
Dataset ◄── aind_behavior_vr_foraging.data_contract.dataset(path)
|
|
52
|
+
│
|
|
53
|
+
▼
|
|
54
|
+
create_processors(dataset) # picks processor variants by dataset version
|
|
55
|
+
│ [TrialTable, PositionAndVelocity, Licks, Sniffing, SoftwareEvents, Events]
|
|
56
|
+
│
|
|
57
|
+
├─► proc.compute() ──► pandas DataFrame ──► one <name>.parquet (run_session)
|
|
58
|
+
│ (provenance stamped into df.attrs / parquet schema)
|
|
59
|
+
│
|
|
60
|
+
└─► proc.nwbize(nwb) ──► populates an NdxEventsNWBFile ──► .nwb.zarr (NwbSession)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- **Processor** — every processor subclasses `AbstractProcessor`, implementing
|
|
64
|
+
`_compute()` and (optionally) `nwbize()`. `compute()` wraps `_compute()` and
|
|
65
|
+
stamps provenance (`packaging_version`, `data_contract_version`,
|
|
66
|
+
`dataset_version`, `processor`) into the DataFrame's `attrs`.
|
|
67
|
+
- **DataFrame** — the common in-memory representation. One row per unit of the
|
|
68
|
+
output (e.g. one trial-table row = one *site*).
|
|
69
|
+
- **Parquet** — `pipeline.run_session()` calls `compute()` on each processor and
|
|
70
|
+
writes a parquet per processor, promoting `df.attrs` to first-class parquet
|
|
71
|
+
metadata (readable from DuckDB, Polars, R arrow, Spark, …).
|
|
72
|
+
- **NWB** — `NwbSession` builds a single `NdxEventsNWBFile` from AIND metadata,
|
|
73
|
+
then calls each processor's `nwbize()` to fill it, and writes NWB-Zarr.
|
|
74
|
+
|
|
75
|
+
Version dispatch is automatic: datasets with schema version `< 0.6.0` receive
|
|
76
|
+
legacy processor variants.
|
|
77
|
+
|
|
78
|
+
## Examples
|
|
79
|
+
|
|
80
|
+
- Runnable script covering the parquet workflows (all-at-once, single stream,
|
|
81
|
+
load-back): [scripts/example_parquet_pipeline.py](scripts/example_parquet_pipeline.py)
|
|
82
|
+
- The NWB workflow: [docs/knowledge/architecture/nwb-packaging.md](docs/knowledge/architecture/nwb-packaging.md)
|
|
83
|
+
- Full architecture docs: [docs/knowledge/](docs/knowledge/) (start at [overview.md](docs/knowledge/overview.md))
|
|
84
|
+
|
|
85
|
+
### Get a trials table
|
|
86
|
+
|
|
87
|
+
Install straight from GitHub with [uv](https://docs.astral.sh/uv/):
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# into a uv project
|
|
91
|
+
uv add "git+https://github.com/AllenNeuralDynamics/Aind.Behavior.VrForaging.Packaging.git"
|
|
92
|
+
|
|
93
|
+
# or into the current environment
|
|
94
|
+
uv pip install "git+https://github.com/AllenNeuralDynamics/Aind.Behavior.VrForaging.Packaging.git"
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Then load a session and compute the trials table (one row per *site*):
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from aind_behavior_vr_foraging.data_contract import dataset
|
|
101
|
+
from aind_behavior_vr_foraging_packaging.pipeline import get_trial_table_processor
|
|
102
|
+
|
|
103
|
+
ds = dataset("path/to/session") # load the raw session
|
|
104
|
+
trials_df = get_trial_table_processor(ds).compute()
|
|
105
|
+
|
|
106
|
+
trials_df.to_parquet("trials.parquet") # optional: persist to disk
|
|
107
|
+
print(f"{len(trials_df)} sites, {trials_df['has_reward'].sum()} rewarded")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`get_trial_table_processor` automatically picks the current or legacy variant
|
|
111
|
+
based on the dataset's schema version. To produce every table at once, use
|
|
112
|
+
`run_session(ds, "output_dir")` instead — it writes `trials.parquet`,
|
|
113
|
+
`position_velocity.parquet`, and the rest, and returns them keyed by name.
|
|
114
|
+
|
|
115
|
+
## Contributors
|
|
116
|
+
|
|
117
|
+
Contributions to this repository are welcome! However, please ensure that your code adheres to the recommended DevOps practices below:
|
|
118
|
+
|
|
119
|
+
### Linting
|
|
120
|
+
|
|
121
|
+
We use [ruff](https://docs.astral.sh/ruff/) as our primary linting tool.
|
|
122
|
+
|
|
123
|
+
### Testing
|
|
124
|
+
|
|
125
|
+
Attempt to add tests when new features are added.
|
|
126
|
+
To run the currently available tests, run `uv run pytest` from the root of the repository.
|
|
127
|
+
|
|
128
|
+
## Integration tests
|
|
129
|
+
|
|
130
|
+
Integration tests run the parser end-to-end against real datasets stored in a public S3 bucket. They are gated by a pytest marker so they don't run by default.
|
|
131
|
+
|
|
132
|
+
**Run locally:**
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
uv run pytest -m integration
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The first run downloads datasets (~100 MB per dataset) to `tests/integration/.cache/`. Subsequent runs reuse the cache when the S3 ETag matches. The cache directory is gitignored.
|
|
139
|
+
|
|
140
|
+
**Trigger on a PR:**
|
|
141
|
+
|
|
142
|
+
Integration tests do not run on every PR. To run them for a specific PR, add the `run-integration` label via the GitHub UI (open the PR, click **Labels** in the right-hand sidebar, and select `run-integration`) or with:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
gh pr edit <PR_NUMBER> --add-label run-integration
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The integration job runs automatically on push to `main` and on `release: published`. A release cannot ship without the integration suite passing.
|
|
149
|
+
|
|
150
|
+
**Adding a dataset:**
|
|
151
|
+
|
|
152
|
+
Add an entry to `tests/integration/datasets.yml`. The manifest schema and full field documentation are in `tests/integration/model.py` (Pydantic model). The `rationale` field is required and is printed alongside any test failure to make triage fast.
|
|
153
|
+
|
|
154
|
+
### Lock files
|
|
155
|
+
|
|
156
|
+
We use [uv](https://docs.astral.sh/uv/) to manage our lock files and therefore encourage everyone to use uv as a package manager as well.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Aind.Behavior.VrForaging.Packaging
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://github.com/astral-sh/ruff)
|
|
6
|
+
[](https://github.com/astral-sh/uv)
|
|
7
|
+
|
|
8
|
+
Parses raw AIND VR-foraging behavioral sessions into analysis-ready **parquet** tables and an **NWB** file.
|
|
9
|
+
|
|
10
|
+
## Architecture
|
|
11
|
+
|
|
12
|
+
A session is loaded once (via `contraqctor`), then a set of independent
|
|
13
|
+
**processors** fan out over it. Each processor owns one output and knows how to
|
|
14
|
+
express it in two targets:
|
|
15
|
+
|
|
16
|
+
```text
|
|
17
|
+
raw session dir
|
|
18
|
+
│
|
|
19
|
+
▼
|
|
20
|
+
Dataset ◄── aind_behavior_vr_foraging.data_contract.dataset(path)
|
|
21
|
+
│
|
|
22
|
+
▼
|
|
23
|
+
create_processors(dataset) # picks processor variants by dataset version
|
|
24
|
+
│ [TrialTable, PositionAndVelocity, Licks, Sniffing, SoftwareEvents, Events]
|
|
25
|
+
│
|
|
26
|
+
├─► proc.compute() ──► pandas DataFrame ──► one <name>.parquet (run_session)
|
|
27
|
+
│ (provenance stamped into df.attrs / parquet schema)
|
|
28
|
+
│
|
|
29
|
+
└─► proc.nwbize(nwb) ──► populates an NdxEventsNWBFile ──► .nwb.zarr (NwbSession)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- **Processor** — every processor subclasses `AbstractProcessor`, implementing
|
|
33
|
+
`_compute()` and (optionally) `nwbize()`. `compute()` wraps `_compute()` and
|
|
34
|
+
stamps provenance (`packaging_version`, `data_contract_version`,
|
|
35
|
+
`dataset_version`, `processor`) into the DataFrame's `attrs`.
|
|
36
|
+
- **DataFrame** — the common in-memory representation. One row per unit of the
|
|
37
|
+
output (e.g. one trial-table row = one *site*).
|
|
38
|
+
- **Parquet** — `pipeline.run_session()` calls `compute()` on each processor and
|
|
39
|
+
writes a parquet per processor, promoting `df.attrs` to first-class parquet
|
|
40
|
+
metadata (readable from DuckDB, Polars, R arrow, Spark, …).
|
|
41
|
+
- **NWB** — `NwbSession` builds a single `NdxEventsNWBFile` from AIND metadata,
|
|
42
|
+
then calls each processor's `nwbize()` to fill it, and writes NWB-Zarr.
|
|
43
|
+
|
|
44
|
+
Version dispatch is automatic: datasets with schema version `< 0.6.0` receive
|
|
45
|
+
legacy processor variants.
|
|
46
|
+
|
|
47
|
+
## Examples
|
|
48
|
+
|
|
49
|
+
- Runnable script covering the parquet workflows (all-at-once, single stream,
|
|
50
|
+
load-back): [scripts/example_parquet_pipeline.py](scripts/example_parquet_pipeline.py)
|
|
51
|
+
- The NWB workflow: [docs/knowledge/architecture/nwb-packaging.md](docs/knowledge/architecture/nwb-packaging.md)
|
|
52
|
+
- Full architecture docs: [docs/knowledge/](docs/knowledge/) (start at [overview.md](docs/knowledge/overview.md))
|
|
53
|
+
|
|
54
|
+
### Get a trials table
|
|
55
|
+
|
|
56
|
+
Install straight from GitHub with [uv](https://docs.astral.sh/uv/):
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# into a uv project
|
|
60
|
+
uv add "git+https://github.com/AllenNeuralDynamics/Aind.Behavior.VrForaging.Packaging.git"
|
|
61
|
+
|
|
62
|
+
# or into the current environment
|
|
63
|
+
uv pip install "git+https://github.com/AllenNeuralDynamics/Aind.Behavior.VrForaging.Packaging.git"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Then load a session and compute the trials table (one row per *site*):
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from aind_behavior_vr_foraging.data_contract import dataset
|
|
70
|
+
from aind_behavior_vr_foraging_packaging.pipeline import get_trial_table_processor
|
|
71
|
+
|
|
72
|
+
ds = dataset("path/to/session") # load the raw session
|
|
73
|
+
trials_df = get_trial_table_processor(ds).compute()
|
|
74
|
+
|
|
75
|
+
trials_df.to_parquet("trials.parquet") # optional: persist to disk
|
|
76
|
+
print(f"{len(trials_df)} sites, {trials_df['has_reward'].sum()} rewarded")
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`get_trial_table_processor` automatically picks the current or legacy variant
|
|
80
|
+
based on the dataset's schema version. To produce every table at once, use
|
|
81
|
+
`run_session(ds, "output_dir")` instead — it writes `trials.parquet`,
|
|
82
|
+
`position_velocity.parquet`, and the rest, and returns them keyed by name.
|
|
83
|
+
|
|
84
|
+
## Contributors
|
|
85
|
+
|
|
86
|
+
Contributions to this repository are welcome! However, please ensure that your code adheres to the recommended DevOps practices below:
|
|
87
|
+
|
|
88
|
+
### Linting
|
|
89
|
+
|
|
90
|
+
We use [ruff](https://docs.astral.sh/ruff/) as our primary linting tool.
|
|
91
|
+
|
|
92
|
+
### Testing
|
|
93
|
+
|
|
94
|
+
Attempt to add tests when new features are added.
|
|
95
|
+
To run the currently available tests, run `uv run pytest` from the root of the repository.
|
|
96
|
+
|
|
97
|
+
## Integration tests
|
|
98
|
+
|
|
99
|
+
Integration tests run the parser end-to-end against real datasets stored in a public S3 bucket. They are gated by a pytest marker so they don't run by default.
|
|
100
|
+
|
|
101
|
+
**Run locally:**
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
uv run pytest -m integration
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The first run downloads datasets (~100 MB per dataset) to `tests/integration/.cache/`. Subsequent runs reuse the cache when the S3 ETag matches. The cache directory is gitignored.
|
|
108
|
+
|
|
109
|
+
**Trigger on a PR:**
|
|
110
|
+
|
|
111
|
+
Integration tests do not run on every PR. To run them for a specific PR, add the `run-integration` label via the GitHub UI (open the PR, click **Labels** in the right-hand sidebar, and select `run-integration`) or with:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
gh pr edit <PR_NUMBER> --add-label run-integration
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The integration job runs automatically on push to `main` and on `release: published`. A release cannot ship without the integration suite passing.
|
|
118
|
+
|
|
119
|
+
**Adding a dataset:**
|
|
120
|
+
|
|
121
|
+
Add an entry to `tests/integration/datasets.yml`. The manifest schema and full field documentation are in `tests/integration/model.py` (Pydantic model). The `rationale` field is required and is printed alongside any test failure to make triage fast.
|
|
122
|
+
|
|
123
|
+
### Lock files
|
|
124
|
+
|
|
125
|
+
We use [uv](https://docs.astral.sh/uv/) to manage our lock files and therefore encourage everyone to use uv as a package manager as well.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.8.22"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "aind-behavior-vr-foraging-packaging"
|
|
7
|
+
description = "Parses and packages AIND VR Foraging behavioral sessions into tabular (parquet) and NWB outputs."
|
|
8
|
+
authors = [
|
|
9
|
+
{name = "Bruno Cruz", email = "bruno.cruz@alleninstitute.org"},
|
|
10
|
+
]
|
|
11
|
+
license = "MIT"
|
|
12
|
+
version = "0.0.3"
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Operating System :: Microsoft :: Windows",
|
|
19
|
+
]
|
|
20
|
+
readme = {file = "README.md", content-type = "text/markdown"}
|
|
21
|
+
|
|
22
|
+
dependencies = [
|
|
23
|
+
"aind-behavior-vr-foraging[data] >= 1",
|
|
24
|
+
"aind-data-schema>2.7",
|
|
25
|
+
"hdmf-zarr",
|
|
26
|
+
"numpy>=2",
|
|
27
|
+
"pandas",
|
|
28
|
+
"pydantic-settings>=2.10.1",
|
|
29
|
+
"pydantic>=2.10",
|
|
30
|
+
"pynwb",
|
|
31
|
+
"scipy",
|
|
32
|
+
"pydantic-settings",
|
|
33
|
+
"aind-nwb-utils",
|
|
34
|
+
"ndx-events",
|
|
35
|
+
"aind-data-access-api>=1.9.2",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Documentation = "https://allenneuraldynamics.github.io/Aind.Behavior.VrForaging.Packaging/"
|
|
40
|
+
Repository = "https://github.com/AllenNeuralDynamics/Aind.Behavior.VrForaging.Packaging/"
|
|
41
|
+
Issues = "https://github.com/AllenNeuralDynamics/Aind.Behavior.VrForaging.Packaging/issues"
|
|
42
|
+
Changelog = "https://github.com/AllenNeuralDynamics/Aind.Behavior.VrForaging.Packaging/releases"
|
|
43
|
+
|
|
44
|
+
[dependency-groups]
|
|
45
|
+
|
|
46
|
+
dev = [
|
|
47
|
+
'ruff',
|
|
48
|
+
'pytest',
|
|
49
|
+
'pytest-cov',
|
|
50
|
+
'codespell',
|
|
51
|
+
'ty',
|
|
52
|
+
"pyarrow>=24.0.0",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.uv]
|
|
56
|
+
default-groups = ['dev']
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 120
|
|
60
|
+
target-version = 'py311'
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
extend-select = ['Q', 'RUF100', 'C90', 'I']
|
|
64
|
+
extend-ignore = []
|
|
65
|
+
mccabe = { max-complexity = 14 }
|
|
66
|
+
pydocstyle = { convention = 'google' }
|
|
67
|
+
|
|
68
|
+
[tool.codespell]
|
|
69
|
+
skip = '.git,*.pdf,*.svg,uv.lock'
|
|
70
|
+
ignore-words-list = 'nd'
|
|
71
|
+
|
|
72
|
+
[tool.pytest.ini_options]
|
|
73
|
+
addopts = "--strict-markers --tb=short --cov=src --cov-report=term-missing --cov-fail-under=0 -m 'not integration'"
|
|
74
|
+
markers = [
|
|
75
|
+
"integration: integration tests that download data from S3 (run with `-m integration`)",
|
|
76
|
+
]
|
|
77
|
+
testpaths = ["tests"]
|
|
78
|
+
python_files = ["test_*.py"]
|
|
79
|
+
python_classes = ["Test*"]
|
|
80
|
+
python_functions = ["test_*"]
|
|
81
|
+
|
|
82
|
+
[project.scripts]
|
|
83
|
+
curriculum = "aind_behavior_vr_foraging_packaging.cli:main"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def pep440_to_semver(ver: str) -> str:
|
|
6
|
+
"""
|
|
7
|
+
Convert a PEP 440 version to a SemVer-compatible string.
|
|
8
|
+
|
|
9
|
+
Examples:
|
|
10
|
+
1.2.3rc2 -> 1.2.3-rc2
|
|
11
|
+
1.2.3a1 -> 1.2.3-a1
|
|
12
|
+
1.2.3b1 -> 1.2.3-b1
|
|
13
|
+
1.2.3.dev4 -> 1.2.3-dev4
|
|
14
|
+
1.2.3.post1 -> 1.2.3+post1
|
|
15
|
+
"""
|
|
16
|
+
# pre-release: a, b, rc -> -aN, -bN, -rcN
|
|
17
|
+
ver = re.sub(r"(?<=\d)(a|b|rc)(\d+)", r"-\1\2", ver)
|
|
18
|
+
# dev release: .devN -> -devN
|
|
19
|
+
ver = re.sub(r"\.dev(\d+)", r"-dev\1", ver)
|
|
20
|
+
# post release: .postN -> +postN
|
|
21
|
+
ver = re.sub(r"\.post(\d+)", r"+post\1", ver)
|
|
22
|
+
return ver
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
__version__ = version(__name__)
|
|
27
|
+
except PackageNotFoundError:
|
|
28
|
+
__version__ = "0.0.0"
|
|
29
|
+
|
|
30
|
+
__semver__ = pep440_to_semver(__version__)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import re
|
|
3
|
+
import typing as ty
|
|
4
|
+
|
|
5
|
+
import aind_behavior_vr_foraging
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import semver
|
|
8
|
+
from contraqctor.contract import Dataset
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _class_name_to_snake(name: str) -> str:
|
|
12
|
+
"""Convert a CamelCase class name to snake_case, e.g. ``LicksProcessor`` → ``licks_processor``."""
|
|
13
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AbstractProcessor(abc.ABC):
|
|
17
|
+
#: Override in subclasses to set a canonical parquet filename stem (e.g. ``"trials"``).
|
|
18
|
+
#: When ``None`` (the default), ``output_name`` falls back to a snake_case of the class name.
|
|
19
|
+
__output_name__: ty.ClassVar[str | None] = None
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def output_name(self) -> str:
|
|
23
|
+
"""Canonical name used as the parquet filename stem.
|
|
24
|
+
|
|
25
|
+
Returns ``__output_name__`` if defined on the class, otherwise a
|
|
26
|
+
snake_case of the class name (e.g. ``LicksProcessor`` → ``licks_processor``).
|
|
27
|
+
"""
|
|
28
|
+
return self.__class__.__output_name__ or _class_name_to_snake(type(self).__name__)
|
|
29
|
+
|
|
30
|
+
def __init__(self, dataset: Dataset, *, raise_on_error: bool = False) -> None:
|
|
31
|
+
self._dataset = dataset
|
|
32
|
+
self._raise_on_error = raise_on_error
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def dataset(self) -> Dataset:
|
|
36
|
+
return self._dataset
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def dataset_version(self) -> semver.Version:
|
|
40
|
+
return self._parse_version(self.dataset.version)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def parser_version(self) -> semver.Version:
|
|
44
|
+
return semver.Version.parse(aind_behavior_vr_foraging.__semver__)
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def _parse_version(value: str | semver.Version) -> semver.Version:
|
|
48
|
+
if isinstance(value, semver.Version):
|
|
49
|
+
return value
|
|
50
|
+
return semver.Version.parse(value)
|
|
51
|
+
|
|
52
|
+
@abc.abstractmethod
|
|
53
|
+
def _compute(self) -> pd.DataFrame:
|
|
54
|
+
"""Compute this processor's output as a DataFrame.
|
|
55
|
+
|
|
56
|
+
Subclasses implement this method. Callers should use :meth:`compute`,
|
|
57
|
+
which wraps ``_compute`` and stamps provenance metadata into ``df.attrs``.
|
|
58
|
+
"""
|
|
59
|
+
raise NotImplementedError
|
|
60
|
+
|
|
61
|
+
def compute(self) -> pd.DataFrame:
|
|
62
|
+
"""Return the processor's output DataFrame with provenance metadata in attrs.
|
|
63
|
+
|
|
64
|
+
Calls :meth:`_compute`, then stamps ``df.attrs`` with:
|
|
65
|
+
|
|
66
|
+
- ``packaging_version``: version of this package (``aind-behavior-vr-foraging-packaging``)
|
|
67
|
+
- ``data_contract_version``: version of ``aind-behavior-vr-foraging`` (defines the behavioral data schema)
|
|
68
|
+
- ``dataset_version``: actual version recorded in the session's ``tasklogic_input.json``
|
|
69
|
+
- ``processor``: this processor's class name
|
|
70
|
+
|
|
71
|
+
Attrs already set by ``_compute`` (e.g. ``sampling_rate_hz`` from
|
|
72
|
+
:class:`SniffingProcessor`) are preserved via ``setdefault``.
|
|
73
|
+
"""
|
|
74
|
+
from importlib.metadata import version as _pkg_version
|
|
75
|
+
|
|
76
|
+
df = self._compute()
|
|
77
|
+
df.attrs.setdefault("packaging_version", _pkg_version("aind-behavior-vr-foraging-packaging"))
|
|
78
|
+
df.attrs.setdefault("data_contract_version", str(self.parser_version))
|
|
79
|
+
df.attrs.setdefault("dataset_version", str(self.dataset_version))
|
|
80
|
+
df.attrs.setdefault("processor", type(self).__name__)
|
|
81
|
+
return df
|
|
82
|
+
|
|
83
|
+
def nwbize(self, nwb_file: ty.Any) -> ty.Any:
|
|
84
|
+
"""Write this processor's output to *nwb_file* and return it.
|
|
85
|
+
|
|
86
|
+
Default implementation is a no-op. Override in subclasses that have
|
|
87
|
+
an NWB representation. May call ``compute()`` internally; the two
|
|
88
|
+
methods are intentionally independent (no shared state).
|
|
89
|
+
"""
|
|
90
|
+
return nwb_file
|
|
91
|
+
|
|
92
|
+
def with_raise_errors(self, raise_on_error: bool = True) -> ty.Self:
|
|
93
|
+
self._raise_on_error = raise_on_error
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def raise_on_error(self) -> bool:
|
|
98
|
+
return self._raise_on_error
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import typing as ty
|
|
3
|
+
|
|
4
|
+
import contraqctor.contract as data_contract
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from .._base import AbstractProcessor
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AcquisitionProcessor(AbstractProcessor):
|
|
13
|
+
__output_name__ = "acquisition"
|
|
14
|
+
|
|
15
|
+
def __init__(self, dataset: data_contract.Dataset, *, raise_on_error: bool = False) -> None:
|
|
16
|
+
super().__init__(dataset, raise_on_error=raise_on_error)
|
|
17
|
+
|
|
18
|
+
def _compute(self) -> pd.DataFrame:
|
|
19
|
+
"""Returns a tall DataFrame of all acquisition streams.
|
|
20
|
+
|
|
21
|
+
Includes a ``_stream_name`` column (e.g. ``"Behavior.HarpBehavior.PwmStart"``)
|
|
22
|
+
so the parquet can be filtered per stream downstream.
|
|
23
|
+
"""
|
|
24
|
+
_ = self._dataset.load_all(strict=False)
|
|
25
|
+
frames = []
|
|
26
|
+
for stream in self._dataset.iter_all():
|
|
27
|
+
if stream.is_collection or stream.has_error:
|
|
28
|
+
if stream.is_collection:
|
|
29
|
+
continue
|
|
30
|
+
logger.debug("Stream %s has error: %s", stream.name, stream.collect_errors())
|
|
31
|
+
if self._raise_on_error:
|
|
32
|
+
raise ValueError(f"Stream {stream.name} has error")
|
|
33
|
+
continue
|
|
34
|
+
name = stream.resolved_name.replace("::", ".")
|
|
35
|
+
try:
|
|
36
|
+
df = stream.data.reset_index()
|
|
37
|
+
df["_stream_name"] = name
|
|
38
|
+
frames.append(df)
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
if self._raise_on_error:
|
|
41
|
+
raise
|
|
42
|
+
logger.debug("Could not load stream %s: %s", stream.name, exc)
|
|
43
|
+
return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
|
|
44
|
+
|
|
45
|
+
def nwbize(self, nwb_file: ty.Any) -> ty.Any:
|
|
46
|
+
"""Add all acquisition streams as DynamicTables to *nwb_file*."""
|
|
47
|
+
import contraqctor.contract as data_contract
|
|
48
|
+
import pynwb
|
|
49
|
+
|
|
50
|
+
from .helper import clean_dataframe_for_nwb
|
|
51
|
+
|
|
52
|
+
_ = self._dataset.load_all(strict=False)
|
|
53
|
+
for stream in self._dataset.iter_all():
|
|
54
|
+
if stream.is_collection:
|
|
55
|
+
err = stream.collect_errors()
|
|
56
|
+
if err:
|
|
57
|
+
logger.debug("Collection stream %s has errors: %s", stream.name, err)
|
|
58
|
+
if self._raise_on_error:
|
|
59
|
+
raise ValueError(f"Collection stream {stream.name} has errors: {err}")
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
name = stream.resolved_name.replace("::", ".")
|
|
63
|
+
try:
|
|
64
|
+
if stream.has_error:
|
|
65
|
+
logger.debug("Stream %s has error: %s", stream.name, stream.collect_errors())
|
|
66
|
+
if self._raise_on_error:
|
|
67
|
+
raise ValueError(f"Stream {stream.name} has error: {stream.collect_errors()}")
|
|
68
|
+
continue
|
|
69
|
+
if isinstance(stream, (data_contract.harp.HarpRegister, data_contract.csv.Csv)):
|
|
70
|
+
table = pynwb.core.DynamicTable.from_dataframe(
|
|
71
|
+
name=name, table_description=stream.description, df=stream.data.reset_index()
|
|
72
|
+
)
|
|
73
|
+
nwb_file.add_acquisition(table)
|
|
74
|
+
elif isinstance(stream, data_contract.json.SoftwareEvents):
|
|
75
|
+
table = pynwb.core.DynamicTable.from_dataframe(
|
|
76
|
+
name=name,
|
|
77
|
+
table_description=stream.description,
|
|
78
|
+
df=clean_dataframe_for_nwb(stream.data.reset_index()),
|
|
79
|
+
)
|
|
80
|
+
nwb_file.add_acquisition(table)
|
|
81
|
+
elif isinstance(stream, data_contract.json.PydanticModel):
|
|
82
|
+
nwb_file.add_acquisition(
|
|
83
|
+
pynwb.core.DynamicTable(
|
|
84
|
+
name=name,
|
|
85
|
+
description=stream.data.model_dump_json(),
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
raise ValueError(f"Stream {stream.name} has unsupported type {type(stream)}")
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
if self._raise_on_error:
|
|
92
|
+
raise
|
|
93
|
+
logger.debug("Error processing stream %s: %s", stream.name, exc)
|
|
94
|
+
return nwb_file
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any, Callable, Union
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def clean_dataframe_for_nwb(data: pd.DataFrame) -> pd.DataFrame:
|
|
11
|
+
"""
|
|
12
|
+
Clean a pandas DataFrame to ensure compatibility with NWB format.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
data : pd.DataFrame
|
|
17
|
+
The cleaned input DataFrame for NWB compatibility
|
|
18
|
+
|
|
19
|
+
Returns
|
|
20
|
+
-------
|
|
21
|
+
pd.DataFrame
|
|
22
|
+
A cleaned DataFrame that adheres to NWB data types
|
|
23
|
+
"""
|
|
24
|
+
for column in data.columns:
|
|
25
|
+
# convert to nwb allowable types
|
|
26
|
+
data[column] = data[column].replace({None: np.nan})
|
|
27
|
+
data[column] = data[column].apply(lambda x: x.value if isinstance(x, Enum) else x)
|
|
28
|
+
data[column] = data[column].apply(lambda x: json.dumps(x) if isinstance(x, dict) else x)
|
|
29
|
+
|
|
30
|
+
return data
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_NestedStructureType = Union[dict, list, Any]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def convert_values_in_nested_structure(
|
|
37
|
+
data: _NestedStructureType,
|
|
38
|
+
check_fn: Callable[[Any], bool],
|
|
39
|
+
convert_fn: Callable[[Any], Any],
|
|
40
|
+
) -> _NestedStructureType:
|
|
41
|
+
"""
|
|
42
|
+
Recursively convert values in nested dictionaries/lists based on a condition.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
data : _NestedStructureType
|
|
47
|
+
Input data structure which may contain nested dictionaries and lists.
|
|
48
|
+
check_fn : Callable
|
|
49
|
+
Function that returns True if value should be converted.
|
|
50
|
+
convert_fn : Callable
|
|
51
|
+
Function that converts the value.
|
|
52
|
+
|
|
53
|
+
Returns
|
|
54
|
+
-------
|
|
55
|
+
_NestedStructureType
|
|
56
|
+
Data structure with converted values.
|
|
57
|
+
"""
|
|
58
|
+
if isinstance(data, dict):
|
|
59
|
+
return {k: convert_values_in_nested_structure(v, check_fn, convert_fn) for k, v in data.items()}
|
|
60
|
+
if isinstance(data, list):
|
|
61
|
+
return [convert_values_in_nested_structure(item, check_fn, convert_fn) for item in data]
|
|
62
|
+
return convert_fn(data) if check_fn(data) else data
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def convert_datetimes_to_iso_string(
|
|
66
|
+
data: _NestedStructureType,
|
|
67
|
+
) -> _NestedStructureType:
|
|
68
|
+
"""
|
|
69
|
+
Convert datetime objects in a nested structure to ISO format strings.
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
data : _NestedStructureType
|
|
74
|
+
Input data structure which may contain nested dictionaries and lists.
|
|
75
|
+
|
|
76
|
+
Returns
|
|
77
|
+
-------
|
|
78
|
+
_NestedStructureType
|
|
79
|
+
Data structure with datetime objects converted to ISO format strings.
|
|
80
|
+
"""
|
|
81
|
+
return convert_values_in_nested_structure(
|
|
82
|
+
data,
|
|
83
|
+
check_fn=lambda x: isinstance(x, datetime),
|
|
84
|
+
convert_fn=lambda x: x.isoformat(),
|
|
85
|
+
)
|