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.
Files changed (21) hide show
  1. aind_behavior_vr_foraging_packaging-0.0.3/PKG-INFO +156 -0
  2. aind_behavior_vr_foraging_packaging-0.0.3/README.md +125 -0
  3. aind_behavior_vr_foraging_packaging-0.0.3/pyproject.toml +83 -0
  4. aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/__init__.py +30 -0
  5. aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/_base.py +98 -0
  6. aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/acquisition/__init__.py +94 -0
  7. aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/acquisition/helper.py +85 -0
  8. aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/cli.py +2 -0
  9. aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/models.py +84 -0
  10. aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/nwb_file/__init__.py +144 -0
  11. aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/pipeline.py +163 -0
  12. aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/processing/__init__.py +20 -0
  13. aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/processing/_events.py +88 -0
  14. aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/processing/_helper.py +131 -0
  15. aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/processing/_legacy_position_and_velocity.py +123 -0
  16. aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/processing/_legacy_trial_table.py +167 -0
  17. aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/processing/_licks.py +117 -0
  18. aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/processing/_position_and_velocity.py +119 -0
  19. aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/processing/_sniffing.py +97 -0
  20. aind_behavior_vr_foraging_packaging-0.0.3/src/aind_behavior_vr_foraging_packaging/processing/_software_events.py +125 -0
  21. 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
+ ![CI](https://github.com/AllenNeuralDynamics/Aind.Behavior.VrForaging.Packaging/actions/workflows/aind-behavior-vr-foraging-packaging.yml/badge.svg)
35
+ [![License](https://img.shields.io/badge/license-MIT-brightgreen)](LICENSE)
36
+ [![ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
37
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](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
+ ![CI](https://github.com/AllenNeuralDynamics/Aind.Behavior.VrForaging.Packaging/actions/workflows/aind-behavior-vr-foraging-packaging.yml/badge.svg)
4
+ [![License](https://img.shields.io/badge/license-MIT-brightgreen)](LICENSE)
5
+ [![ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
6
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](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
+ )