Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/trials_table_mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,14 @@ Columns are grouped by the raw source they map from.
| `reward_probabilityL` / `reward_probabilityR` | The **block** probability from `Trial -> metadata -> p_reward_left` / `p_reward_right`. The top-level `trial.p_reward_left` / `p_reward_right` is the per-trial probability, not the block probability, so it is not used here. `None` when the trial or its metadata is missing. |
| `reward_size_left` | `Trial -> reward_size.left` — the reward volume (uL) at the left port. Defaults to `2.0` when not set on the trial. `None` when the trial is missing. |
| `reward_size_right` | `Trial -> reward_size.right` — the reward volume (uL) at the right port. Defaults to `2.0` when not set on the trial. `None` when the trial is missing. |
| `rewarded_historyL` / `rewarded_historyR` | Filter `is_rewarded == True`, then on `is_right_choice`. |
| `rewarded_historyL` / `rewarded_historyR` | **Earned** reward only: filter `is_rewarded == True`, then on `is_right_choice`. `False` on both sides when `is_auto_reward_right` is set (either side) — that trial's water is autowater and is reported by `auto_waterL` / `auto_waterR`. |

### From `TrialGeneratorSpec.json` (`SoftwareEvents` stream)

| Trials column | Mapping |
| --- | --- |
| `base_reward_probability_sum` | If `type == "CoupledTrialGenerator"`, look at `reward_probability_parameters`. |
| `min_reward_each_block` | Present when `type == "CoupledWarmupTrialGenerator"` (has `min_block_reward`); otherwise `None`. |
| `min_reward_each_block` | `min_block_reward` when `type == "CoupledWarmupTrialGenerator"`; otherwise `0`, since a generator without that field enforces no per-block reward minimum. |

### Trial period timing (the four period `SoftwareEvents` streams)

Expand Down Expand Up @@ -184,8 +184,10 @@ These were mapped during exploration but are no longer in scope:
| 2026-06-17 | `auto_waterL` / `auto_waterR` now encode no auto-response (`is_auto_reward_right` is `None`) and missing trials as `0` instead of `NULL`. The columns are non-nullable (`int`, default `0`). |
| 2026-06-20 | Added `reward_size_left` / `reward_size_right` (reward volume in uL) from `task_parameters.reward_size`, and `side_bias` from the per-trial `TrialMetrics` event (`bias` field). |
| 2026-06-20 | `reward_probabilityL` / `reward_probabilityR` now read the block probability from `trial.metadata.p_reward_left` / `p_reward_right` instead of the top-level per-trial `trial.p_reward_left` / `p_reward_right`. |
| 2026-07-27 | Added `anti_bias_left_water` / `anti_bias_right_water` (boolean anti-bias water interventions per side) and `anti_bias_lickspout_movement` (mm the anti-bias algorithm shifted the lickspouts) from `TrialOutcome`'s `trial.metadata.extra` (`is_bias_water_intervention` / `is_bias_stage_intervention`), `is_auto_reward_right`, and `lickspout_offset_delta`. These are also overlaid on the QC `side_bias.png` figure. |
| 2026-07-22 | `lickspout_position_x` / `y1` / `y2` / `z` now derive from the `HarpManipulator` `AccumulatedSteps` stream (microsteps → mm via the `InputSchemas.Rig` manipulator calibration, `full_step_to_mm / microstep_resolution`), sampled per trial via the closest sample in the `[start_time, stop_time)` window and re-referenced to the session-start position (displacement relative to session start, mm), replacing the static `InitialManipulatorPosition` software event. `Motor{i}` maps to `Axis(i + 1)` (X, Y1, Y2, Z). The rig and `AccumulatedSteps` streams are required when there are trials (`build` raises if either is missing). Column descriptions corrected from `um` to `mm`. |
| 2026-07-24 | `reward_size_left` / `reward_size_right` moved from session-level `task_parameters.reward_size` to per-trial `Trial.reward_size` (fields `.left` / `.right`). The columns are now nullable — `None` when the trial is missing. A missing `TaskLogic` stream no longer raises; session distribution columns are simply null. `min_reward_each_block` moved from `CoupledTrialGenerator` to `CoupledWarmupTrialGenerator`. |
| 2026-07-27 | Added `anti_bias_left_water` / `anti_bias_right_water` (boolean anti-bias water interventions per side) and `anti_bias_lickspout_movement` (mm the anti-bias algorithm shifted the lickspouts) from `TrialOutcome`'s `trial.metadata.extra` (`is_bias_water_intervention` / `is_bias_stage_intervention`), `is_auto_reward_right`, and `lickspout_offset_delta`. These are also overlaid on the QC `side_bias.png` figure. |
| 2026-08-06 | **Breaking:** the trial `start_time` / `stop_time` columns are removed and replaced by one start/stop pair per task period: `quiescent_start_time` / `quiescent_stop_time`, `response_start_time` / `response_stop_time`, `reward_consumption_start_time` / `reward_consumption_stop_time`, and `ITI_start_time` / `ITI_stop_time`, read from the `ResponsePeriod` and `RewardConsumptionPeriod` streams in addition to `QuiescentPeriod` and `ItiPeriod`. Each period event marks its period's start, so each stop is the next period's start; `ITI_stop_time` is the next trial's `QuiescentPeriod` timestamp (`NaN` on the last trial). The two new streams are also checked for positional alignment with `TrialOutcome`. NWB's required native `start_time` / `stop_time` are now derived when writing (`quiescent_start_time` → `ITI_stop_time`, falling back to `ITI_start_time`), so the NWB trials table changes in two ways: the old `start_time` / `stop_time` columns are gone, and the native trial extent now ends at the *end* of the ITI rather than at its start. |
| 2026-08-06 | Confirmed and documented that the legacy `delay_*` columns describe the acquisition software's **quiescence period**: `delay_start_time` is the `QuiescentPeriod` timestamp (always equal to the new `quiescent_start_time`) and `delay_duration` / `delay_beta` / `delay_min` / `delay_max` summarize `quiescence_period_duration`. `delay_duration` is the *configured* duration — each lick restarts the quiescent period, so the realized `quiescent_stop_time - quiescent_start_time` can be longer. Column descriptions updated accordingly. |
| 2026-08-12 | `rewarded_historyL` / `rewarded_historyR` now record **earned** reward only: an auto-reward trial (`is_auto_reward_right` set to either side) is `False` on *both* sides, since `TrialOutcome.is_rewarded` is `True` for autowater too and that water is already reported by `auto_waterL` / `auto_waterR`. This matches the `earned` / `automatic` split used for the NWB reward-delivery annotations. |
| 2026-08-12 | `min_reward_each_block` is now `0` rather than `NULL` when the trial generator exposes no `min_block_reward` — no per-block minimum is a floor of zero, not an unknown. The column is non-nullable (`float`, default `0`). |
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ def _reward_delivery_series(

Only valve-open events (``port_column`` is truthy) are reward
deliveries; the ``data`` field annotates each as earned, manual, or
automatic via :func:`get_annotated_rewards`.
auto via :func:`get_annotated_rewards`.

Parameters
----------
Expand Down Expand Up @@ -223,7 +223,7 @@ def _reward_delivery_series(
unit="second",
description=(
f"The reward delivery time of the {side_label} lick port. The data field "
"annotates whether the reward was earned, manual, or automatic"
"annotates whether the reward was earned, manual, or auto"
),
)

Expand Down
58 changes: 42 additions & 16 deletions src/dynamic_foraging_processing/processing/_trial_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,9 @@ def _distribution_stats(

``beta`` is the scale of an exponential distribution (``1 / rate``); it
is ``None`` for non-exponential families (e.g. the scalar quiescent
duration). ``min``/``max`` come from the truncation parameters when set.
duration). ``min``/``max`` come from the truncation parameters when set,
except for a uniform distribution, whose bounds are its own ``min`` and
``max`` distribution parameters.

Parameters
----------
Expand All @@ -247,11 +249,16 @@ def _distribution_stats(
"""
beta: t.Optional[float] = None
params = distribution.distribution_parameters
if params.family == DistributionFamily.EXPONENTIAL and params.rate:
beta = 1.0 / params.rate
truncation = distribution.truncation_parameters
minimum = truncation.min if truncation is not None else None
maximum = truncation.max if truncation is not None else None
if params.family == DistributionFamily.EXPONENTIAL and params.rate:
beta = 1.0 / params.rate
elif params.family == DistributionFamily.UNIFORM:
# A uniform distribution carries its bounds in the distribution
# parameters rather than the truncation parameters.
minimum = params.min
maximum = params.max
return beta, minimum, maximum

@staticmethod
Expand Down Expand Up @@ -332,17 +339,32 @@ def _side_bias(payload: t.Any) -> t.Optional[float]:
# ------------------------------------------------------------------ #
@staticmethod
def _rewarded_history(
is_rewarded: bool, is_right_choice: t.Optional[bool], *, is_right: bool
trial: Trial,
is_rewarded: bool,
is_right_choice: t.Optional[bool],
*,
is_right: bool,
) -> bool:
"""Return if mouse was rewarded based on choice.
"""Return whether the mouse *earned* reward on the requested side.

``rewarded_history`` records earned reward only, i.e. water the animal
worked for. ``TrialOutcome.is_rewarded`` is ``True`` for any water
delivered on the trial, autowater included, so an autowater trial
(``trial.is_auto_reward_right is not None``) is ``False`` on *both*
sides here — its water is reported by ``auto_waterL``/``auto_waterR``
instead. This matches the ``earned``/``automatic`` split in
:func:`~dynamic_foraging_processing.utils.rewards.get_annotated_rewards`.

A trial with no reward or an ignored trial (no choice) counts as not
rewarded on either side (``False``).
A trial with no reward or an ignored trial (no choice) likewise counts
as not rewarded on either side (``False``).

Parameters
----------
trial : Trial
The per-trial task-logic model; ``is_auto_reward_right`` being set
(to either side) marks the trial as an autowater trial.
is_rewarded : bool
Whether the trial delivered reward.
Whether the trial delivered reward (earned *or* auto).
is_right_choice : bool or None
``True`` for a right choice, ``False`` for left, ``None`` for ignored.
is_right : bool
Expand All @@ -351,10 +373,10 @@ def _rewarded_history(
Returns
-------
bool
``True`` only when the trial was rewarded and the choice was on the
requested side; ``False`` otherwise.
``True`` only when the trial delivered reward with no autowater and
the choice was on the requested side; ``False`` otherwise.
"""
if is_right_choice is None:
if is_right_choice is None or trial.is_auto_reward_right is not None:
return False
return is_rewarded and (is_right_choice is is_right)

Expand Down Expand Up @@ -636,9 +658,9 @@ def _session_columns(self, task_logic: AindDynamicForagingTaskLogic) -> t.Dict[s
delay_max=delay_max,
base_reward_probability_sum=base_reward_sum,
)
# ``min_block_reward`` is warmup-generator-only; main coupled generators omit it.
if hasattr(generator, "min_block_reward"):
columns["min_reward_each_block"] = generator.min_block_reward
# ``min_block_reward`` is warmup-generator-only; a generator that omits it
# enforces no per-block minimum, which is a floor of 0 rather than unknown.
columns["min_reward_each_block"] = getattr(generator, "min_block_reward", 0)
return columns

def _manipulator_mm_per_step(self, rig: AindDynamicForagingRig) -> t.Dict[str, float]:
Expand Down Expand Up @@ -855,8 +877,12 @@ def _build_row(
**periods,
delay_start_time=start,
animal_response=self._animal_response(response),
rewarded_historyL=self._rewarded_history(is_rewarded, is_right_choice, is_right=False),
rewarded_historyR=self._rewarded_history(is_rewarded, is_right_choice, is_right=True),
rewarded_historyL=self._rewarded_history(
trial, is_rewarded, is_right_choice, is_right=False
),
rewarded_historyR=self._rewarded_history(
trial, is_rewarded, is_right_choice, is_right=True
),
goCue_start_time=self._closest_time_in_window(go_cue_times, start, stop),
left_valve_open_time=left_valve_open_time,
right_valve_open_time=right_valve_open_time,
Expand Down
17 changes: 13 additions & 4 deletions src/dynamic_foraging_processing/processing/models/trial_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,16 @@ class TrialConfig(BaseModel):
description="The response of the animal. 0, left choice; 1, right choice; 2, no response",
)
rewarded_historyL: bool = Field(
default=False, description="The reward history of left lick port"
default=False,
description=(
"The earned reward history of the left lick port; False on autowater trials, whose water is reported by auto_waterL"
),
)
rewarded_historyR: bool = Field(
default=False, description="The reward history of right lick port"
default=False,
description=(
"The earned reward history of the right lick port; False on autowater trials, whose water is reported by auto_waterR"
),
)
delay_start_time: Optional[float] = Field(
default=None,
Expand Down Expand Up @@ -133,8 +139,11 @@ class TrialConfig(BaseModel):
block_max: Optional[float] = Field(
default=None, description="The maximum length allowed for each block"
)
min_reward_each_block: Optional[float] = Field(
default=None, description="The minimum reward allowed for each block"
min_reward_each_block: float = Field(
default=0,
description=(
"The minimum reward allowed for each block; 0 when the generator enforces no per-block minimum (only the warmup generator does)"
),
)

# --- delay_duration ---
Expand Down
15 changes: 10 additions & 5 deletions src/dynamic_foraging_processing/utils/rewards.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def get_annotated_rewards(
trial_outcome_df: pd.DataFrame,
manual_water_times: np.ndarray,
) -> np.ndarray:
"""Annotate each reward delivery as ``earned``, ``automatic``, or ``manual``.
"""Annotate each reward delivery as ``earned``, ``auto``, or ``manual``.

Annotates the deliveries of a single lick port. Each delivery is classified
as follows, with ``manual`` taking precedence because manual water is not
Expand All @@ -45,10 +45,13 @@ def get_annotated_rewards(
``GiveManualWater`` software event for this port. The software-event
timestamps are correlated to the reward-delivery timestamps with
:func:`find_closest_timestamps`.
- ``automatic`` -- otherwise, when the matching trial auto-responded
- ``auto`` -- otherwise, when the matching trial auto-responded
(``is_auto_reward_right is not None``).
- ``earned`` -- otherwise (no matching trial, or no auto-response).

Deliveries are matched to trials by the ``TrialOutcome`` software-event
timestamp: each delivery takes the annotation of the closest trial.

Parameters
----------
reward_delivery_times : numpy.ndarray
Expand All @@ -64,7 +67,7 @@ def get_annotated_rewards(
-------
numpy.ndarray
Array of the same shape as ``reward_delivery_times`` whose entries are
``"earned"``, ``"automatic"``, or ``"manual"``.
``"earned"``, ``"auto"``, or ``"manual"``.
"""
reward_times = np.asarray(reward_delivery_times)
if reward_times.size == 0:
Expand All @@ -83,9 +86,11 @@ def get_annotated_rewards(
if trial is None or trial.is_auto_reward_right is None:
annotated_rewards.append("earned")
else:
annotated_rewards.append("automatic")
annotated_rewards.append("auto")

annotated_rewards = np.array(annotated_rewards)
# Object dtype, not the inferred fixed-width string dtype: a run of only "auto"
# and "earned" entries would be too narrow to hold "manual" and would truncate it.
annotated_rewards = np.array(annotated_rewards, dtype=object)

# Manual water is independent of trials (multiple can occur within a trial) and
# takes precedence, so annotate the manual deliveries directly. Correlate each
Expand Down
6 changes: 4 additions & 2 deletions tests/test_nwb/test_acquisition/test_acquisition_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,9 @@ def test_get_manual_water_times_returns_empty_when_absent():
{
"HarpBehavior": _FakeNode({"OutputSet": _FakeStream(_make_output_set_frame())}),
"SoftwareEvents": _FakeNode(
{"TrialOutcome": _FakeStream(_make_trial_outcome_frame())}
{
"TrialOutcome": _FakeStream(_make_trial_outcome_frame()),
}
),
}
)
Expand Down Expand Up @@ -265,7 +267,7 @@ def test_build_acquisition_returns_populated_list():
# the second is overridden to manual by the right-side manual-water event.
assert isinstance(right_reward, AcquisitionSeries)
np.testing.assert_array_equal(right_reward.timestamps, np.array([0.3, 0.5]))
np.testing.assert_array_equal(right_reward.data, np.array(["automatic", "manual"]))
np.testing.assert_array_equal(right_reward.data, np.array(["auto", "manual"]))
assert right_reward.name == "right_reward_delivery_time"
assert "right lick port" in right_reward.description

Expand Down
2 changes: 1 addition & 1 deletion tests/test_pipeline/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ def test_manual_water_times_reads_manual_annotations():
"left_reward_delivery_time": _FakeSeries(
np.array(["manual", "earned", "manual"]), np.array([0.1, 0.2, 0.3])
),
"right_reward_delivery_time": _FakeSeries(np.array(["automatic"]), np.array([0.5])),
"right_reward_delivery_time": _FakeSeries(np.array(["auto"]), np.array([0.5])),
}

left, right = Pipeline._manual_water_times(nwb_file)
Expand Down
Loading