Skip to content
3 changes: 3 additions & 0 deletions docs/trials_table_mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ Columns are grouped by the raw source they map from.
| Trials column | Mapping |
| --- | --- |
| `auto_waterL` / `auto_waterR` | From `is_auto_reward_right`. `1` on the auto-responded side; `0` on the other side, when there was no auto-response (`None`), or when the trial is missing. |
| `anti_bias_left_water` / `anti_bias_right_water` | Boolean. `True` when the anti-bias algorithm delivered a water intervention to that side — i.e. `trial.metadata.extra.is_bias_water_intervention` is `True` **and** `is_auto_reward_right` points to that side (`False` → left, `True` → right). The anti-bias water uses the same auto-response channel as ordinary autowater, so the `is_bias_water_intervention` flag is what distinguishes it. `False` otherwise. |
| `anti_bias_lickspout_movement` | Signed horizontal displacement (mm, positive is rightward) the anti-bias algorithm moved the lickspouts on this trial: `trial.lickspout_offset_delta` when `trial.metadata.extra.is_bias_stage_intervention` is `True`, else `0.0`. |
| `bait_left` / `bait_right` | Boolean. `bait_right` is `True` if `p_reward_right == 1` and `is_auto_reward_right` is `None` or `False`. `bait_left` is `True` if `p_reward_left == 1` and `is_auto_reward_right` is `None` or `True`. |
| `response_duration` | `response_deadline_duration`. |
| `reward_consumption_duration` | `Trial -> reward_consumption_duration`. |
Expand Down Expand Up @@ -142,5 +144,6 @@ 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`. |
98 changes: 98 additions & 0 deletions src/dynamic_foraging_processing/processing/_trial_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
from aind_behavior_dynamic_foraging.rig import AindDynamicForagingRig
from aind_behavior_dynamic_foraging.task_logic import AindDynamicForagingTaskLogic
from aind_behavior_dynamic_foraging.task_logic.trial_generators import TrialGeneratorSpec
from aind_behavior_dynamic_foraging.task_logic.trial_generators.block_based_trial_generator import (
BlockBasedTrialMetadata,
)
from aind_behavior_dynamic_foraging.task_logic.trial_models import (
Trial,
TrialMetrics,
Expand Down Expand Up @@ -404,6 +407,97 @@ def _auto_water(trial: Trial, *, is_right: bool) -> int:
return 0
return int(trial.is_auto_reward_right is is_right)

@staticmethod
def _bias_metadata(trial: Trial) -> BlockBasedTrialMetadata:
"""Return the block-based extra metadata carrying the anti-bias flags.

The anti-bias flags (``is_bias_water_intervention``,
``is_bias_stage_intervention``) live on ``trial.metadata.extra``. That
field is schema-typed ``Any``, so it deserializes off the stream as a
plain ``dict`` rather than a model; a ``BlockBasedTrialMetadata``
instance is also accepted. When metadata or extra is missing (e.g. an
older session, or a non-block-based generator), the model's all-``False``
default is returned so the anti-bias columns are simply inert.

Parameters
----------
trial : Trial
The per-trial task-logic model.

Returns
-------
BlockBasedTrialMetadata
The parsed extra metadata, or an all-``False`` default when absent
or unrecognized.
"""
metadata = trial.metadata
extra = metadata.extra if metadata is not None else None
if isinstance(extra, BlockBasedTrialMetadata):
return extra
if isinstance(extra, dict):
return BlockBasedTrialMetadata.model_validate(extra)
return BlockBasedTrialMetadata()

@staticmethod
def _anti_bias_water(
trial: Trial, bias_metadata: BlockBasedTrialMetadata, *, is_right: bool
) -> bool:
"""Return whether the anti-bias algorithm watered the requested side.

The anti-bias algorithm delivers its water intervention through the same
auto-response channel as ordinary autowater (``is_auto_reward_right``:
``True`` right, ``False`` left), so the two are distinguished only by the
``is_bias_water_intervention`` flag. This is ``True`` only when the trial
was a bias-water intervention *and* the auto-response was to the
requested side.

Parameters
----------
trial : Trial
The per-trial task-logic model.
bias_metadata : BlockBasedTrialMetadata
The trial's extra metadata (see ``_bias_metadata``).
is_right : bool
``True`` for the right port, ``False`` for the left port.

Returns
-------
bool
Whether an anti-bias water intervention targeted the requested side.
"""
if not bias_metadata.is_bias_water_intervention:
return False
return trial.is_auto_reward_right is is_right

@staticmethod
def _anti_bias_lickspout_movement(
trial: Trial, bias_metadata: BlockBasedTrialMetadata
) -> float:
"""Return the anti-bias lickspout displacement (mm) for this trial.

The anti-bias algorithm's other intervention shifts the lickspouts
horizontally; the per-trial displacement is ``trial.lickspout_offset_delta``
(positive is rightward). Reported only when the trial is flagged as a
bias-stage intervention, so a stray offset from another source is not
attributed to the anti-bias algorithm; ``0.0`` otherwise.

Parameters
----------
trial : Trial
The per-trial task-logic model.
bias_metadata : BlockBasedTrialMetadata
The trial's extra metadata (see ``_bias_metadata``).

Returns
-------
float
The signed displacement (mm), or ``0.0`` when there was no
lickspout intervention.
"""
if not bias_metadata.is_bias_stage_intervention:
return 0.0
return trial.lickspout_offset_delta

@staticmethod
def _block_reward_probability(trial: Trial, *, is_right: bool) -> t.Optional[float]:
"""Return the block reward probability for a side from the trial metadata.
Expand Down Expand Up @@ -679,6 +773,7 @@ def _build_row(
trial = outcome.trial
is_right_choice = outcome.is_right_choice
is_rewarded = bool(outcome.is_rewarded)
bias_metadata = self._bias_metadata(trial)

return TrialConfig(
start_time=start,
Expand All @@ -703,6 +798,9 @@ def _build_row(
delay_duration=trial.quiescence_period_duration,
auto_waterL=self._auto_water(trial, is_right=False),
auto_waterR=self._auto_water(trial, is_right=True),
anti_bias_left_water=self._anti_bias_water(trial, bias_metadata, is_right=False),
anti_bias_right_water=self._anti_bias_water(trial, bias_metadata, is_right=True),
anti_bias_lickspout_movement=self._anti_bias_lickspout_movement(trial, bias_metadata),
**session,
**lickspout,
)
Expand Down
20 changes: 20 additions & 0 deletions src/dynamic_foraging_processing/processing/models/trial_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,26 @@ class TrialConfig(BaseModel):
auto_waterL: int = Field(default=0, description="Autowater given at Left")
auto_waterR: int = Field(default=0, description="Autowater given at Right")

# --- anti_bias (interventions the anti-bias algorithm applies) ---
anti_bias_left_water: bool = Field(
default=False,
description=(
"Whether the anti-bias algorithm delivered a water intervention to the left lickport on this trial."
),
)
anti_bias_right_water: bool = Field(
default=False,
description=(
"Whether the anti-bias algorithm delivered a water intervention to the right lickport on this trial."
),
)
anti_bias_lickspout_movement: float = Field(
default=0.0,
description=(
"Horizontal distance (mm) the lickspouts were moved by the anti-bias algorithm on this trial (positive is rightward); 0 when no lickspout intervention occurred."
),
)

# --- lickspout_position (mapping's `lickspout_positions` -> these four components) ---
lickspout_position_x: Optional[float] = Field(
default=None,
Expand Down
52 changes: 49 additions & 3 deletions src/dynamic_foraging_processing/qc/processed/plots.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,21 @@ def plot_lick_latency(
return LICK_LATENCY_PLOT


def _add_bias_plot(ax: plt.Axes, side_bias: np.ndarray) -> None:
"""Draw the per-trial side-bias trace from the trial-table column."""
def _add_bias_plot(
ax: plt.Axes,
side_bias: np.ndarray,
anti_bias_left_water: t.Optional[np.ndarray] = None,
anti_bias_right_water: t.Optional[np.ndarray] = None,
anti_bias_lickspout_movement: t.Optional[np.ndarray] = None,
) -> None:
"""Draw the per-trial side-bias trace with anti-bias interventions overlaid.

The anti-bias algorithm pushes against a developing side bias, so its two
interventions are drawn on top of the bias trace they respond to: water
interventions as short ticks at the top (right port) and bottom (left
port), and lickspout movements as markers on the trace at the trials where
the spout was shifted.
"""
ax.set_xlabel("Trial #")
ax.set_ylabel("Side Bias")
ax.axhline(+0.7, color="r", linestyle="--")
Expand All @@ -153,6 +166,24 @@ def _add_bias_plot(ax: plt.Axes, side_bias: np.ndarray) -> None:
if len(bias):
ax.set_xlim([0, len(bias)])

plotted = False
if anti_bias_right_water is not None:
right = np.where(np.asarray(anti_bias_right_water, dtype=bool))[0]
ax.vlines(right, 0.9, 1.0, color="red", linewidth=1, label="Anti-bias water (R)")
plotted = True
if anti_bias_left_water is not None:
left = np.where(np.asarray(anti_bias_left_water, dtype=bool))[0]
ax.vlines(left, -1.0, -0.9, color="blue", linewidth=1, label="Anti-bias water (L)")
plotted = True
if anti_bias_lickspout_movement is not None:
move = np.asarray(anti_bias_lickspout_movement, dtype=float)
moved = np.where(move != 0)[0]
heights = bias[moved] if len(bias) else np.zeros(len(moved))
ax.plot(moved, heights, "g^", markersize=6, label="Anti-bias lickspout move")
plotted = True
if plotted:
ax.legend(loc="upper left", fontsize="x-small")


def _add_lickspout_position_plot(
ax: plt.Axes,
Expand Down Expand Up @@ -305,6 +336,9 @@ def plot_side_bias(
autowater_right: t.Optional[np.ndarray] = None,
manual_left_times: t.Optional[np.ndarray] = None,
manual_right_times: t.Optional[np.ndarray] = None,
anti_bias_left_water: t.Optional[np.ndarray] = None,
anti_bias_right_water: t.Optional[np.ndarray] = None,
anti_bias_lickspout_movement: t.Optional[np.ndarray] = None,
) -> str:
"""Save the four-panel side-bias figure.

Expand All @@ -331,6 +365,12 @@ def plot_side_bias(
Per-trial autowater indicator arrays.
manual_left_times, manual_right_times : numpy.ndarray, optional
Manual-water delivery timestamps (s).
anti_bias_left_water, anti_bias_right_water : numpy.ndarray, optional
Boolean per-trial arrays flagging anti-bias water interventions on each
side; overlaid on the side-bias trace.
anti_bias_lickspout_movement : numpy.ndarray, optional
Per-trial signed lickspout displacement (mm) applied by the anti-bias
algorithm; nonzero trials are marked on the side-bias trace.

Returns
-------
Expand All @@ -343,7 +383,13 @@ def plot_side_bias(
axis.spines["top"].set_visible(False)
axis.spines["right"].set_visible(False)

_add_bias_plot(ax[0], side_bias)
_add_bias_plot(
ax[0],
side_bias,
anti_bias_left_water=anti_bias_left_water,
anti_bias_right_water=anti_bias_right_water,
anti_bias_lickspout_movement=anti_bias_lickspout_movement,
)
_add_lickspout_position_plot(ax[1], lickspout_x, lickspout_y1, lickspout_y2, lickspout_z)
_add_behavior_plot(
ax[2],
Expand Down
6 changes: 6 additions & 0 deletions src/dynamic_foraging_processing/qc/processed/results.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@
"reward_probability_right": "reward_probabilityR",
"autowater_left": "auto_waterL",
"autowater_right": "auto_waterR",
"anti_bias_left_water": "anti_bias_left_water",
"anti_bias_right_water": "anti_bias_right_water",
"anti_bias_lickspout_movement": "anti_bias_lickspout_movement",
"go_cue_times": "goCue_start_time",
}

Expand Down Expand Up @@ -123,6 +126,9 @@ def behavior_qc_results(
autowater_right=_column(trials, "autowater_right"),
manual_left_times=manual_left_times,
manual_right_times=manual_right_times,
anti_bias_left_water=_column(trials, "anti_bias_left_water"),
anti_bias_right_water=_column(trials, "anti_bias_right_water"),
anti_bias_lickspout_movement=_column(trials, "anti_bias_lickspout_movement"),
)
plot_lick_intervals(left_lick_times, right_lick_times, results_folder)
plot_lick_latency(
Expand Down
Loading