Skip to content

Processors

Every processor subclasses AbstractProcessor and implements two methods:

  • _compute() — returns a pandas.DataFrame with one row per output unit.
  • nwbize(nwb) — populates an NWBFile with the same data (optional).

compute() wraps _compute() and stamps provenance (packaging_version, data_contract_version, dataset_version, processor) into df.attrs.


AbstractProcessor

AbstractProcessor

Bases: ABC

Source code in src/aind_behavior_vr_foraging_packaging/_base.py
class AbstractProcessor(abc.ABC):
    #: Override in subclasses to set a canonical parquet filename stem (e.g. ``"sites"``).
    #: When ``None`` (the default), ``output_name`` falls back to a snake_case of the class name.
    __output_name__: ty.ClassVar[str | None] = None

    @property
    def output_name(self) -> str:
        """Canonical name used as the parquet filename stem.

        Returns ``__output_name__`` if defined on the class, otherwise a
        snake_case of the class name (e.g. ``LicksProcessor`` → ``licks_processor``).
        """
        return self.__class__.__output_name__ or _class_name_to_snake(type(self).__name__)

    def __init__(self, dataset: Dataset, *, strict_parsing: bool = False) -> None:
        self._dataset = dataset
        self._strict_parsing = strict_parsing

    @property
    def dataset(self) -> Dataset:
        return self._dataset

    @cached_property
    def provenance(self) -> PackagingProvenance:
        """Provenance snapshot for this processor's dataset.

        Cached so that :meth:`compute` and version-check code in subclasses
        share a single :class:`~aind_behavior_vr_foraging_packaging._provenance.PackagingProvenance`
        instance rather than rebuilding it on every call.
        """

        return PackagingProvenance.build(self._dataset)

    @abc.abstractmethod
    def _compute(self) -> pd.DataFrame:
        """Compute this processor's output as a DataFrame.

        Subclasses implement this method. Callers should use :meth:`compute`,
        which wraps ``_compute`` and stamps provenance metadata into ``df.attrs``.
        """
        raise NotImplementedError

    def compute(self) -> pd.DataFrame:
        """Return the processor's output DataFrame with provenance metadata in attrs.

        Calls :meth:`_compute`, then stamps ``df.attrs`` with the session-level
        provenance keys from :class:`~aind_behavior_vr_foraging_packaging._provenance.PackagingProvenance`
        plus a processor-specific ``processor`` key (this class's name).

        Attrs already set by ``_compute`` (e.g. ``sampling_rate_hz`` from
        :class:`SniffingProcessor`) are preserved via ``setdefault``.
        """
        df = self._compute()
        for k, v in self.provenance.model_dump().items():
            df.attrs.setdefault(k, v)
        df.attrs.setdefault("processor", type(self).__name__)
        return df

    def nwbize(self, nwb_file: ty.Any) -> ty.Any:
        """Write this processor's output to *nwb_file* and return it.

        Default implementation is a no-op. Override in subclasses that have
        an NWB representation. May call ``compute()`` internally; the two
        methods are intentionally independent (no shared state).

        That independence costs a second full ``_compute()`` per session when
        both outputs are written (``--write-nwb``). Processors for which that
        is expensive decorate ``_compute`` with
        :func:`~aind_behavior_vr_foraging_packaging._base.cached_frame`, which
        removes the recomputation while preserving the no-shared-state
        guarantee — every call still hands back its own copy.
        """
        return nwb_file

    def with_strict_parsing(self, strict_parsing: bool = True) -> ty.Self:
        self._strict_parsing = strict_parsing
        return self

    @property
    def strict_parsing(self) -> bool:
        """Whether *known* data anomalies raise instead of being logged and worked around.

        The flag covers only anomalies a processor explicitly checks for and can name,
        and only where a degraded-but-meaningful output exists. The convention is::

            if <specific condition detected>:
                msg = "<what was violated>"
                if self.strict_parsing:
                    raise DatasetProcessorError(msg)
                logger.warning("%s; <what is used instead>.", msg)

        It does **not** gate general exceptions. Never write
        ``except Exception: ... if self.strict_parsing: raise`` — with the flag off (the
        default) that swallows real bugs, API drift and corrupt files as though they were
        data quirks, dropping the processor's output while the run still reports success.
        Catch only the exception types that signal an *expected* condition, narrowly —
        e.g. ``except (KeyError, FileNotFoundError)`` for a stream a given schema version
        does not declare — and let everything else propagate.

        Failures that leave nothing meaningful to emit (e.g. absent treadmill calibration,
        without which position cannot be computed at all) should raise unconditionally
        rather than consult this flag: there is no degraded output to fall back to.

        Isolating one failure from the rest of a run is the caller's job, not the flag's.
        :func:`~aind_behavior_vr_foraging_packaging.pipeline.batch.process_sessions`
        catches whatever a processor raises, so a single bad session or processor never
        aborts a batch.
        """
        return self._strict_parsing

output_name property

Canonical name used as the parquet filename stem.

Returns __output_name__ if defined on the class, otherwise a snake_case of the class name (e.g. LicksProcessorlicks_processor).

provenance cached property

Provenance snapshot for this processor's dataset.

Cached so that :meth:compute and version-check code in subclasses share a single :class:~aind_behavior_vr_foraging_packaging._provenance.PackagingProvenance instance rather than rebuilding it on every call.

strict_parsing property

Whether known data anomalies raise instead of being logged and worked around.

The flag covers only anomalies a processor explicitly checks for and can name, and only where a degraded-but-meaningful output exists. The convention is::

if <specific condition detected>:
    msg = "<what was violated>"
    if self.strict_parsing:
        raise DatasetProcessorError(msg)
    logger.warning("%s; <what is used instead>.", msg)

It does not gate general exceptions. Never write except Exception: ... if self.strict_parsing: raise — with the flag off (the default) that swallows real bugs, API drift and corrupt files as though they were data quirks, dropping the processor's output while the run still reports success. Catch only the exception types that signal an expected condition, narrowly — e.g. except (KeyError, FileNotFoundError) for a stream a given schema version does not declare — and let everything else propagate.

Failures that leave nothing meaningful to emit (e.g. absent treadmill calibration, without which position cannot be computed at all) should raise unconditionally rather than consult this flag: there is no degraded output to fall back to.

Isolating one failure from the rest of a run is the caller's job, not the flag's. :func:~aind_behavior_vr_foraging_packaging.pipeline.batch.process_sessions catches whatever a processor raises, so a single bad session or processor never aborts a batch.

compute()

Return the processor's output DataFrame with provenance metadata in attrs.

Calls :meth:_compute, then stamps df.attrs with the session-level provenance keys from :class:~aind_behavior_vr_foraging_packaging._provenance.PackagingProvenance plus a processor-specific processor key (this class's name).

Attrs already set by _compute (e.g. sampling_rate_hz from :class:SniffingProcessor) are preserved via setdefault.

Source code in src/aind_behavior_vr_foraging_packaging/_base.py
def compute(self) -> pd.DataFrame:
    """Return the processor's output DataFrame with provenance metadata in attrs.

    Calls :meth:`_compute`, then stamps ``df.attrs`` with the session-level
    provenance keys from :class:`~aind_behavior_vr_foraging_packaging._provenance.PackagingProvenance`
    plus a processor-specific ``processor`` key (this class's name).

    Attrs already set by ``_compute`` (e.g. ``sampling_rate_hz`` from
    :class:`SniffingProcessor`) are preserved via ``setdefault``.
    """
    df = self._compute()
    for k, v in self.provenance.model_dump().items():
        df.attrs.setdefault(k, v)
    df.attrs.setdefault("processor", type(self).__name__)
    return df

nwbize(nwb_file)

Write this processor's output to nwb_file and return it.

Default implementation is a no-op. Override in subclasses that have an NWB representation. May call compute() internally; the two methods are intentionally independent (no shared state).

That independence costs a second full _compute() per session when both outputs are written (--write-nwb). Processors for which that is expensive decorate _compute with :func:~aind_behavior_vr_foraging_packaging._base.cached_frame, which removes the recomputation while preserving the no-shared-state guarantee — every call still hands back its own copy.

Source code in src/aind_behavior_vr_foraging_packaging/_base.py
def nwbize(self, nwb_file: ty.Any) -> ty.Any:
    """Write this processor's output to *nwb_file* and return it.

    Default implementation is a no-op. Override in subclasses that have
    an NWB representation. May call ``compute()`` internally; the two
    methods are intentionally independent (no shared state).

    That independence costs a second full ``_compute()`` per session when
    both outputs are written (``--write-nwb``). Processors for which that
    is expensive decorate ``_compute`` with
    :func:`~aind_behavior_vr_foraging_packaging._base.cached_frame`, which
    removes the recomputation while preserving the no-shared-state
    guarantee — every call still hands back its own copy.
    """
    return nwb_file

PackagingProvenance

PackagingProvenance

Bases: BaseModel

Immutable provenance snapshot for one packaging run.

All version strings are validated as semver-compatible on construction, so any call site is guaranteed a well-formed object.

Attributes

packaging_version: Version of this package (aind-behavior-vr-foraging-packaging). data_contract_version: Version of aind-behavior-vr-foraging (the behavioural schema library). dataset_version: Version recorded in the session's tasklogic_input.json.

Source code in src/aind_behavior_vr_foraging_packaging/_provenance.py
class PackagingProvenance(BaseModel):
    """Immutable provenance snapshot for one packaging run.

    All version strings are validated as semver-compatible on construction, so
    any call site is guaranteed a well-formed object.

    Attributes
    ----------
    packaging_version:
        Version of this package (``aind-behavior-vr-foraging-packaging``).
    data_contract_version:
        Version of ``aind-behavior-vr-foraging`` (the behavioural schema library).
    dataset_version:
        Version recorded in the session's ``tasklogic_input.json``.
    """

    model_config = ConfigDict(frozen=True)

    packaging_version: str
    data_contract_version: str
    dataset_version: str

    @property
    def dataset_semver(self) -> semver.Version:
        """Dataset version as a parsed :class:`semver.Version`."""
        return semver.Version.parse(self.dataset_version)

    @property
    def data_contract_semver(self) -> semver.Version:
        """Data-contract (parser) version as a parsed :class:`semver.Version`."""
        return semver.Version.parse(self.data_contract_version)

    @classmethod
    def build(cls, dataset: Dataset) -> "PackagingProvenance":
        """Construct a :class:`PackagingProvenance` from a live dataset."""
        return cls(
            packaging_version=importlib.metadata.version(_PACKAGING_PKG),
            data_contract_version=aind_behavior_vr_foraging.__semver__,
            dataset_version=str(dataset.version),
        )

dataset_semver property

Dataset version as a parsed :class:semver.Version.

data_contract_semver property

Data-contract (parser) version as a parsed :class:semver.Version.

build(dataset) classmethod

Construct a :class:PackagingProvenance from a live dataset.

Source code in src/aind_behavior_vr_foraging_packaging/_provenance.py
@classmethod
def build(cls, dataset: Dataset) -> "PackagingProvenance":
    """Construct a :class:`PackagingProvenance` from a live dataset."""
    return cls(
        packaging_version=importlib.metadata.version(_PACKAGING_PKG),
        data_contract_version=aind_behavior_vr_foraging.__semver__,
        dataset_version=str(dataset.version),
    )

SiteTableProcessor

SiteTableProcessor

Bases: AbstractProcessor

Source code in src/aind_behavior_vr_foraging_packaging/processing/_site_table.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
class SiteTableProcessor(AbstractProcessor):
    __output_name__ = "sites"

    def __init__(self, dataset: contraqctor.contract.Dataset, *, strict_parsing: bool = False) -> None:
        super().__init__(dataset, strict_parsing=strict_parsing)

        if self.provenance.dataset_semver != self.provenance.data_contract_semver:
            logger.warning(
                "Dataset version %s does not match parser version %s",
                self.provenance.dataset_semver,
                self.provenance.data_contract_semver,
            )
        self.rig_configuration = self._ensure_json_not_pydantic(
            self.dataset["Behavior"]["InputSchemas"]["Rig"].load().data
        )

    @staticmethod
    def _ensure_json_not_pydantic(d: t.Any) -> dict:
        if isinstance(d, BaseModel):
            return d.model_dump()
        return d

    @staticmethod
    def _parse_speaker_choice_feedback(dataset: contraqctor.contract.Dataset) -> pd.DataFrame:
        speaker_choice = dataset.at("Behavior").at("HarpBehavior").load().at("PwmStart").load().data
        speaker_choice = speaker_choice[(speaker_choice["MessageType"] == "WRITE") & (speaker_choice["PwmDO2"])]
        return speaker_choice

    @staticmethod
    def _parse_odor_onset(dataset: contraqctor.contract.Dataset) -> pd.Series:
        odor_onset = dataset.at("Behavior").at("HarpOlfactometer").load().at("EndValveState").load().data
        odor_onset = odor_onset[odor_onset["MessageType"] == "WRITE"]["EndValve0"]
        odor_onset = odor_onset[(odor_onset) & (~odor_onset.shift(1, fill_value=False))]
        return odor_onset

    @staticmethod
    def _parse_continuous_patch_state(dataset: contraqctor.contract.Dataset) -> pd.DataFrame:
        patches_state = dataset.at("Behavior").at("SoftwareEvents").at("PatchState").load().data
        expanded = pd.json_normalize(patches_state["data"])
        expanded.index = patches_state.index
        patches_state = patches_state.join(expanded)
        return patches_state

    def _parse_patch_state_at_reward(self, dataset: contraqctor.contract.Dataset) -> pd.DataFrame:
        if self.provenance.dataset_semver < semver.Version(major=0, minor=6, patch=0):
            raise DatasetProcessorError("PatchStateAtReward is only available in dataset version 0.6.0 and above")
        # TODO this is likely something we want to overload for 0.5.x to work.
        patches_state_at_reward = dataset.at("Behavior").at("SoftwareEvents").at("PatchStateAtReward").load().data
        expanded = pd.json_normalize(patches_state_at_reward["data"])
        expanded.index = patches_state_at_reward.index
        patches_state_at_reward = patches_state_at_reward.join(expanded)
        return patches_state_at_reward

    @staticmethod
    def _parse_wait_reward_outcome(dataset: contraqctor.contract.Dataset) -> pd.Series:
        try:
            return dataset.at("Behavior").at("SoftwareEvents").at("WaitRewardOutcome").load().data
        except FileNotFoundError:
            return pd.Series(dtype=bool)

    @staticmethod
    def _as_dict(d: contraqctor.contract.DataStream | PydanticModel | BaseModel | dict) -> dict:
        if isinstance(d, (PydanticModel, contraqctor.contract.DataStream)):
            d = t.cast(BaseModel | dict, d.data)
        if isinstance(d, dict):
            return d
        if isinstance(d, BaseModel):
            return d.model_dump()
        else:
            raise TypeError(f"Cannot convert type {type(d)} to dict")

    @staticmethod
    def _parse_friction(dataset: contraqctor.contract.Dataset) -> pd.Series:
        d = dataset.at("Behavior").at("HarpTreadmill").at("BrakeCurrentSetPoint").load().data
        return d.loc[d["MessageType"] == "WRITE", "BrakeCurrentSetPoint"]

    @staticmethod
    def _parse_is_stopped(dataset: contraqctor.contract.Dataset) -> pd.DataFrame | None:
        return dataset.at("Behavior").at("OperationControl").at("IsStopped").load().data

    def _parse_velocity(self, dataset: contraqctor.contract.Dataset) -> pd.Series | None:
        rig_config = self._ensure_json_not_pydantic(self.rig_configuration)
        return PositionAndVelocityProcessor.compute_position_and_velocity_from_treadmill(dataset, rig_config)[
            "velocity"
        ]

    def _get_olfactometer_channel_count(self, dataset: contraqctor.contract.Dataset) -> int:
        extra_olfs = getattr(self.rig_configuration, "harp_olfactometer_extension", None)
        n_extra_channels = 4 * len(extra_olfs) if extra_olfs is not None else 0
        return (
            3 + n_extra_channels
        )  # The channel 3 is always used as carrier, therefore only 3 odor channels are available.

    def _process_odor_concentration(self, odor_specification: BaseModel | dict | None, n_channels: int) -> list[float]:

        concentration = [0.0] * n_channels
        if odor_specification is None:
            return concentration
        odor_specification = self._ensure_json_not_pydantic(odor_specification)
        return TypeAdapter(OdorMixture).validate_python(odor_specification)

    @staticmethod
    def _load_blocks(dataset: contraqctor.contract.Dataset) -> pd.DataFrame:
        blocks = t.cast(pd.DataFrame, dataset.at("Behavior").at("SoftwareEvents").at("Block").load().data)
        blocks["block_count"] = range(len(blocks))
        return blocks

    def _select_patch_state_at_reward(
        self, candidates: pd.DataFrame, *, choice_time: float, site_start: float, site_stop: float
    ) -> pd.DataFrame:
        """Narrow the in-site patch-reward states down to the one belonging to this site.

        From dataset 0.6.0 onwards the experimenter's manual rewards are logged separately, as
        ``ForceGiveReward``, so only the site's own earned reward can land in the interval and
        more than one row means the parse is wrong. Overridden by
        :class:`~._legacy_site_table.LegacySiteTableProcessor`, where manual and earned rewards
        share a single logging path and cannot be told apart from the record alone.
        """
        assert len(candidates) <= 1, (
            f"Site interval [{site_start}, {site_stop}) booked {len(candidates)} rewards to this patch, "
            "expected at most one. A site can only be rewarded once, and from dataset 0.6.0 the "
            "experimenter's manual water is logged separately as ForceGiveReward, so it cannot account "
            f"for the extras. Reward times: {list(candidates.index)}"
        )
        return candidates

    def _select_reward_onset_time(
        self,
        site_water_delivery: pd.Series,
        reward_metadata_sliced: pd.DataFrame,
        *,
        site_start: float,
        choice_time: float,
    ) -> float:
        """Pick the valve opening that delivered this site's earned reward.

        ``site_water_delivery`` is guaranteed non-empty by the caller. See
        :meth:`_select_patch_state_at_reward` for why the legacy processor overrides this.
        """
        if len(reward_metadata_sliced) > 1:
            closest_index = site_water_delivery.index.get_indexer(pd.Index([site_start]), method="nearest")[0]
            return t.cast(float, site_water_delivery.index[closest_index])
        return t.cast(float, site_water_delivery.index[0])

    def _select_choice_feedback(
        self,
        candidates: pd.DataFrame,
        *,
        site_index: int,
        t_start: float,
        t_end: float,
    ) -> pd.DataFrame:
        """Validate and return the choice-feedback events for a single site interval.

        The base implementation enforces that at most one choice-cue event falls inside
        the interval — a violation indicates a parse or hardware problem and raises.
        Override in subclasses (e.g. :class:`~._legacy_site_table.LegacySiteTableProcessor`)
        to handle known hardware quirks gracefully.
        """
        assert len(candidates) <= 1, (
            f"Multiple speaker choices in site interval [{t_start}, {t_end}); "
            f"site index {site_index}, timestamps: {candidates.index.tolist()}"
        )
        return candidates

    def process_to_sites(self) -> list[Site]:
        """
        Processes sites, patches, and blocks from the dataset and merges them.
        Returns a DataFrame with merged information.
        """
        dataset = self.dataset
        odor_sites = t.cast(pd.DataFrame, dataset.at("Behavior").at("SoftwareEvents").at("ActiveSite").load().data)
        patches = t.cast(pd.DataFrame, dataset.at("Behavior").at("SoftwareEvents").at("ActivePatch").load().data)
        patches["patch_count"] = range(len(patches))
        blocks = self._load_blocks(dataset)

        # Merge nearest patch (backward in time)
        merged = pd.merge_asof(
            odor_sites.sort_index(),
            patches[["data", "patch_count"]].rename(columns={"data": "patch_data"}).sort_index(),
            left_index=True,
            right_index=True,
            direction="backward",
            suffixes=("", "_patch"),
        )
        merged["patch_index"] = merged["patch_data"].apply(lambda d: d["state_index"])

        # Merge nearest block (backward in time)
        merged = pd.merge_asof(
            merged.sort_index(),
            blocks[["block_count"]].sort_index(),
            left_index=True,
            right_index=True,
            direction="backward",
        )

        choice_feedback = self._parse_speaker_choice_feedback(dataset)
        water_delivery = parse_water_delivery(dataset)
        reward_metadata = parse_reward_metadata(dataset)

        # In theory we only need the metadata, but since we are aligning
        # temporally later, we must have access to the hardware-aligned times.
        manual_water_delivery = parse_manual_water_delivery(dataset)

        odor_onset = self._parse_odor_onset(dataset)
        patch_state_at_reward = self._parse_patch_state_at_reward(dataset)
        friction = self._parse_friction(dataset)
        olfactometer_channel_count = self._get_olfactometer_channel_count(dataset)
        wait_reward_outcome = self._parse_wait_reward_outcome(dataset)
        is_stopped = self._parse_is_stopped(dataset)
        velocity = self._parse_velocity(dataset)

        # Precompute all site indices
        merged["site_label"] = merged["data"].apply(lambda d: d["label"])
        merged["patch_label"] = merged["patch_data"].apply(lambda d: d["label"])

        # Site-level indices
        merged["_site_index_in_patch"] = merged.groupby("patch_count").cumcount()
        merged["_site_index_in_block"] = merged.groupby("block_count").cumcount()
        merged["_site_index_by_type"] = merged.groupby("site_label").cumcount()
        merged["_site_index_in_patch_by_type"] = merged.groupby(["patch_count", "site_label"]).cumcount()
        merged["_site_index_in_block_by_type"] = merged.groupby(["block_count", "site_label"]).cumcount()

        # Patch-level indices (computed on patches, then mapped back to sites via patch_count)
        patches_with_blocks = pd.merge_asof(
            patches.sort_index(),
            blocks[["block_count"]].sort_index(),
            left_index=True,
            right_index=True,
            direction="backward",
        )
        patches_with_blocks["patch_label"] = patches_with_blocks["data"].apply(lambda d: d["label"])
        patches_with_blocks["_patch_index_in_block"] = patches_with_blocks.groupby("block_count").cumcount()
        patches_with_blocks["_patch_index_by_type"] = patches_with_blocks.groupby("patch_label").cumcount()
        patches_with_blocks["_patch_index_in_block_by_type"] = patches_with_blocks.groupby(
            ["block_count", "patch_label"]
        ).cumcount()
        merged = merged.join(
            patches_with_blocks.set_index("patch_count")[
                ["_patch_index_in_block", "_patch_index_by_type", "_patch_index_in_block_by_type"]
            ],
            on="patch_count",
        )

        # Only mutable states that requires trial-based
        current_friction = 0  # Keeps track of the last known friction. Sites with null friction will not update this.

        sites: list[Site] = []
        # We reject the last site because it may not have completed and would require custom logic to handle
        for i in range(len(merged) - 1):
            # We generally assume that all relevant events happen within the software-event derived timestamp intervals
            # Note this may not always be true depending on system jitter, but it is generally a safe assumption.
            # If you find edge cases where this is not true, submit an issue so we can investigate and improve the parser.

            this_timestamp = t.cast(float, merged.index[i])
            next_timestamp = t.cast(float, merged.index[i + 1])

            this_site = merged.iloc[i]["data"]
            this_patch = merged.iloc[i]["patch_data"]

            site_choice_feedback = slice_by_index(choice_feedback, this_timestamp, next_timestamp)
            site_choice_feedback = self._select_choice_feedback(
                site_choice_feedback, site_index=i, t_start=this_timestamp, t_end=next_timestamp
            )

            choice_time: float = (
                t.cast(float, site_choice_feedback.index[0]) if not site_choice_feedback.empty else np.nan
            )

            site_odor_onset = slice_by_index(odor_onset, this_timestamp, next_timestamp)
            site_force_reward = slice_by_index(manual_water_delivery, this_timestamp, next_timestamp)

            this_friction = slice_by_index(friction, this_timestamp, next_timestamp)
            if not this_friction.empty:
                current_friction = this_friction.values[-1]

            site_patch_state_at_reward = slice_by_index(patch_state_at_reward, this_timestamp, next_timestamp)
            site_patch_state_at_reward = site_patch_state_at_reward[
                site_patch_state_at_reward["PatchId"] == merged.iloc[i]["patch_index"]
            ]
            site_patch_state_at_reward = self._select_patch_state_at_reward(
                site_patch_state_at_reward,
                choice_time=choice_time,
                site_start=this_timestamp,
                site_stop=next_timestamp,
            )

            ##
            row = merged.iloc[i]

            # Compute last_stop_time, last_stop_duration, velocity_at_last_stop
            # We skip calculation if no choice_time was found or IsStopped data is unavailable
            site_stop_time: float = np.nan
            site_stop_duration: float = np.nan
            site_velocity_at_stop: float = np.nan
            if not np.isnan(choice_time) and is_stopped is not None:
                site_is_stopped = slice_by_index(is_stopped, this_timestamp, choice_time, end_inclusive=True)
                stops_before_choice = site_is_stopped[site_is_stopped["IsStopped"]]
                if stops_before_choice.empty:
                    msg = f"Choice occurred at {choice_time} but no IsStopped=True event found in site interval [{this_timestamp}, {next_timestamp})"
                    if self.strict_parsing:
                        raise DatasetProcessorError(msg)
                    else:
                        logger.warning(msg + ". Falling back to global search.")
                        stops_before_choice = is_stopped[is_stopped["IsStopped"] & (is_stopped.index <= choice_time)]
                    if stops_before_choice.empty:
                        raise DatasetProcessorError(
                            f"Choice occurred at {choice_time} but no IsStopped=True event found before choice time"
                        )

                site_stop_time = (
                    t.cast(float, stops_before_choice.index[-1]) if not stops_before_choice.empty else np.nan
                )
                site_stop_duration = choice_time - site_stop_time
                if velocity is not None:
                    closest_ts = get_closest_from_timestamp(np.array([site_stop_time]), velocity, search_mode="closest")
                    site_velocity_at_stop = float(velocity[closest_ts[0]])

            if site_odor_onset.empty and this_site["odor_specification"] is not None:
                # Sometimes the timestamp for the odor onset arrives slightly before the site. We should investigate
                # but for now we just log a warning and use the site onset instead after checking if this is the issue
                odor_onset_before_site = odor_onset[
                    (odor_onset.index < this_timestamp) & (odor_onset.index >= this_timestamp - 0.002)
                ]  # we use a 2ms conservative window
                if odor_onset_before_site.empty:
                    if self.strict_parsing:
                        raise DatasetProcessorError("No odor onset found in site interval")
                    else:
                        logger.warning("No odor onset found in site interval")
                        odor_onset_time = np.nan
                else:
                    logger.warning("Odor onset found slightly (<2ms) before site interval, using site onset instead")
                    odor_onset_time = this_timestamp
            else:
                # we always take the first odor onset in case animal goes in and out
                odor_onset_time = t.cast(float, site_odor_onset.index[0]) if not site_odor_onset.empty else np.nan

            site_water_delivery = slice_by_index(water_delivery, this_timestamp, next_timestamp)
            reward_metadata_sliced = slice_by_index(reward_metadata, this_timestamp, next_timestamp)
            if reward_metadata_sliced.empty or bool(reward_metadata_sliced["data"].fillna(0).eq(0).all()):
                # Note: for None or 0 reward metadata there won't be a hardware water delivery event
                # However, if the experimenter manually triggered a reward around this time, we should not count that
                # as a reward for this site either, so we make an explicit decision to set reward_onset_time to nan
                reward_onset_time = np.nan
            else:
                if len(site_water_delivery) == 0:
                    if self.strict_parsing:
                        raise DatasetProcessorError(
                            "Valid reward metadata found but no water delivery in site interval"
                        )
                    else:
                        logger.error("Valid reward metadata found but no water delivery in site interval")
                        reward_onset_time = np.nan
                else:
                    reward_onset_time = self._select_reward_onset_time(
                        site_water_delivery,
                        reward_metadata_sliced,
                        site_start=this_timestamp,
                        choice_time=choice_time,
                    )

            wait_reward_outcome_sliced = slice_by_index(wait_reward_outcome, this_timestamp, next_timestamp)
            has_waited_reward_delay = (
                wait_reward_outcome_sliced.iloc[0]["data"]["IsSuccessfulWait"]
                if not wait_reward_outcome_sliced.empty
                else None
            )

            site = Site(
                start_time=this_timestamp,
                stop_time=next_timestamp,
                start_position=this_site["start_position"],
                length=this_site["length"],
                site_label=str(this_site["label"]),
                friction=current_friction,
                patch_label=str(this_patch["label"]),
                odor_concentration=self._process_odor_concentration(
                    this_patch["odor_specification"], olfactometer_channel_count
                ),
                patch_index=row["patch_count"],
                patch_index_in_block=row["_patch_index_in_block"],
                patch_index_by_type=row["_patch_index_by_type"],
                patch_index_in_block_by_type=row["_patch_index_in_block_by_type"],
                site_index=i,
                site_index_in_patch=row["_site_index_in_patch"],
                site_index_in_block=row["_site_index_in_block"],
                site_index_by_type=row["_site_index_by_type"],
                site_index_in_patch_by_type=row["_site_index_in_patch_by_type"],
                site_index_in_block_by_type=row["_site_index_in_block_by_type"],
                odor_onset_time=odor_onset_time,
                reward_onset_time=reward_onset_time,
                reward_amount=np.nan
                if site_patch_state_at_reward.empty
                else site_patch_state_at_reward.iloc[0]["Amount"],
                reward_probability=np.nan
                if site_patch_state_at_reward.empty
                else site_patch_state_at_reward.iloc[0]["Probability"],
                reward_available=np.nan
                if site_patch_state_at_reward.empty
                else site_patch_state_at_reward.iloc[0]["Available"],
                has_reward=not np.isnan(reward_onset_time),
                has_forced_rewards=not site_force_reward.empty,
                choice_cue_time=choice_time,
                has_choice=not site_choice_feedback.empty,
                reward_delay_duration=reward_onset_time - choice_time
                if reward_onset_time is not np.nan and choice_time is not None
                else np.nan,
                has_waited_reward_delay=has_waited_reward_delay,
                last_stop_time=None if np.isnan(site_stop_time) else site_stop_time,
                last_stop_duration=None if np.isnan(site_stop_duration) else site_stop_duration,
                velocity_at_last_stop=None if np.isnan(site_velocity_at_stop) else site_velocity_at_stop,
                block_index=row["block_count"],
            )
            sites.append(site)
        return sites

    @cached_frame
    def _compute(self) -> pd.DataFrame:
        """Returns site table as a DataFrame with one row per site."""
        sites = self.process_to_sites()
        return pd.DataFrame([s.model_dump() for s in sites])

    def nwbize(self, nwb_file: t.Any) -> t.Any:
        """Add sites to *nwb_file* from compute() output."""
        df = self.compute()
        for col in df.columns:
            if col in ("start_time", "stop_time"):
                continue
            nwb_file.add_trial_column(name=col, description=col)
        for _, row in df.iterrows():
            trial = {k: (np.nan if v is None else v) for k, v in row.to_dict().items()}
            nwb_file.add_trial(**trial)
        return nwb_file

process_to_sites()

Processes sites, patches, and blocks from the dataset and merges them. Returns a DataFrame with merged information.

Source code in src/aind_behavior_vr_foraging_packaging/processing/_site_table.py
def process_to_sites(self) -> list[Site]:
    """
    Processes sites, patches, and blocks from the dataset and merges them.
    Returns a DataFrame with merged information.
    """
    dataset = self.dataset
    odor_sites = t.cast(pd.DataFrame, dataset.at("Behavior").at("SoftwareEvents").at("ActiveSite").load().data)
    patches = t.cast(pd.DataFrame, dataset.at("Behavior").at("SoftwareEvents").at("ActivePatch").load().data)
    patches["patch_count"] = range(len(patches))
    blocks = self._load_blocks(dataset)

    # Merge nearest patch (backward in time)
    merged = pd.merge_asof(
        odor_sites.sort_index(),
        patches[["data", "patch_count"]].rename(columns={"data": "patch_data"}).sort_index(),
        left_index=True,
        right_index=True,
        direction="backward",
        suffixes=("", "_patch"),
    )
    merged["patch_index"] = merged["patch_data"].apply(lambda d: d["state_index"])

    # Merge nearest block (backward in time)
    merged = pd.merge_asof(
        merged.sort_index(),
        blocks[["block_count"]].sort_index(),
        left_index=True,
        right_index=True,
        direction="backward",
    )

    choice_feedback = self._parse_speaker_choice_feedback(dataset)
    water_delivery = parse_water_delivery(dataset)
    reward_metadata = parse_reward_metadata(dataset)

    # In theory we only need the metadata, but since we are aligning
    # temporally later, we must have access to the hardware-aligned times.
    manual_water_delivery = parse_manual_water_delivery(dataset)

    odor_onset = self._parse_odor_onset(dataset)
    patch_state_at_reward = self._parse_patch_state_at_reward(dataset)
    friction = self._parse_friction(dataset)
    olfactometer_channel_count = self._get_olfactometer_channel_count(dataset)
    wait_reward_outcome = self._parse_wait_reward_outcome(dataset)
    is_stopped = self._parse_is_stopped(dataset)
    velocity = self._parse_velocity(dataset)

    # Precompute all site indices
    merged["site_label"] = merged["data"].apply(lambda d: d["label"])
    merged["patch_label"] = merged["patch_data"].apply(lambda d: d["label"])

    # Site-level indices
    merged["_site_index_in_patch"] = merged.groupby("patch_count").cumcount()
    merged["_site_index_in_block"] = merged.groupby("block_count").cumcount()
    merged["_site_index_by_type"] = merged.groupby("site_label").cumcount()
    merged["_site_index_in_patch_by_type"] = merged.groupby(["patch_count", "site_label"]).cumcount()
    merged["_site_index_in_block_by_type"] = merged.groupby(["block_count", "site_label"]).cumcount()

    # Patch-level indices (computed on patches, then mapped back to sites via patch_count)
    patches_with_blocks = pd.merge_asof(
        patches.sort_index(),
        blocks[["block_count"]].sort_index(),
        left_index=True,
        right_index=True,
        direction="backward",
    )
    patches_with_blocks["patch_label"] = patches_with_blocks["data"].apply(lambda d: d["label"])
    patches_with_blocks["_patch_index_in_block"] = patches_with_blocks.groupby("block_count").cumcount()
    patches_with_blocks["_patch_index_by_type"] = patches_with_blocks.groupby("patch_label").cumcount()
    patches_with_blocks["_patch_index_in_block_by_type"] = patches_with_blocks.groupby(
        ["block_count", "patch_label"]
    ).cumcount()
    merged = merged.join(
        patches_with_blocks.set_index("patch_count")[
            ["_patch_index_in_block", "_patch_index_by_type", "_patch_index_in_block_by_type"]
        ],
        on="patch_count",
    )

    # Only mutable states that requires trial-based
    current_friction = 0  # Keeps track of the last known friction. Sites with null friction will not update this.

    sites: list[Site] = []
    # We reject the last site because it may not have completed and would require custom logic to handle
    for i in range(len(merged) - 1):
        # We generally assume that all relevant events happen within the software-event derived timestamp intervals
        # Note this may not always be true depending on system jitter, but it is generally a safe assumption.
        # If you find edge cases where this is not true, submit an issue so we can investigate and improve the parser.

        this_timestamp = t.cast(float, merged.index[i])
        next_timestamp = t.cast(float, merged.index[i + 1])

        this_site = merged.iloc[i]["data"]
        this_patch = merged.iloc[i]["patch_data"]

        site_choice_feedback = slice_by_index(choice_feedback, this_timestamp, next_timestamp)
        site_choice_feedback = self._select_choice_feedback(
            site_choice_feedback, site_index=i, t_start=this_timestamp, t_end=next_timestamp
        )

        choice_time: float = (
            t.cast(float, site_choice_feedback.index[0]) if not site_choice_feedback.empty else np.nan
        )

        site_odor_onset = slice_by_index(odor_onset, this_timestamp, next_timestamp)
        site_force_reward = slice_by_index(manual_water_delivery, this_timestamp, next_timestamp)

        this_friction = slice_by_index(friction, this_timestamp, next_timestamp)
        if not this_friction.empty:
            current_friction = this_friction.values[-1]

        site_patch_state_at_reward = slice_by_index(patch_state_at_reward, this_timestamp, next_timestamp)
        site_patch_state_at_reward = site_patch_state_at_reward[
            site_patch_state_at_reward["PatchId"] == merged.iloc[i]["patch_index"]
        ]
        site_patch_state_at_reward = self._select_patch_state_at_reward(
            site_patch_state_at_reward,
            choice_time=choice_time,
            site_start=this_timestamp,
            site_stop=next_timestamp,
        )

        ##
        row = merged.iloc[i]

        # Compute last_stop_time, last_stop_duration, velocity_at_last_stop
        # We skip calculation if no choice_time was found or IsStopped data is unavailable
        site_stop_time: float = np.nan
        site_stop_duration: float = np.nan
        site_velocity_at_stop: float = np.nan
        if not np.isnan(choice_time) and is_stopped is not None:
            site_is_stopped = slice_by_index(is_stopped, this_timestamp, choice_time, end_inclusive=True)
            stops_before_choice = site_is_stopped[site_is_stopped["IsStopped"]]
            if stops_before_choice.empty:
                msg = f"Choice occurred at {choice_time} but no IsStopped=True event found in site interval [{this_timestamp}, {next_timestamp})"
                if self.strict_parsing:
                    raise DatasetProcessorError(msg)
                else:
                    logger.warning(msg + ". Falling back to global search.")
                    stops_before_choice = is_stopped[is_stopped["IsStopped"] & (is_stopped.index <= choice_time)]
                if stops_before_choice.empty:
                    raise DatasetProcessorError(
                        f"Choice occurred at {choice_time} but no IsStopped=True event found before choice time"
                    )

            site_stop_time = (
                t.cast(float, stops_before_choice.index[-1]) if not stops_before_choice.empty else np.nan
            )
            site_stop_duration = choice_time - site_stop_time
            if velocity is not None:
                closest_ts = get_closest_from_timestamp(np.array([site_stop_time]), velocity, search_mode="closest")
                site_velocity_at_stop = float(velocity[closest_ts[0]])

        if site_odor_onset.empty and this_site["odor_specification"] is not None:
            # Sometimes the timestamp for the odor onset arrives slightly before the site. We should investigate
            # but for now we just log a warning and use the site onset instead after checking if this is the issue
            odor_onset_before_site = odor_onset[
                (odor_onset.index < this_timestamp) & (odor_onset.index >= this_timestamp - 0.002)
            ]  # we use a 2ms conservative window
            if odor_onset_before_site.empty:
                if self.strict_parsing:
                    raise DatasetProcessorError("No odor onset found in site interval")
                else:
                    logger.warning("No odor onset found in site interval")
                    odor_onset_time = np.nan
            else:
                logger.warning("Odor onset found slightly (<2ms) before site interval, using site onset instead")
                odor_onset_time = this_timestamp
        else:
            # we always take the first odor onset in case animal goes in and out
            odor_onset_time = t.cast(float, site_odor_onset.index[0]) if not site_odor_onset.empty else np.nan

        site_water_delivery = slice_by_index(water_delivery, this_timestamp, next_timestamp)
        reward_metadata_sliced = slice_by_index(reward_metadata, this_timestamp, next_timestamp)
        if reward_metadata_sliced.empty or bool(reward_metadata_sliced["data"].fillna(0).eq(0).all()):
            # Note: for None or 0 reward metadata there won't be a hardware water delivery event
            # However, if the experimenter manually triggered a reward around this time, we should not count that
            # as a reward for this site either, so we make an explicit decision to set reward_onset_time to nan
            reward_onset_time = np.nan
        else:
            if len(site_water_delivery) == 0:
                if self.strict_parsing:
                    raise DatasetProcessorError(
                        "Valid reward metadata found but no water delivery in site interval"
                    )
                else:
                    logger.error("Valid reward metadata found but no water delivery in site interval")
                    reward_onset_time = np.nan
            else:
                reward_onset_time = self._select_reward_onset_time(
                    site_water_delivery,
                    reward_metadata_sliced,
                    site_start=this_timestamp,
                    choice_time=choice_time,
                )

        wait_reward_outcome_sliced = slice_by_index(wait_reward_outcome, this_timestamp, next_timestamp)
        has_waited_reward_delay = (
            wait_reward_outcome_sliced.iloc[0]["data"]["IsSuccessfulWait"]
            if not wait_reward_outcome_sliced.empty
            else None
        )

        site = Site(
            start_time=this_timestamp,
            stop_time=next_timestamp,
            start_position=this_site["start_position"],
            length=this_site["length"],
            site_label=str(this_site["label"]),
            friction=current_friction,
            patch_label=str(this_patch["label"]),
            odor_concentration=self._process_odor_concentration(
                this_patch["odor_specification"], olfactometer_channel_count
            ),
            patch_index=row["patch_count"],
            patch_index_in_block=row["_patch_index_in_block"],
            patch_index_by_type=row["_patch_index_by_type"],
            patch_index_in_block_by_type=row["_patch_index_in_block_by_type"],
            site_index=i,
            site_index_in_patch=row["_site_index_in_patch"],
            site_index_in_block=row["_site_index_in_block"],
            site_index_by_type=row["_site_index_by_type"],
            site_index_in_patch_by_type=row["_site_index_in_patch_by_type"],
            site_index_in_block_by_type=row["_site_index_in_block_by_type"],
            odor_onset_time=odor_onset_time,
            reward_onset_time=reward_onset_time,
            reward_amount=np.nan
            if site_patch_state_at_reward.empty
            else site_patch_state_at_reward.iloc[0]["Amount"],
            reward_probability=np.nan
            if site_patch_state_at_reward.empty
            else site_patch_state_at_reward.iloc[0]["Probability"],
            reward_available=np.nan
            if site_patch_state_at_reward.empty
            else site_patch_state_at_reward.iloc[0]["Available"],
            has_reward=not np.isnan(reward_onset_time),
            has_forced_rewards=not site_force_reward.empty,
            choice_cue_time=choice_time,
            has_choice=not site_choice_feedback.empty,
            reward_delay_duration=reward_onset_time - choice_time
            if reward_onset_time is not np.nan and choice_time is not None
            else np.nan,
            has_waited_reward_delay=has_waited_reward_delay,
            last_stop_time=None if np.isnan(site_stop_time) else site_stop_time,
            last_stop_duration=None if np.isnan(site_stop_duration) else site_stop_duration,
            velocity_at_last_stop=None if np.isnan(site_velocity_at_stop) else site_velocity_at_stop,
            block_index=row["block_count"],
        )
        sites.append(site)
    return sites

nwbize(nwb_file)

Add sites to nwb_file from compute() output.

Source code in src/aind_behavior_vr_foraging_packaging/processing/_site_table.py
def nwbize(self, nwb_file: t.Any) -> t.Any:
    """Add sites to *nwb_file* from compute() output."""
    df = self.compute()
    for col in df.columns:
        if col in ("start_time", "stop_time"):
            continue
        nwb_file.add_trial_column(name=col, description=col)
    for _, row in df.iterrows():
        trial = {k: (np.nan if v is None else v) for k, v in row.to_dict().items()}
        nwb_file.add_trial(**trial)
    return nwb_file

PositionAndVelocityProcessor

PositionAndVelocityProcessor

Bases: AbstractProcessor

Source code in src/aind_behavior_vr_foraging_packaging/processing/_position_and_velocity.py
class PositionAndVelocityProcessor(AbstractProcessor):
    __output_name__ = "position_velocity"

    def __init__(self, dataset: contraqctor.contract.Dataset, *, sampling_rate_hz: float | None = None, **kwargs):
        super().__init__(dataset=dataset, **kwargs)
        self._sampling_rate_hz = sampling_rate_hz

    @cached_frame
    def _compute(self) -> pd.DataFrame:
        """Returns DataFrame with 'position' (cm) and 'velocity' (cm/s) indexed by harp time."""
        return self.compute_position_and_velocity(self.dataset, downsample_to_hz=self._sampling_rate_hz)

    def nwbize(self, nwb_file: ty.Any) -> ty.Any:
        """Add a single ``position_velocity`` DynamicTable to *nwb_file*.

        The table has three columns: ``timestamp`` (harp time, seconds), ``position`` (cm) and
        ``velocity`` (cm/s).
        """
        from pynwb.base import ProcessingModule
        from pynwb.core import DynamicTable

        module = nwb_file.processing.get("behavior")
        if module is None:
            module = ProcessingModule(name="behavior", description="Processing module for behavior data")
            nwb_file.add_processing_module(module)

        df = self.compute()
        table = DynamicTable.from_dataframe(
            name=self.__output_name__,
            table_description="Treadmill-derived position (cm) and velocity (cm/s) by harp timestamp (s)",
            df=pd.DataFrame(
                {
                    "timestamp": df.index.values,
                    "position": df["position"].values,
                    "velocity": df["velocity"].values,
                }
            ),
        )
        module.add(table)
        return nwb_file

    def compute_position_and_velocity(
        self, dataset: contraqctor.contract.Dataset, *, downsample_to_hz: float | None
    ) -> pd.DataFrame:
        """Computes position and velocity from treadmill encoder data"""
        dataset.at("Behavior").at("InputSchemas").load_all()

        rig_settings = dataset.at("Behavior").at("InputSchemas").at("Rig").load().data
        rig_settings = rig_settings.model_dump() if isinstance(rig_settings, BaseModel) else rig_settings

        try:
            df = self.compute_position_and_velocity_from_treadmill(dataset, rig_settings)
        except KeyError as e:
            e.add_note(
                "Missing calibration data for HarpTreadmill in rig settings. Cannot compute position and velocity."
            )
            raise

        if downsample_to_hz is None:
            return df

        df.sort_index(inplace=True)
        df.index = pd.to_timedelta(df.index, unit="s")
        dt = pd.to_timedelta(1.0 / downsample_to_hz, unit="s")
        df = df.resample(dt, label="right", closed="right").mean()
        df.dropna(inplace=True)
        df.index = df.index.total_seconds()  # Convert back to harp time!

        return df

    @staticmethod
    def compute_position_and_velocity_from_treadmill(
        dataset: contraqctor.contract.Dataset,
        rig_config: dict,
    ) -> pd.DataFrame:
        """Compute position and velocity from treadmill encoder data.

        Args:
            dataset: A contraqctor Dataset providing access to HarpTreadmill data.
            rig_config: Rig configuration dict. Must contain a
                ``harp_treadmill.calibration`` entry with ``wheel_diameter``
                (cm), ``pulses_per_revolution``, and ``invert_direction``.

        Returns:
            DataFrame with ``position`` (cm) and ``velocity`` (cm/s) columns,
            indexed by harp timestamp (seconds).
        """
        calibration = rig_config.get("harp_treadmill", {}).get("calibration")
        if calibration is None:
            raise KeyError("Missing harp_treadmill.calibration in rig_config.")
        calibration = calibration.get("output", calibration)
        wheel_diameter: float = calibration["wheel_diameter"]
        pulses_per_revolution: float = calibration["pulses_per_revolution"]
        invert_direction: bool = calibration["invert_direction"]
        converting_factor = wheel_diameter * np.pi / pulses_per_revolution * (-1 if invert_direction else 1)
        treadmill_data = ty.cast(
            pd.DataFrame,
            dataset.at("Behavior").at("HarpTreadmill").load().at("SensorData").load().data,
        )
        encoder = treadmill_data.query("MessageType == 'EVENT'")["Encoder"].copy()
        position = (encoder - encoder.iloc[0]) * converting_factor
        displacement = position.diff().fillna(0)
        velocity = displacement / position.index.to_series().diff().fillna(1)
        return pd.DataFrame({"position": position, "velocity": velocity})

nwbize(nwb_file)

Add a single position_velocity DynamicTable to nwb_file.

The table has three columns: timestamp (harp time, seconds), position (cm) and velocity (cm/s).

Source code in src/aind_behavior_vr_foraging_packaging/processing/_position_and_velocity.py
def nwbize(self, nwb_file: ty.Any) -> ty.Any:
    """Add a single ``position_velocity`` DynamicTable to *nwb_file*.

    The table has three columns: ``timestamp`` (harp time, seconds), ``position`` (cm) and
    ``velocity`` (cm/s).
    """
    from pynwb.base import ProcessingModule
    from pynwb.core import DynamicTable

    module = nwb_file.processing.get("behavior")
    if module is None:
        module = ProcessingModule(name="behavior", description="Processing module for behavior data")
        nwb_file.add_processing_module(module)

    df = self.compute()
    table = DynamicTable.from_dataframe(
        name=self.__output_name__,
        table_description="Treadmill-derived position (cm) and velocity (cm/s) by harp timestamp (s)",
        df=pd.DataFrame(
            {
                "timestamp": df.index.values,
                "position": df["position"].values,
                "velocity": df["velocity"].values,
            }
        ),
    )
    module.add(table)
    return nwb_file

compute_position_and_velocity(dataset, *, downsample_to_hz)

Computes position and velocity from treadmill encoder data

Source code in src/aind_behavior_vr_foraging_packaging/processing/_position_and_velocity.py
def compute_position_and_velocity(
    self, dataset: contraqctor.contract.Dataset, *, downsample_to_hz: float | None
) -> pd.DataFrame:
    """Computes position and velocity from treadmill encoder data"""
    dataset.at("Behavior").at("InputSchemas").load_all()

    rig_settings = dataset.at("Behavior").at("InputSchemas").at("Rig").load().data
    rig_settings = rig_settings.model_dump() if isinstance(rig_settings, BaseModel) else rig_settings

    try:
        df = self.compute_position_and_velocity_from_treadmill(dataset, rig_settings)
    except KeyError as e:
        e.add_note(
            "Missing calibration data for HarpTreadmill in rig settings. Cannot compute position and velocity."
        )
        raise

    if downsample_to_hz is None:
        return df

    df.sort_index(inplace=True)
    df.index = pd.to_timedelta(df.index, unit="s")
    dt = pd.to_timedelta(1.0 / downsample_to_hz, unit="s")
    df = df.resample(dt, label="right", closed="right").mean()
    df.dropna(inplace=True)
    df.index = df.index.total_seconds()  # Convert back to harp time!

    return df

compute_position_and_velocity_from_treadmill(dataset, rig_config) staticmethod

Compute position and velocity from treadmill encoder data.

Parameters:

Name Type Description Default
dataset Dataset

A contraqctor Dataset providing access to HarpTreadmill data.

required
rig_config dict

Rig configuration dict. Must contain a harp_treadmill.calibration entry with wheel_diameter (cm), pulses_per_revolution, and invert_direction.

required

Returns:

Type Description
DataFrame

DataFrame with position (cm) and velocity (cm/s) columns,

DataFrame

indexed by harp timestamp (seconds).

Source code in src/aind_behavior_vr_foraging_packaging/processing/_position_and_velocity.py
@staticmethod
def compute_position_and_velocity_from_treadmill(
    dataset: contraqctor.contract.Dataset,
    rig_config: dict,
) -> pd.DataFrame:
    """Compute position and velocity from treadmill encoder data.

    Args:
        dataset: A contraqctor Dataset providing access to HarpTreadmill data.
        rig_config: Rig configuration dict. Must contain a
            ``harp_treadmill.calibration`` entry with ``wheel_diameter``
            (cm), ``pulses_per_revolution``, and ``invert_direction``.

    Returns:
        DataFrame with ``position`` (cm) and ``velocity`` (cm/s) columns,
        indexed by harp timestamp (seconds).
    """
    calibration = rig_config.get("harp_treadmill", {}).get("calibration")
    if calibration is None:
        raise KeyError("Missing harp_treadmill.calibration in rig_config.")
    calibration = calibration.get("output", calibration)
    wheel_diameter: float = calibration["wheel_diameter"]
    pulses_per_revolution: float = calibration["pulses_per_revolution"]
    invert_direction: bool = calibration["invert_direction"]
    converting_factor = wheel_diameter * np.pi / pulses_per_revolution * (-1 if invert_direction else 1)
    treadmill_data = ty.cast(
        pd.DataFrame,
        dataset.at("Behavior").at("HarpTreadmill").load().at("SensorData").load().data,
    )
    encoder = treadmill_data.query("MessageType == 'EVENT'")["Encoder"].copy()
    position = (encoder - encoder.iloc[0]) * converting_factor
    displacement = position.diff().fillna(0)
    velocity = displacement / position.index.to_series().diff().fillna(1)
    return pd.DataFrame({"position": position, "velocity": velocity})

LicksProcessor

LicksProcessor

Bases: AbstractProcessor

Source code in src/aind_behavior_vr_foraging_packaging/processing/_licks.py
class LicksProcessor(AbstractProcessor):
    __output_name__ = "licks"

    def __init__(self, dataset: contraqctor.contract.Dataset, *, refractory_period_s: float | None = 0.01, **kwargs):
        super().__init__(dataset=dataset, **kwargs)
        self._refractory_period_s = refractory_period_s

    @cached_frame
    def _compute(self) -> pd.DataFrame:
        """Returns DataFrame with 'is_lick_onset' (bool) indexed by harp time."""
        licks = self._compute_lick_state(self.dataset)
        return licks.rename("is_lick_onset").to_frame()

    def nwbize(self, nwb_file: ty.Any) -> ty.Any:
        """Add lick TimeSeries to *nwb_file*."""
        from pynwb import TimeSeries
        from pynwb.base import ProcessingModule

        module = nwb_file.processing.get("behavior")
        if module is None:
            module = ProcessingModule(name="behavior", description="Processing module for behavior data")
            nwb_file.add_processing_module(module)

        df = self.compute()
        module.add(
            TimeSeries(
                name="licks",
                data=df["is_lick_onset"].values,
                unit="n/a",
                timestamps=df.index.values,
                description="Lick onset/offset transitions (True = lick onset, False = lick offset).",
            )
        )
        return nwb_file

    def _compute_lick_state(self, dataset: contraqctor.contract.Dataset) -> pd.Series:
        """Load the lickometer state and compute the lick onset/offset series.

        Args:
            dataset: A contraqctor Dataset providing access to the
                ``HarpLickometer`` device.

        Returns:
            A boolean Series named ``"IsLickOnset"`` indexed by harp timestamp
            (seconds), where ``True`` marks a lick onset and ``False`` a lick
            offset.
        """
        data = ty.cast(
            pd.DataFrame,
            dataset.at("Behavior").at("HarpLickometer").load().at("LickState").load().data,
        )
        data = data[data["MessageType"] == "EVENT"]
        return self._lick_onsets_from_state(data["Channel0"].astype(bool), self._refractory_period_s)

    @staticmethod
    def _lick_onsets_from_state(lick_state: pd.Series, refractory_period_s: float | None) -> pd.Series:
        """Compute the lick onset/offset transition series from a boolean lick state.

        Only distinct state changes are kept, so the resulting boolean series
        alternates between ``True`` (lick onset) and ``False`` (lick offset),
        starting on the first onset, as done in ``contraqctor.qc.harp.lickety_split``.
        Lick onsets whose gap to the preceding onset is below
        ``refractory_period_s`` are treated as spurious double-detections and
        removed together with their paired offset.

        Args:
            lick_state: A boolean Series of the raw lick state (``True`` while a
                lick is detected) indexed by harp timestamp (seconds).
            refractory_period_s: Minimum spacing between onsets; ``None`` or ``0``
                disables refractory filtering.

        Returns:
            A boolean Series named ``"IsLickOnset"`` indexed by harp timestamp
            (seconds), where ``True`` marks a lick onset and ``False`` a lick
            offset.
        """
        # Keep only distinct state transitions: True = lick onset, False = lick offset.
        is_onset = lick_state[lick_state != lick_state.shift()].astype(bool)
        is_onset.name = "IsLickOnset"

        # Start the series on the first lick onset so it begins with an onset.
        onset_positions = np.flatnonzero(is_onset.values)
        if len(onset_positions) == 0:
            return is_onset.iloc[:0]
        is_onset = is_onset.iloc[onset_positions[0] :]

        if not refractory_period_s:
            return is_onset

        flags = is_onset.values
        onset_positions = np.flatnonzero(flags)
        onset_times = is_onset.index.values[onset_positions]
        violating = np.flatnonzero(np.diff(onset_times) < refractory_period_s) + 1
        if len(violating) == 0:
            return is_onset

        keep = np.ones(len(flags), dtype=bool)
        bad_onsets = onset_positions[violating]
        keep[bad_onsets] = False

        paired_offsets = bad_onsets + 1
        paired_offsets = paired_offsets[paired_offsets < len(flags)]
        paired_offsets = paired_offsets[~flags[paired_offsets]]
        keep[paired_offsets] = False

        return is_onset.iloc[keep]

nwbize(nwb_file)

Add lick TimeSeries to nwb_file.

Source code in src/aind_behavior_vr_foraging_packaging/processing/_licks.py
def nwbize(self, nwb_file: ty.Any) -> ty.Any:
    """Add lick TimeSeries to *nwb_file*."""
    from pynwb import TimeSeries
    from pynwb.base import ProcessingModule

    module = nwb_file.processing.get("behavior")
    if module is None:
        module = ProcessingModule(name="behavior", description="Processing module for behavior data")
        nwb_file.add_processing_module(module)

    df = self.compute()
    module.add(
        TimeSeries(
            name="licks",
            data=df["is_lick_onset"].values,
            unit="n/a",
            timestamps=df.index.values,
            description="Lick onset/offset transitions (True = lick onset, False = lick offset).",
        )
    )
    return nwb_file

SniffingProcessor

SniffingProcessor

Bases: AbstractProcessor

Source code in src/aind_behavior_vr_foraging_packaging/processing/_sniffing.py
class SniffingProcessor(AbstractProcessor):
    __output_name__ = "sniffing"

    def __init__(
        self, dataset: contraqctor.contract.Dataset, *, resampling_frequency_hz: float | None = None, **kwargs
    ):
        super().__init__(dataset=dataset, **kwargs)
        self._resampling_frequency_hz = resampling_frequency_hz

    @cached_frame
    def _compute(self) -> pd.DataFrame:
        """Returns DataFrame with 'voltage' (V) indexed by harp time.
        Sampling rate stored in df.attrs['sampling_rate_hz'].
        """
        sniff, fs = self.compute_sniff_signal(self.dataset)
        df = sniff.rename("voltage").to_frame()
        df.attrs["sampling_rate_hz"] = fs
        return df

    def nwbize(self, nwb_file: ty.Any) -> ty.Any:
        """Add sniffing TimeSeries to *nwb_file*."""
        from pynwb import TimeSeries
        from pynwb.base import ProcessingModule

        module = nwb_file.processing.get("behavior")
        if module is None:
            module = ProcessingModule(name="behavior", description="Processing module for behavior data")
            nwb_file.add_processing_module(module)

        df = self.compute()
        fs = float(df.attrs.get("sampling_rate_hz", 0.0))
        module.add(
            TimeSeries(
                name="sniffing",
                data=df["voltage"].values,
                unit="V",
                timestamps=df.index.values,
                description=(
                    "Filtered breathing/sniff signal derived from the sniff detector raw voltage "
                    f"at sampling rate {fs} Hz."
                ),
            )
        )
        return nwb_file

    def compute_sniff_signal(self, dataset: contraqctor.contract.Dataset) -> tuple[pd.Series, float]:
        """Computes the filtered breathing/sniff signal from the sniff detector raw voltage.

        The raw voltage is resampled onto a uniform time grid and then passed
        through a 0.2-20 Hz band-pass filter to isolate the breathing band, as
        done in ``contraqctor.qc.harp.sniff_detector``. The grid spacing is set
        by the processor's ``resampling_frequency_hz`` when provided, otherwise
        it defaults to the median sampling rate of the raw voltage.

        Args:
            dataset: A contraqctor Dataset providing access to the
                ``HarpSniffDetector`` device.

        Returns:
            A tuple of the filtered sniff signal (indexed by harp timestamp in
            seconds) and the sampling frequency (Hz) used for resampling.
        """
        raw = ty.cast(
            pd.DataFrame,
            dataset.at("Behavior").at("HarpSniffDetector").load().at("RawVoltage").load().data,
        )
        raw = raw[raw["MessageType"] == "EVENT"]["RawVoltage"]

        timestamps = np.asarray(raw.index.values, dtype=float)
        signal = np.asarray(raw.values, dtype=float)

        if self._resampling_frequency_hz is not None:
            fs = self._resampling_frequency_hz
        else:
            fs = 1.0 / float(np.median(np.diff(timestamps)))
        dt = 1.0 / fs
        t_uniform = np.arange(timestamps[0], timestamps[-1], dt)

        interp_func = interp1d(timestamps, signal, kind="linear", bounds_error=False, fill_value="extrapolate")
        y_uniform = interp_func(t_uniform)

        b_band, a_band = butter(2, [0.2, 20], "bandpass", fs=fs)
        y_filtered = filtfilt(b_band, a_band, y_uniform)

        return pd.Series(y_filtered, index=t_uniform), fs

nwbize(nwb_file)

Add sniffing TimeSeries to nwb_file.

Source code in src/aind_behavior_vr_foraging_packaging/processing/_sniffing.py
def nwbize(self, nwb_file: ty.Any) -> ty.Any:
    """Add sniffing TimeSeries to *nwb_file*."""
    from pynwb import TimeSeries
    from pynwb.base import ProcessingModule

    module = nwb_file.processing.get("behavior")
    if module is None:
        module = ProcessingModule(name="behavior", description="Processing module for behavior data")
        nwb_file.add_processing_module(module)

    df = self.compute()
    fs = float(df.attrs.get("sampling_rate_hz", 0.0))
    module.add(
        TimeSeries(
            name="sniffing",
            data=df["voltage"].values,
            unit="V",
            timestamps=df.index.values,
            description=(
                "Filtered breathing/sniff signal derived from the sniff detector raw voltage "
                f"at sampling rate {fs} Hz."
            ),
        )
    )
    return nwb_file

compute_sniff_signal(dataset)

Computes the filtered breathing/sniff signal from the sniff detector raw voltage.

The raw voltage is resampled onto a uniform time grid and then passed through a 0.2-20 Hz band-pass filter to isolate the breathing band, as done in contraqctor.qc.harp.sniff_detector. The grid spacing is set by the processor's resampling_frequency_hz when provided, otherwise it defaults to the median sampling rate of the raw voltage.

Parameters:

Name Type Description Default
dataset Dataset

A contraqctor Dataset providing access to the HarpSniffDetector device.

required

Returns:

Type Description
Series

A tuple of the filtered sniff signal (indexed by harp timestamp in

float

seconds) and the sampling frequency (Hz) used for resampling.

Source code in src/aind_behavior_vr_foraging_packaging/processing/_sniffing.py
def compute_sniff_signal(self, dataset: contraqctor.contract.Dataset) -> tuple[pd.Series, float]:
    """Computes the filtered breathing/sniff signal from the sniff detector raw voltage.

    The raw voltage is resampled onto a uniform time grid and then passed
    through a 0.2-20 Hz band-pass filter to isolate the breathing band, as
    done in ``contraqctor.qc.harp.sniff_detector``. The grid spacing is set
    by the processor's ``resampling_frequency_hz`` when provided, otherwise
    it defaults to the median sampling rate of the raw voltage.

    Args:
        dataset: A contraqctor Dataset providing access to the
            ``HarpSniffDetector`` device.

    Returns:
        A tuple of the filtered sniff signal (indexed by harp timestamp in
        seconds) and the sampling frequency (Hz) used for resampling.
    """
    raw = ty.cast(
        pd.DataFrame,
        dataset.at("Behavior").at("HarpSniffDetector").load().at("RawVoltage").load().data,
    )
    raw = raw[raw["MessageType"] == "EVENT"]["RawVoltage"]

    timestamps = np.asarray(raw.index.values, dtype=float)
    signal = np.asarray(raw.values, dtype=float)

    if self._resampling_frequency_hz is not None:
        fs = self._resampling_frequency_hz
    else:
        fs = 1.0 / float(np.median(np.diff(timestamps)))
    dt = 1.0 / fs
    t_uniform = np.arange(timestamps[0], timestamps[-1], dt)

    interp_func = interp1d(timestamps, signal, kind="linear", bounds_error=False, fill_value="extrapolate")
    y_uniform = interp_func(t_uniform)

    b_band, a_band = butter(2, [0.2, 20], "bandpass", fs=fs)
    y_filtered = filtfilt(b_band, a_band, y_uniform)

    return pd.Series(y_filtered, index=t_uniform), fs

SoftwareEventsProcessor

SoftwareEventsProcessor

Bases: AbstractProcessor

Collects all SoftwareEvents streams into a single tall DataFrame.

Each row is one event, with columns:

  • event_name (str): the stream's short name, e.g. "ActiveSite".
  • data (str): JSON-serialized event payload. The structure varies by event type (polymorphic). Parse back with::

    df["data"].apply(json.loads) # or flatten a specific event type: active_sites = df[df["event_name"] == "ActiveSite"] pd.json_normalize(active_sites["data"].apply(json.loads).tolist())

Rows are sorted by harp timestamp (the DataFrame index, named "timestamp").

Source code in src/aind_behavior_vr_foraging_packaging/processing/_software_events.py
class SoftwareEventsProcessor(AbstractProcessor):
    """Collects all SoftwareEvents streams into a single tall DataFrame.

    Each row is one event, with columns:

    - ``event_name`` (str): the stream's short name, e.g. ``"ActiveSite"``.
    - ``data`` (str): JSON-serialized event payload. The structure varies by
      event type (polymorphic). Parse back with::

          df["data"].apply(json.loads)
          # or flatten a specific event type:
          active_sites = df[df["event_name"] == "ActiveSite"]
          pd.json_normalize(active_sites["data"].apply(json.loads).tolist())

    Rows are sorted by harp timestamp (the DataFrame index, named ``"timestamp"``).
    """

    __output_name__ = "software_events"

    def _compute(self) -> pd.DataFrame:
        """Returns all software events sorted by timestamp.

        Returns
        -------
        pd.DataFrame
            Index ``"timestamp"`` (harp seconds). Columns: ``event_name``, ``data``.
        """
        import contraqctor.contract as _dc

        sw_collection: ty.Any = self._dataset.at("Behavior").at("SoftwareEvents")
        sw_collection.load_all(strict=False)

        frames: list[pd.DataFrame] = []
        for stream in sw_collection.iter_all():
            if stream.is_collection:
                continue
            if stream.has_error:
                if self._strict_parsing:
                    raise ValueError(f"Stream {stream.name} error: {stream.collect_errors()}")
                logger.debug("Skipping %s: %s", stream.name, stream.collect_errors())
                continue
            if not isinstance(stream, _dc.json.SoftwareEvents):
                continue

            df = ty.cast(pd.DataFrame, stream.data)
            if df.empty:
                # An event type that never fired contributes no rows, and its frame
                # may not even carry a "data" column. Not an error.
                continue
            frames.append(
                pd.DataFrame(
                    {
                        "event_name": stream.name,
                        "data": df["data"].apply(lambda d: json.dumps(d, default=str)),
                    },
                    index=df.index,
                )
            )

        if not frames:
            empty = pd.DataFrame(columns=["event_name", "data"])
            empty.index.name = "timestamp"
            return empty

        result = pd.concat(frames).sort_index()
        result.index.name = "timestamp"
        return result

    def nwbize(self, nwb_file: ty.Any) -> ty.Any:
        """Add each SoftwareEvents stream as a separate DynamicTable acquisition.

        The NWB representation keeps one table per event type (mirroring the
        original stream structure) rather than the single tall table produced by
        ``compute()``.  The ``data`` column is JSON-serialized to remain
        compatible with NWB's string dtypes.
        """
        import json as _json

        import contraqctor.contract as _dc
        import pynwb

        from ..acquisition.helper import clean_dataframe_for_nwb

        sw_collection: ty.Any = self._dataset.at("Behavior").at("SoftwareEvents")
        sw_collection.load_all(strict=False)

        for stream in sw_collection.iter_all():
            if stream.is_collection:
                continue
            if stream.has_error:
                if self._strict_parsing:
                    raise ValueError(f"Stream {stream.name} error: {stream.collect_errors()}")
                logger.debug("Skipping %s: %s", stream.name, stream.collect_errors())
                continue
            if not isinstance(stream, _dc.json.SoftwareEvents):
                continue

            name = stream.resolved_name.replace("::", ".")
            df = ty.cast(pd.DataFrame, stream.data).copy()
            if df.empty:
                continue
            df["data"] = df["data"].apply(lambda d: _json.dumps(d, default=str))
            table = pynwb.core.DynamicTable.from_dataframe(
                name=name,
                table_description=stream.description,
                df=clean_dataframe_for_nwb(df.reset_index()),
            )
            nwb_file.add_acquisition(table)

        return nwb_file

nwbize(nwb_file)

Add each SoftwareEvents stream as a separate DynamicTable acquisition.

The NWB representation keeps one table per event type (mirroring the original stream structure) rather than the single tall table produced by compute(). The data column is JSON-serialized to remain compatible with NWB's string dtypes.

Source code in src/aind_behavior_vr_foraging_packaging/processing/_software_events.py
def nwbize(self, nwb_file: ty.Any) -> ty.Any:
    """Add each SoftwareEvents stream as a separate DynamicTable acquisition.

    The NWB representation keeps one table per event type (mirroring the
    original stream structure) rather than the single tall table produced by
    ``compute()``.  The ``data`` column is JSON-serialized to remain
    compatible with NWB's string dtypes.
    """
    import json as _json

    import contraqctor.contract as _dc
    import pynwb

    from ..acquisition.helper import clean_dataframe_for_nwb

    sw_collection: ty.Any = self._dataset.at("Behavior").at("SoftwareEvents")
    sw_collection.load_all(strict=False)

    for stream in sw_collection.iter_all():
        if stream.is_collection:
            continue
        if stream.has_error:
            if self._strict_parsing:
                raise ValueError(f"Stream {stream.name} error: {stream.collect_errors()}")
            logger.debug("Skipping %s: %s", stream.name, stream.collect_errors())
            continue
        if not isinstance(stream, _dc.json.SoftwareEvents):
            continue

        name = stream.resolved_name.replace("::", ".")
        df = ty.cast(pd.DataFrame, stream.data).copy()
        if df.empty:
            continue
        df["data"] = df["data"].apply(lambda d: _json.dumps(d, default=str))
        table = pynwb.core.DynamicTable.from_dataframe(
            name=name,
            table_description=stream.description,
            df=clean_dataframe_for_nwb(df.reset_index()),
        )
        nwb_file.add_acquisition(table)

    return nwb_file

EventsProcessor

EventsProcessor

Bases: AbstractProcessor

Collects derived/computed events into a single tall table, alongside SoftwareEventsProcessor's raw streams.

Each row is one event, with columns event_name (str) and data (the event's payload), indexed by timestamp (harp seconds). Unlike SoftwareEventsProcessor, sources here are computed from one or more underlying streams rather than being a straight passthrough.

To add a new event source: write a @staticmethod(dataset) -> pd.DataFrame returning a data-column frame indexed by timestamp, and append it to _EVENT_SOURCES.

Source code in src/aind_behavior_vr_foraging_packaging/processing/_events.py
class EventsProcessor(AbstractProcessor):
    """Collects derived/computed events into a single tall table, alongside SoftwareEventsProcessor's raw streams.

    Each row is one event, with columns ``event_name`` (str) and ``data`` (the event's payload),
    indexed by ``timestamp`` (harp seconds). Unlike SoftwareEventsProcessor, sources here are
    computed from one or more underlying streams rather than being a straight passthrough.

    To add a new event source: write a ``@staticmethod(dataset) -> pd.DataFrame`` returning a
    ``data``-column frame indexed by timestamp, and append it to ``_EVENT_SOURCES``.
    """

    __output_name__ = "events"

    _EVENT_SOURCES: t.ClassVar[list[tuple[str, t.Callable[[contraqctor.contract.Dataset], pd.DataFrame]]]] = [
        ("ManualWaterDelivery", parse_manual_water_delivery),
    ]

    @cached_frame
    def _compute(self) -> pd.DataFrame:
        """Returns all derived events sorted by timestamp.

        Returns
        -------
        pd.DataFrame
            Index ``"timestamp"`` (harp seconds). Columns: ``event_name``, ``data``.
        """
        frames: list[pd.DataFrame] = []
        for name, source in self._EVENT_SOURCES:
            df = source(self.dataset)
            if df.empty:
                continue
            frames.append(pd.DataFrame({"event_name": name, "data": df["data"]}, index=df.index))

        if not frames:
            empty = pd.DataFrame(columns=["event_name", "data"])
            empty.index.name = "timestamp"
            return empty

        result = pd.concat(frames).sort_index()
        result.index.name = "timestamp"
        return result

    def nwbize(self, nwb_file: t.Any) -> t.Any:
        """Add the derived events to *nwb_file* as an ``EventsTable``.

        The tall ``compute()`` frame maps one-to-one onto the table: the index becomes the required
        ``timestamp`` column, and ``event_name``/``data`` become columns. ``data`` is JSON-serialized
        to stay within NWB's string dtypes. No table is added when there are no derived events.
        """
        import json

        from pynwb.event import EventsTable

        df = self.compute()
        if df.empty:
            return nwb_file

        table = EventsTable(name="events", description="Events derived/computed from one or more raw streams.")
        table.add_column(name="event_name", description="Name of the derived event source.")
        table.add_column(name="data", description="JSON-serialized event payload.")
        for timestamp, row in df.iterrows():
            table.add_row(
                data={
                    "timestamp": float(t.cast(float, timestamp)),
                    "event_name": str(row["event_name"]),
                    "data": json.dumps(row["data"], default=str),
                }
            )

        nwb_file.add_events_table(table)
        return nwb_file

nwbize(nwb_file)

Add the derived events to nwb_file as an EventsTable.

The tall compute() frame maps one-to-one onto the table: the index becomes the required timestamp column, and event_name/data become columns. data is JSON-serialized to stay within NWB's string dtypes. No table is added when there are no derived events.

Source code in src/aind_behavior_vr_foraging_packaging/processing/_events.py
def nwbize(self, nwb_file: t.Any) -> t.Any:
    """Add the derived events to *nwb_file* as an ``EventsTable``.

    The tall ``compute()`` frame maps one-to-one onto the table: the index becomes the required
    ``timestamp`` column, and ``event_name``/``data`` become columns. ``data`` is JSON-serialized
    to stay within NWB's string dtypes. No table is added when there are no derived events.
    """
    import json

    from pynwb.event import EventsTable

    df = self.compute()
    if df.empty:
        return nwb_file

    table = EventsTable(name="events", description="Events derived/computed from one or more raw streams.")
    table.add_column(name="event_name", description="Name of the derived event source.")
    table.add_column(name="data", description="JSON-serialized event payload.")
    for timestamp, row in df.iterrows():
        table.add_row(
            data={
                "timestamp": float(t.cast(float, timestamp)),
                "event_name": str(row["event_name"]),
                "data": json.dumps(row["data"], default=str),
            }
        )

    nwb_file.add_events_table(table)
    return nwb_file

SessionMetadataProcessor

SessionMetadataProcessor

Bases: AbstractProcessor

Produces a single-row DataFrame of session-level metadata.

session_id is always the session directory's name; the stream's own session_name field is ignored. subject and date come from the contraqctor Behavior/InputSchemas/Session stream, with no fallback.

Source code in src/aind_behavior_vr_foraging_packaging/processing/_session_metadata.py
class SessionMetadataProcessor(AbstractProcessor):
    """Produces a single-row DataFrame of session-level metadata.

    ``session_id`` is always the session directory's name; the stream's own
    ``session_name`` field is ignored. ``subject`` and ``date`` come from the
    contraqctor ``Behavior/InputSchemas/Session`` stream, with no fallback.
    """

    __output_name__ = "session"

    def _compute(self) -> pd.DataFrame:
        raw = self._load_session_stream()
        row = self._build_metadata(raw, session_root(self._dataset).name, self.provenance)
        return pd.DataFrame([row.model_dump()])

    def _load_session_stream(self) -> dict[str, Any]:
        """Return the Session stream's payload as a plain dict."""
        data = self._dataset.at("Behavior").at("InputSchemas").at("Session").load().data
        return data.model_dump() if isinstance(data, BaseModel) else cast(dict[str, Any], data)

    @staticmethod
    def _build_metadata(raw: dict, session_id: str, provenance: PackagingProvenance) -> SessionMetadata:
        """Raises :exc:`KeyError` if ``subject`` or ``date`` is absent or empty."""
        for field in ("subject", "date"):
            if not raw.get(field):
                raise KeyError(f"Required field {field!r} missing from the contraqctor Session stream")
        return SessionMetadata(
            session_id=session_id,
            subject_id=str(raw["subject"]),
            date=datetime.datetime.fromisoformat(str(raw["date"])),
            dataset_version=provenance.dataset_version,
            data_contract_version=provenance.data_contract_version,
            packaging_version=provenance.packaging_version,
        )

Legacy processors

These are selected automatically when the dataset version is < 0.6.0.

LegacySiteTableProcessor

Bases: SiteTableProcessor

SiteTableProcessor for VR foraging datasets with schema version < 0.6.0.

Key differences from SiteTableProcessor: - Block stream is optional; falls back to ActivePatch stream when absent. - Olfactometer always exposes 3 odor channels (channel 3 is the carrier). - OdorSpecification uses legacy format: {"index": int, "concentration": float}. - Choice cue is read from HarpBehavior.PwmStart with dynamic port detection (PwmDO1 in v0.3, PwmDO2 in v0.4+; port is inferred from which channel is active). - PatchStateAtReward is reconstructed from split PatchReward.json files when absent. - A site interval may contain several water deliveries, because manual (experimenter) water was not logged separately until 0.6.0 added ForceGiveReward. The first delivery at or after the choice tone is taken as the earned one; see :meth:_first_after_choice. - HarpTreadmill is optional; friction defaults to 0 when absent. - IsStopped and velocity streams are absent; last_stop_ fields are always None.

Note on NWB table naming: re-ingested legacy datasets use the current full-path naming convention for DynamicTables (e.g., "Behavior.HarpBehavior.PwmStart"), not the legacy stripped names (e.g., "HarpBehavior.PwmStart").

Source code in src/aind_behavior_vr_foraging_packaging/processing/_legacy_site_table.py
class LegacySiteTableProcessor(SiteTableProcessor):
    """SiteTableProcessor for VR foraging datasets with schema version < 0.6.0.

    Key differences from SiteTableProcessor:
    - Block stream is optional; falls back to ActivePatch stream when absent.
    - Olfactometer always exposes 3 odor channels (channel 3 is the carrier).
    - OdorSpecification uses legacy format: {"index": int, "concentration": float}.
    - Choice cue is read from HarpBehavior.PwmStart with dynamic port detection
      (PwmDO1 in v0.3, PwmDO2 in v0.4+; port is inferred from which channel is active).
    - PatchStateAtReward is reconstructed from split PatchReward*.json files when absent.
    - A site interval may contain several water deliveries, because manual (experimenter)
      water was not logged separately until 0.6.0 added ``ForceGiveReward``. The first
      delivery at or after the choice tone is taken as the earned one; see
      :meth:`_first_after_choice`.
    - HarpTreadmill is optional; friction defaults to 0 when absent.
    - IsStopped and velocity streams are absent; last_stop_* fields are always None.

    Note on NWB table naming: re-ingested legacy datasets use the current
    full-path naming convention for DynamicTables (e.g., "Behavior.HarpBehavior.PwmStart"),
    not the legacy stripped names (e.g., "HarpBehavior.PwmStart").
    """

    def __init__(self, dataset: contraqctor.contract.Dataset, *, strict_parsing: bool = False) -> None:

        # Bypass SiteTableProcessor.__init__ — InputSchemas/Rig is not present in legacy datasets.
        AbstractProcessor.__init__(self, dataset, strict_parsing=strict_parsing)
        if self.provenance.dataset_semver >= semver.Version(major=0, minor=6, patch=0):
            raise DatasetProcessorError(
                f"LegacySiteTableProcessor only supports datasets < 0.6.0, "
                f"got {self.provenance.dataset_semver}. "
                "Use SiteTableProcessor for current datasets."
            )
        if self.provenance.dataset_semver != self.provenance.data_contract_semver:
            logger.warning(
                "Dataset version %s does not match parser version %s",
                self.provenance.dataset_semver,
                self.provenance.data_contract_semver,
            )

    @staticmethod
    def _load_blocks(dataset: contraqctor.contract.Dataset) -> pd.DataFrame:  # type: ignore[override]
        try:
            blocks = t.cast(pd.DataFrame, dataset.at("Behavior").at("SoftwareEvents").at("Block").load().data)
            blocks["block_count"] = range(len(blocks))
        except KeyError:
            # No Block stream: treat the whole session as a single block (block 0).
            # Using range(n_patches) would create spurious blocks — if there is no
            # block information, the safest assumption is one block.
            logger.info("No Block stream found; treating entire session as block 0.")
            blocks = t.cast(pd.DataFrame, dataset.at("Behavior").at("SoftwareEvents").at("ActivePatch").load().data)
            blocks["block_count"] = 0
        return blocks

    @staticmethod
    def _parse_speaker_choice_feedback(dataset: contraqctor.contract.Dataset) -> pd.DataFrame:  # type: ignore[override]
        # The choice-cue speaker channel shifted between rig generations:
        # v0.3 used PwmDO1, v0.4+ used PwmDO2. Detect the active port dynamically
        # rather than hardcoding, so we handle both without a version branch.
        pwm = dataset.at("Behavior").at("HarpBehavior").load().at("PwmStart").load().data
        writes = pwm[pwm["MessageType"] == "WRITE"]
        do_cols = [c for c in writes.columns if c.startswith("PwmDO")]
        active_cols = [c for c in do_cols if writes[c].any()]
        if not active_cols:
            logger.warning("No active PwmDO channel found in PwmStart; choice cue times will be NaN.")
            return writes.iloc[:0]  # empty DataFrame with same columns
        if len(active_cols) > 1:
            logger.warning("Multiple active PwmDO channels found (%s); using %s.", active_cols, active_cols[0])
        col = active_cols[0]
        logger.debug("Using %s as choice-cue channel.", col)
        return writes[writes[col]]

    def _select_choice_feedback(
        self,
        candidates: pd.DataFrame,
        *,
        site_index: int,
        t_start: float,
        t_end: float,
    ) -> pd.DataFrame:
        """Tolerate duplicate choice-cue pulses seen in pre-0.6.0 Harp data.

        This was only identified in a single session (behavior_754570_2024-09-06_10-58-52)
        and is not intended to be maintained as a fix in the stable version.
        """
        if len(candidates) > 1:
            logger.warning(
                "Site %d: %d speaker-choice events in interval [%.3f, %.3f); "
                "keeping first (Harp double-trigger). Timestamps: %s",
                site_index,
                len(candidates),
                t_start,
                t_end,
                candidates.index.tolist(),
            )
            return candidates.iloc[:1]
        return candidates

    def _get_olfactometer_channel_count(self, dataset: contraqctor.contract.Dataset) -> int:
        return _LEGACY_OLFACTOMETER_CHANNEL_COUNT

    def _process_odor_concentration(self, odor_specification: BaseModel | dict | None, n_channels: int) -> list[float]:
        concentration = [0.0] * n_channels
        if odor_specification is None:
            return concentration
        if isinstance(odor_specification, BaseModel):
            odor_specification = odor_specification.model_dump()
        index = odor_specification.get("index")
        if not isinstance(index, int):
            raise TypeError(f"Legacy odor_specification.index must be an int, got {type(index).__name__}")
        concentration[index] = float(odor_specification.get("concentration", 0.0))
        return concentration

    def _parse_patch_state_at_reward(self, dataset: contraqctor.contract.Dataset) -> pd.DataFrame:
        # Try the unified stream first (introduced in 0.6.0).
        try:
            patches_state_at_reward = dataset.at("Behavior").at("SoftwareEvents").at("PatchStateAtReward").load().data
            expanded = pd.json_normalize(patches_state_at_reward["data"])
            expanded.index = patches_state_at_reward.index
            return patches_state_at_reward.join(expanded)
        except (KeyError, FileNotFoundError):
            pass

        # Fall back to separate streams present in 0.3.x – 0.5.x.
        try:
            amount_df = t.cast(
                pd.DataFrame,
                dataset.at("Behavior").at("SoftwareEvents").at("PatchRewardAmount").load().data,
            )
            available_df = t.cast(
                pd.DataFrame,
                dataset.at("Behavior").at("SoftwareEvents").at("PatchRewardAvailable").load().data,
            )
            prob_df = t.cast(
                pd.DataFrame,
                dataset.at("Behavior").at("SoftwareEvents").at("PatchRewardProbability").load().data,
            )
            active_patch_df = t.cast(
                pd.DataFrame,
                dataset.at("Behavior").at("SoftwareEvents").at("ActivePatch").load().data,
            )
        except (KeyError, FileNotFoundError) as exc:
            logger.warning("Could not load split reward streams (%s); reward metadata will be NaN.", exc)
            return pd.DataFrame(columns=["PatchId", "Amount", "Probability", "Available"])

        # The three split streams fire at the same harp frame — align on Amount's index.
        result = pd.DataFrame(
            {
                "Amount": amount_df["data"].values,
                "Available": available_df["data"].values,
                "Probability": prob_df["data"].values,
            },
            index=amount_df.index,
        )

        # Assign PatchId from the most recent ActivePatch event before each reward.
        patch_state_index = active_patch_df["data"].apply(
            lambda d: d.get("state_index", np.nan) if isinstance(d, dict) else np.nan
        )
        patch_lookup = patch_state_index.rename_axis("patch_time").reset_index(name="state_index")
        reward_times = result.rename_axis("reward_time").reset_index()[["reward_time"]]
        merged = pd.merge_asof(
            reward_times, patch_lookup, left_on="reward_time", right_on="patch_time", direction="backward"
        )
        result["PatchId"] = merged["state_index"].values

        return result

    @staticmethod
    def _first_after_choice(candidates: _TRewardEvents, *, choice_time: float, what: str) -> _TRewardEvents:
        """Keep only the first reward event at or after the choice tone.

        ``ForceGiveReward`` was introduced in dataset 0.6.0; before that the experimenter's
        manual water went through the same code path as an earned reward, emitting the same
        ``GiveReward`` event, decrementing the same patch bookkeeping and commanding the same
        valve pulse. A site interval can therefore hold several deliveries that are identical
        in the record.

        The heuristic: the earned reward is the first delivery to follow the choice tone, since
        that is what the task's reward-delay timer is started by. Anything else in the interval
        is assumed to be manual and dropped. With no choice tone in the interval there is
        nothing to anchor on, so the first delivery is kept.

        This is a heuristic, not ground truth — the two are not separable before 0.6.0.
        """
        if len(candidates) <= 1:
            return candidates
        after_tone = np.flatnonzero(np.asarray(candidates.index >= choice_time))
        pos = int(after_tone[0]) if after_tone.size else 0
        chosen = candidates.iloc[pos : pos + 1]
        logger.warning(
            "%d %s events in one site interval; keeping the one at t=%s (first at/after the "
            "choice tone). The other %d are assumed to be manually delivered — manual and earned "
            "water are not separable before dataset 0.6.0.",
            len(candidates),
            what,
            chosen.index[0],
            len(candidates) - 1,
        )
        return chosen

    def _select_patch_state_at_reward(  # type: ignore[override]
        self, candidates: pd.DataFrame, *, choice_time: float, site_start: float, site_stop: float
    ) -> pd.DataFrame:
        return self._first_after_choice(candidates, choice_time=choice_time, what="patch-reward-state")

    def _select_reward_onset_time(  # type: ignore[override]
        self,
        site_water_delivery: pd.Series,
        reward_metadata_sliced: pd.DataFrame,
        *,
        site_start: float,
        choice_time: float,
    ) -> float:
        chosen = self._first_after_choice(site_water_delivery, choice_time=choice_time, what="water-delivery")
        return t.cast(float, chosen.index[0])

    @staticmethod
    def _parse_friction(dataset: contraqctor.contract.Dataset) -> pd.Series:  # type: ignore[override]
        try:
            d = dataset.at("Behavior").at("HarpTreadmill").at("BrakeCurrentSetPoint").load().data
            return d.loc[d["MessageType"] == "WRITE", "BrakeCurrentSetPoint"]
        except (KeyError, FileNotFoundError):
            logger.info("HarpTreadmill not found; friction will default to 0 for all sites.")
            return pd.Series(dtype=float)

    @staticmethod
    def _parse_is_stopped(dataset: contraqctor.contract.Dataset) -> None:  # type: ignore[override]
        return None

    def _parse_velocity(self, dataset: contraqctor.contract.Dataset) -> None:  # type: ignore[override]
        return None

LegacyPositionAndVelocityProcessor

Bases: PositionAndVelocityProcessor

PositionAndVelocityProcessor for VR foraging datasets with schema version < 0.6.0.

The only difference from PositionAndVelocityProcessor is support for v0.3 datasets where no HarpTreadmill device exists. In those sessions the encoder was wired to HarpBehavior.AnalogData.Encoder (already-differential counts at 1 kHz) and rig calibration lives under treadmill.settings rather than harp_treadmill.calibration.

For v0.4+ datasets this processor is identical to PositionAndVelocityProcessor: it uses HarpTreadmill.SensorData via the parent static method unchanged.

Source code in src/aind_behavior_vr_foraging_packaging/processing/_legacy_position_and_velocity.py
class LegacyPositionAndVelocityProcessor(PositionAndVelocityProcessor):
    """PositionAndVelocityProcessor for VR foraging datasets with schema version < 0.6.0.

    The only difference from PositionAndVelocityProcessor is support for v0.3
    datasets where no HarpTreadmill device exists. In those sessions the encoder
    was wired to HarpBehavior.AnalogData.Encoder (already-differential counts at
    1 kHz) and rig calibration lives under ``treadmill.settings`` rather than
    ``harp_treadmill.calibration``.

    For v0.4+ datasets this processor is identical to PositionAndVelocityProcessor:
    it uses HarpTreadmill.SensorData via the parent static method unchanged.
    """

    def compute_position_and_velocity(
        self,
        dataset: contraqctor.contract.Dataset,
        *,
        downsample_to_hz: float | None = 250.0,
    ) -> pd.DataFrame:
        dataset.at("Behavior").at("InputSchemas").load_all()
        rig_settings = dataset.at("Behavior").at("InputSchemas").at("Rig").load().data
        rig_settings = rig_settings.model_dump() if isinstance(rig_settings, BaseModel) else rig_settings

        try:
            # v0.4+ path: delegate entirely to the parent static method (no changes)
            df = self.compute_position_and_velocity_from_treadmill(dataset, rig_settings)
        except (KeyError, FileNotFoundError):
            # v0.3 path: no HarpTreadmill; encoder lives in HarpBehavior.AnalogData
            logger.info("HarpTreadmill not available; falling back to HarpBehavior.AnalogData (v0.3 encoder path).")
            df = self.compute_position_and_velocity_from_analog_data(dataset, rig_settings)

        if downsample_to_hz is None:
            return df

        df.sort_index(inplace=True)
        df.index = pd.to_timedelta(df.index, unit="s")
        dt = pd.to_timedelta(1.0 / downsample_to_hz, unit="s")
        df = df.resample(dt, label="right", closed="right").mean()
        df.dropna(inplace=True)
        df.index = df.index.total_seconds()
        return df

    @staticmethod
    def compute_position_and_velocity_from_analog_data(
        dataset: contraqctor.contract.Dataset,
        rig_config: dict,
    ) -> pd.DataFrame:
        """Compute position and velocity from HarpBehavior.AnalogData (v0.3 datasets).

        AnalogData.Encoder contains already-differential counts timestamped per read.
        Velocity and position are computed the same way as the parent's treadmill
        path: displacement / actual dt, using the harp timestamps directly.

        Args:
            dataset: Dataset providing access to HarpBehavior data.
            rig_config: Rig config dict. Supports the ``treadmill.settings``
                nesting used in v0.3 as well as the current ``harp_treadmill.calibration``
                schema, with both snake_case and camelCase key aliases.

        Returns:
            DataFrame with ``position`` (cm) and ``velocity`` (cm/s) columns,
            indexed by harp timestamp (seconds).
        """
        wheel_diameter, ppr, invert = _extract_legacy_treadmill_calibration(rig_config)
        converter = wheel_diameter * np.pi / ppr * (-1 if invert else 1)

        analog_data = ty.cast(
            pd.DataFrame,
            dataset.at("Behavior").at("HarpBehavior").load().at("AnalogData").load().data,
        )
        encoder = analog_data[analog_data["MessageType"] == "EVENT"]["Encoder"].astype(float)

        # Already-differential counts: displacement per sample
        displacement = encoder * converter
        position = displacement.cumsum()
        position -= position.iloc[0]
        velocity = displacement / encoder.index.to_series().diff().fillna(1)

        return pd.DataFrame({"position": position.values, "velocity": velocity.values}, index=encoder.index)

compute_position_and_velocity_from_analog_data(dataset, rig_config) staticmethod

Compute position and velocity from HarpBehavior.AnalogData (v0.3 datasets).

AnalogData.Encoder contains already-differential counts timestamped per read. Velocity and position are computed the same way as the parent's treadmill path: displacement / actual dt, using the harp timestamps directly.

Parameters:

Name Type Description Default
dataset Dataset

Dataset providing access to HarpBehavior data.

required
rig_config dict

Rig config dict. Supports the treadmill.settings nesting used in v0.3 as well as the current harp_treadmill.calibration schema, with both snake_case and camelCase key aliases.

required

Returns:

Type Description
DataFrame

DataFrame with position (cm) and velocity (cm/s) columns,

DataFrame

indexed by harp timestamp (seconds).

Source code in src/aind_behavior_vr_foraging_packaging/processing/_legacy_position_and_velocity.py
@staticmethod
def compute_position_and_velocity_from_analog_data(
    dataset: contraqctor.contract.Dataset,
    rig_config: dict,
) -> pd.DataFrame:
    """Compute position and velocity from HarpBehavior.AnalogData (v0.3 datasets).

    AnalogData.Encoder contains already-differential counts timestamped per read.
    Velocity and position are computed the same way as the parent's treadmill
    path: displacement / actual dt, using the harp timestamps directly.

    Args:
        dataset: Dataset providing access to HarpBehavior data.
        rig_config: Rig config dict. Supports the ``treadmill.settings``
            nesting used in v0.3 as well as the current ``harp_treadmill.calibration``
            schema, with both snake_case and camelCase key aliases.

    Returns:
        DataFrame with ``position`` (cm) and ``velocity`` (cm/s) columns,
        indexed by harp timestamp (seconds).
    """
    wheel_diameter, ppr, invert = _extract_legacy_treadmill_calibration(rig_config)
    converter = wheel_diameter * np.pi / ppr * (-1 if invert else 1)

    analog_data = ty.cast(
        pd.DataFrame,
        dataset.at("Behavior").at("HarpBehavior").load().at("AnalogData").load().data,
    )
    encoder = analog_data[analog_data["MessageType"] == "EVENT"]["Encoder"].astype(float)

    # Already-differential counts: displacement per sample
    displacement = encoder * converter
    position = displacement.cumsum()
    position -= position.iloc[0]
    velocity = displacement / encoder.index.to_series().diff().fillna(1)

    return pd.DataFrame({"position": position.values, "velocity": velocity.values}, index=encoder.index)