From 61ec8c7b598b67af22e43a8888e82298458cbd67 Mon Sep 17 00:00:00 2001 From: arjunsridhar12345 Date: Mon, 27 Jul 2026 22:48:54 -0700 Subject: [PATCH 1/6] feat: add anti-bias intervention to trial table and qc plot --- .../processing/_trial_table.py | 98 +++++++++++++++++++ .../processing/models/trial_config.py | 20 ++++ .../qc/processed/plots.py | 52 +++++++++- .../qc/processed/results.py | 6 ++ 4 files changed, 173 insertions(+), 3 deletions(-) diff --git a/src/dynamic_foraging_processing/processing/_trial_table.py b/src/dynamic_foraging_processing/processing/_trial_table.py index 622f1ed..3839e7d 100644 --- a/src/dynamic_foraging_processing/processing/_trial_table.py +++ b/src/dynamic_foraging_processing/processing/_trial_table.py @@ -12,6 +12,9 @@ import pandas as pd 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, @@ -397,6 +400,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. @@ -573,6 +667,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, @@ -597,6 +692,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, ) diff --git a/src/dynamic_foraging_processing/processing/models/trial_config.py b/src/dynamic_foraging_processing/processing/models/trial_config.py index 8c20bcc..d4363cb 100644 --- a/src/dynamic_foraging_processing/processing/models/trial_config.py +++ b/src/dynamic_foraging_processing/processing/models/trial_config.py @@ -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, description="x position (um) of the lickspout position (left-right)" diff --git a/src/dynamic_foraging_processing/qc/processed/plots.py b/src/dynamic_foraging_processing/qc/processed/plots.py index d04c941..cd135ed 100644 --- a/src/dynamic_foraging_processing/qc/processed/plots.py +++ b/src/dynamic_foraging_processing/qc/processed/plots.py @@ -85,8 +85,21 @@ def plot_lick_intervals( return LICK_INTERVALS_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="--") @@ -100,6 +113,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="cyan", 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="cyan", 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, @@ -250,6 +281,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. @@ -276,6 +310,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 ------- @@ -288,7 +328,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], diff --git a/src/dynamic_foraging_processing/qc/processed/results.py b/src/dynamic_foraging_processing/qc/processed/results.py index 762890f..1f8b7cb 100644 --- a/src/dynamic_foraging_processing/qc/processed/results.py +++ b/src/dynamic_foraging_processing/qc/processed/results.py @@ -37,6 +37,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", } @@ -114,6 +117,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) return results From ea74ee996a10fd0e8946689d885db3bf30d9f1c8 Mon Sep 17 00:00:00 2001 From: arjunsridhar12345 Date: Mon, 27 Jul 2026 22:49:11 -0700 Subject: [PATCH 2/6] test: update tests --- tests/test_processing/test_trial_table.py | 133 +++++++++++++++++++++- tests/test_qc/test_plots.py | 16 +++ 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/tests/test_processing/test_trial_table.py b/tests/test_processing/test_trial_table.py index 3f71c26..bde00ff 100644 --- a/tests/test_processing/test_trial_table.py +++ b/tests/test_processing/test_trial_table.py @@ -90,6 +90,9 @@ def _outcome( block_p_right=None, reward_size_left=None, reward_size_right=None, + lickspout_offset_delta=None, + is_bias_water_intervention=None, + is_bias_stage_intervention=None, ): """Build a serialized ``TrialOutcome`` payload (dict, as delivered by the reader). @@ -97,7 +100,10 @@ def _outcome( fields); ``block_p_left`` / ``block_p_right``, when given, are the block probabilities stored under ``trial.metadata`` (the source of the ``reward_probability`` columns). ``reward_size_left`` / ``reward_size_right`` - override the default per-trial reward volumes (uL). + override the default per-trial reward volumes (uL). ``lickspout_offset_delta`` + sets the per-trial horizontal spout displacement (mm), and the + ``is_bias_*_intervention`` flags populate the anti-bias ``metadata.extra`` + (``BlockBasedTrialMetadata``) block. """ trial = { "p_reward_left": p_left, @@ -108,6 +114,8 @@ def _outcome( "inter_trial_interval_duration": 4.0, "is_auto_reward_right": auto, } + if lickspout_offset_delta is not None: + trial["lickspout_offset_delta"] = lickspout_offset_delta if reward_size_left is not None or reward_size_right is not None: trial["reward_size"] = { "left": reward_size_left if reward_size_left is not None else 2.0, @@ -115,6 +123,12 @@ def _outcome( } if block_p_left is not None or block_p_right is not None: trial["metadata"] = {"p_reward_left": block_p_left, "p_reward_right": block_p_right} + if is_bias_water_intervention is not None or is_bias_stage_intervention is not None: + metadata = trial.setdefault("metadata", {}) + metadata["extra"] = { + "is_bias_water_intervention": bool(is_bias_water_intervention), + "is_bias_stage_intervention": bool(is_bias_stage_intervention), + } return { "trial": trial, "is_right_choice": is_right_choice, @@ -298,6 +312,13 @@ def test_build_full_dataset(): assert first["side_bias"] == pytest.approx(0.3) assert pd.isna(second["side_bias"]) + # No anti-bias interventions in the base fixture -> inert defaults. + assert bool(first["anti_bias_left_water"]) is False + assert bool(first["anti_bias_right_water"]) is False + assert first["anti_bias_lickspout_movement"] == 0.0 + assert bool(second["anti_bias_left_water"]) is False + assert second["anti_bias_lickspout_movement"] == 0.0 + def test_build_missing_task_logic_leaves_session_columns_null(): """A missing TaskLogic stream still builds; session distribution columns are null.""" @@ -546,6 +567,116 @@ def test_auto_water_encodes_side_from_auto_response(): assert TrialTableBuilder._auto_water(no_auto, is_right=True) == 0 +def test_bias_metadata_parses_dict_model_and_default(): + """``_bias_metadata`` handles a dict extra, a model extra, and missing metadata.""" + from aind_behavior_dynamic_foraging.task_logic.trial_generators.block_based_trial_generator import ( + BlockBasedTrialMetadata, + ) + + # Dict extra (as delivered off the stream) is validated into the model. + from_dict = TrialOutcome.model_validate( + _outcome(1.0, 1.0, is_right_choice=True, is_rewarded=True, is_bias_water_intervention=True) + ).trial + assert TrialTableBuilder._bias_metadata(from_dict).is_bias_water_intervention is True + + # A ``BlockBasedTrialMetadata`` instance is returned as-is. + trial = TrialOutcome.model_validate( + _outcome(1.0, 1.0, is_right_choice=True, is_rewarded=True, block_p_left=0.5) + ).trial + trial.metadata.extra = BlockBasedTrialMetadata(is_bias_stage_intervention=True) + assert TrialTableBuilder._bias_metadata(trial).is_bias_stage_intervention is True + + # No metadata -> all-False default. + no_meta = TrialOutcome.model_validate( + _outcome(1.0, 1.0, is_right_choice=True, is_rewarded=True) + ).trial + assert no_meta.metadata is None + default = TrialTableBuilder._bias_metadata(no_meta) + assert default.is_bias_water_intervention is False + assert default.is_bias_stage_intervention is False + + # Metadata present but a non-dict / non-model extra -> default. + other = TrialOutcome.model_validate( + _outcome(1.0, 1.0, is_right_choice=True, is_rewarded=True, block_p_left=0.5) + ).trial + other.metadata.extra = "unexpected" + assert TrialTableBuilder._bias_metadata(other).is_bias_water_intervention is False + + +def test_anti_bias_water_gated_on_intervention_flag_and_side(): + """Anti-bias water is True only for a bias-water intervention on the matching side.""" + # Right-side bias-water intervention. + right = TrialOutcome.model_validate( + _outcome( + 1.0, 1.0, is_right_choice=True, is_rewarded=True, auto=True, + is_bias_water_intervention=True, + ) + ).trial + meta = TrialTableBuilder._bias_metadata(right) + assert TrialTableBuilder._anti_bias_water(right, meta, is_right=True) is True + assert TrialTableBuilder._anti_bias_water(right, meta, is_right=False) is False + + # Auto-response to the left without the bias flag is ordinary autowater, not + # an anti-bias intervention. + autowater = TrialOutcome.model_validate( + _outcome(1.0, 1.0, is_right_choice=False, is_rewarded=True, auto=False) + ).trial + auto_meta = TrialTableBuilder._bias_metadata(autowater) + assert TrialTableBuilder._anti_bias_water(autowater, auto_meta, is_right=False) is False + + +def test_anti_bias_lickspout_movement_gated_on_stage_flag(): + """Movement is the offset delta only when flagged a bias-stage intervention.""" + moved = TrialOutcome.model_validate( + _outcome( + 1.0, 1.0, is_right_choice=True, is_rewarded=True, + lickspout_offset_delta=1.5, is_bias_stage_intervention=True, + ) + ).trial + assert TrialTableBuilder._anti_bias_lickspout_movement( + moved, TrialTableBuilder._bias_metadata(moved) + ) == pytest.approx(1.5) + + # Offset present but not flagged as a stage intervention -> 0.0. + unflagged = TrialOutcome.model_validate( + _outcome(1.0, 1.0, is_right_choice=True, is_rewarded=True, lickspout_offset_delta=1.5) + ).trial + assert ( + TrialTableBuilder._anti_bias_lickspout_movement( + unflagged, TrialTableBuilder._bias_metadata(unflagged) + ) + == 0.0 + ) + + +def test_build_populates_anti_bias_columns(): + """A dataset with anti-bias interventions populates the three anti-bias columns.""" + dataset = _full_dataset() + software_events = dataset.children["Behavior"].children["SoftwareEvents"] + software_events.children["TrialOutcome"] = _Stream( + _events( + [10.1, 20.1], + [ + _outcome( + 1.0, 1.0, is_right_choice=True, is_rewarded=True, auto=True, + is_bias_water_intervention=True, + ), + _outcome( + 1.0, 1.0, is_right_choice=True, is_rewarded=True, + lickspout_offset_delta=-0.8, is_bias_stage_intervention=True, + ), + ], + ) + ) + table = TrialTableBuilder(dataset).build() + first, second = table.iloc[0], table.iloc[1] + assert bool(first["anti_bias_right_water"]) is True + assert bool(first["anti_bias_left_water"]) is False + assert first["anti_bias_lickspout_movement"] == 0.0 + assert bool(second["anti_bias_right_water"]) is False + assert second["anti_bias_lickspout_movement"] == pytest.approx(-0.8) + + def test_block_reward_probability_reads_metadata_not_trial(): """The block probability comes from ``trial.metadata``, not the top-level p_reward.""" trial = TrialOutcome.model_validate( diff --git a/tests/test_qc/test_plots.py b/tests/test_qc/test_plots.py index 5109f59..1eb3e2d 100644 --- a/tests/test_qc/test_plots.py +++ b/tests/test_qc/test_plots.py @@ -2,6 +2,7 @@ import os +import matplotlib.pyplot as plt import numpy as np from dynamic_foraging_processing.qc.processed import plots as _plots @@ -46,11 +47,26 @@ def test_plot_side_bias_full_inputs(tmp_path): autowater_right=np.array([0, 0, 0, 1, 0, 0]), manual_left_times=np.array([0.1, 3.6]), # 0.1 -> -1, 3.6 -> trial index manual_right_times=np.array([5.6]), + anti_bias_left_water=np.array([False, False, True, False, False, False]), + anti_bias_right_water=np.array([False, False, False, False, False, True]), + anti_bias_lickspout_movement=np.array([0.0, 0.5, 0.0, 0.0, -0.3, 0.0]), ) assert name == _plots.SIDE_BIAS_PLOT assert os.path.exists(tmp_path / name) +def test_add_bias_plot_movement_with_empty_bias(): + """Lickspout markers fall back to y=0 when the bias trace is empty.""" + fig, ax = plt.subplots() + # Empty bias but a nonzero movement -> heights come from ``np.zeros``. + _plots._add_bias_plot( + ax, + np.array([]), + anti_bias_lickspout_movement=np.array([1.0, 0.0]), + ) + plt.close(fig) + + def test_plot_side_bias_minimal_inputs(tmp_path): """With only choices supplied, the optional panels are skipped cleanly.""" name = _plots.plot_side_bias(np.array([]), np.array([]), str(tmp_path)) From ca1115b04e65841a184670ef2fdaf8fdf57ab56c Mon Sep 17 00:00:00 2001 From: arjunsridhar12345 Date: Mon, 27 Jul 2026 22:49:27 -0700 Subject: [PATCH 3/6] docs: update trial table doc --- docs/trials_table_mapping.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/trials_table_mapping.md b/docs/trials_table_mapping.md index 81f45db..7ffabd1 100644 --- a/docs/trials_table_mapping.md +++ b/docs/trials_table_mapping.md @@ -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`. | @@ -142,4 +144,5 @@ 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-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`. | From bb643891f57e891e26249dd19a4c8c67997035dc Mon Sep 17 00:00:00 2001 From: arjunsridhar12345 Date: Mon, 27 Jul 2026 22:52:39 -0700 Subject: [PATCH 4/6] chore: fix linting --- tests/test_processing/test_trial_table.py | 28 ++++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/tests/test_processing/test_trial_table.py b/tests/test_processing/test_trial_table.py index bde00ff..39d236a 100644 --- a/tests/test_processing/test_trial_table.py +++ b/tests/test_processing/test_trial_table.py @@ -608,7 +608,11 @@ def test_anti_bias_water_gated_on_intervention_flag_and_side(): # Right-side bias-water intervention. right = TrialOutcome.model_validate( _outcome( - 1.0, 1.0, is_right_choice=True, is_rewarded=True, auto=True, + 1.0, + 1.0, + is_right_choice=True, + is_rewarded=True, + auto=True, is_bias_water_intervention=True, ) ).trial @@ -629,8 +633,12 @@ def test_anti_bias_lickspout_movement_gated_on_stage_flag(): """Movement is the offset delta only when flagged a bias-stage intervention.""" moved = TrialOutcome.model_validate( _outcome( - 1.0, 1.0, is_right_choice=True, is_rewarded=True, - lickspout_offset_delta=1.5, is_bias_stage_intervention=True, + 1.0, + 1.0, + is_right_choice=True, + is_rewarded=True, + lickspout_offset_delta=1.5, + is_bias_stage_intervention=True, ) ).trial assert TrialTableBuilder._anti_bias_lickspout_movement( @@ -658,12 +666,20 @@ def test_build_populates_anti_bias_columns(): [10.1, 20.1], [ _outcome( - 1.0, 1.0, is_right_choice=True, is_rewarded=True, auto=True, + 1.0, + 1.0, + is_right_choice=True, + is_rewarded=True, + auto=True, is_bias_water_intervention=True, ), _outcome( - 1.0, 1.0, is_right_choice=True, is_rewarded=True, - lickspout_offset_delta=-0.8, is_bias_stage_intervention=True, + 1.0, + 1.0, + is_right_choice=True, + is_rewarded=True, + lickspout_offset_delta=-0.8, + is_bias_stage_intervention=True, ), ], ) From 57bd166d9df91362c2de03495f373814aef87377 Mon Sep 17 00:00:00 2001 From: arjunsridhar12345 Date: Fri, 31 Jul 2026 10:44:46 -0700 Subject: [PATCH 5/6] fix: restore changes lost in merge conflict --- .../qc/processed/plots.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/dynamic_foraging_processing/qc/processed/plots.py b/src/dynamic_foraging_processing/qc/processed/plots.py index 0d96491..712a800 100644 --- a/src/dynamic_foraging_processing/qc/processed/plots.py +++ b/src/dynamic_foraging_processing/qc/processed/plots.py @@ -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="--") From cd288b56cb156ba1a06e7486d4ca46ac382fcc55 Mon Sep 17 00:00:00 2001 From: arjunsridhar12345 Date: Fri, 31 Jul 2026 10:46:32 -0700 Subject: [PATCH 6/6] refactor: use convention red for right and blue for left --- src/dynamic_foraging_processing/qc/processed/plots.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dynamic_foraging_processing/qc/processed/plots.py b/src/dynamic_foraging_processing/qc/processed/plots.py index 712a800..1e3007c 100644 --- a/src/dynamic_foraging_processing/qc/processed/plots.py +++ b/src/dynamic_foraging_processing/qc/processed/plots.py @@ -169,11 +169,11 @@ def _add_bias_plot( 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="cyan", linewidth=1, label="Anti-bias water (R)") + 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="cyan", linewidth=1, label="Anti-bias water (L)") + 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)