From 824d88765ceddef297517dc811297df0c2c86ee8 Mon Sep 17 00:00:00 2001 From: arjunsridhar12345 <109561860+arjunsridhar12345@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:18:15 -0700 Subject: [PATCH 1/4] fix: block beta block min and block max are nan on every trial for uniform distribution (#67) * fix: add check for uniform distribution * test: update tests --- .../processing/_trial_table.py | 13 +++-- tests/test_processing/test_trial_table.py | 51 +++++++++++++++---- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/dynamic_foraging_processing/processing/_trial_table.py b/src/dynamic_foraging_processing/processing/_trial_table.py index 9daaab9..8158162 100644 --- a/src/dynamic_foraging_processing/processing/_trial_table.py +++ b/src/dynamic_foraging_processing/processing/_trial_table.py @@ -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 ---------- @@ -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 diff --git a/tests/test_processing/test_trial_table.py b/tests/test_processing/test_trial_table.py index e5ebe3f..ebadf13 100644 --- a/tests/test_processing/test_trial_table.py +++ b/tests/test_processing/test_trial_table.py @@ -23,6 +23,8 @@ Scalar, ScalarDistributionParameter, TruncationParameters, + UniformDistribution, + UniformDistributionParameters, ) from dynamic_foraging_processing.processing import TrialConfig, TrialTableBuilder @@ -139,16 +141,28 @@ def _outcome( } -def _task_logic(quiescent_scalar=True): - """Build a coupled-generator task logic with known distribution parameters.""" - quiescent = ( - Scalar(distribution_parameters=ScalarDistributionParameter(value=0.0)) - if quiescent_scalar - else ExponentialDistribution( - distribution_parameters=ExponentialDistributionParameters(rate=1.0), - truncation_parameters=TruncationParameters(min=0.0, max=1.0), +def _quiescent_distribution(kind): + """Build the quiescent-duration distribution for the requested family. + + ``"uniform"`` deliberately also carries truncation parameters that differ + from its own bounds, so tests can pin down which pair is reported. + """ + if kind == "scalar": + return Scalar(distribution_parameters=ScalarDistributionParameter(value=0.0)) + if kind == "uniform": + return UniformDistribution( + distribution_parameters=UniformDistributionParameters(min=0.25, max=0.75), + truncation_parameters=TruncationParameters(min=9.0, max=99.0), ) + return ExponentialDistribution( + distribution_parameters=ExponentialDistributionParameters(rate=1.0), + truncation_parameters=TruncationParameters(min=0.0, max=1.0), ) + + +def _task_logic(quiescent="scalar"): + """Build a coupled-generator task logic with known distribution parameters.""" + quiescent = _quiescent_distribution(quiescent) spec = CoupledTrialGeneratorSpec( quiescent_duration=quiescent, inter_trial_interval_duration=ExponentialDistribution( @@ -348,6 +362,9 @@ def test_build_full_dataset(): assert first["ITI_min"] == 1.0 and first["ITI_max"] == 10.0 assert first["block_beta"] == pytest.approx(20.0) assert pd.isna(first["delay_beta"]) # scalar quiescent distribution + # Scalar has neither a scale nor truncation parameters -> null bounds. + assert pd.isna(first["delay_min"]) + assert pd.isna(first["delay_max"]) assert pd.isna(first["min_reward_each_block"]) # removed from generator schema assert first["base_reward_probability_sum"] == pytest.approx(0.8) @@ -418,7 +435,7 @@ def test_build_exponential_quiescent_sets_delay_beta(): """A non-scalar quiescent distribution populates delay beta/min/max.""" behavior = _full_dataset().children["Behavior"] behavior.children["InputSchemas"].children["TaskLogic"] = _Stream( - _task_logic(quiescent_scalar=False) + _task_logic(quiescent="exponential") ) table = TrialTableBuilder(_Node({"Behavior": behavior})).build() assert table.iloc[0]["delay_beta"] == pytest.approx(1.0) @@ -426,6 +443,22 @@ def test_build_exponential_quiescent_sets_delay_beta(): assert table.iloc[0]["delay_max"] == 1.0 +def test_build_uniform_quiescent_sets_delay_bounds_from_parameters(): + """A uniform quiescent distribution reports its own bounds and no beta.""" + behavior = _full_dataset().children["Behavior"] + behavior.children["InputSchemas"].children["TaskLogic"] = _Stream( + _task_logic(quiescent="uniform") + ) + table = TrialTableBuilder(_Node({"Behavior": behavior})).build() + first = table.iloc[0] + # Uniform has no scale parameter, so beta stays null. + assert pd.isna(first["delay_beta"]) + # The bounds come from the distribution parameters (0.25/0.75), not from the + # truncation parameters the fixture also sets (9.0/99.0). + assert first["delay_min"] == pytest.approx(0.25) + assert first["delay_max"] == pytest.approx(0.75) + + def test_build_warns_on_misaligned_streams(caplog): """A per-trial stream shorter than TrialOutcome warns but still builds.""" table = TrialTableBuilder(_misaligned_dataset()).build() From 6d62d7857e3294995588b14a86a715c411de5840 Mon Sep 17 00:00:00 2001 From: arjunsridhar12345 <109561860+arjunsridhar12345@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:09:03 -0700 Subject: [PATCH 2/4] fix: update the nwb packaging to include the auto water annotation (#66) * fix: use repsonse event for correlating with reward delivery times * test: update tests * refactor: revert to using trial outcome and set automatic label to auto * test: update tests --- .../nwb/acquisition/acquisition_builder.py | 4 ++-- .../utils/rewards.py | 15 ++++++++---- .../test_acquisition_builder.py | 6 +++-- tests/test_pipeline/test_pipeline.py | 2 +- tests/test_utils/test_rewards.py | 24 ++++++++++++++----- 5 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/dynamic_foraging_processing/nwb/acquisition/acquisition_builder.py b/src/dynamic_foraging_processing/nwb/acquisition/acquisition_builder.py index a63fda2..ebd5270 100644 --- a/src/dynamic_foraging_processing/nwb/acquisition/acquisition_builder.py +++ b/src/dynamic_foraging_processing/nwb/acquisition/acquisition_builder.py @@ -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 ---------- @@ -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" ), ) diff --git a/src/dynamic_foraging_processing/utils/rewards.py b/src/dynamic_foraging_processing/utils/rewards.py index 068a144..02234a8 100644 --- a/src/dynamic_foraging_processing/utils/rewards.py +++ b/src/dynamic_foraging_processing/utils/rewards.py @@ -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 @@ -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 @@ -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: @@ -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 diff --git a/tests/test_nwb/test_acquisition/test_acquisition_builder.py b/tests/test_nwb/test_acquisition/test_acquisition_builder.py index 4b1f91f..f16915e 100644 --- a/tests/test_nwb/test_acquisition/test_acquisition_builder.py +++ b/tests/test_nwb/test_acquisition/test_acquisition_builder.py @@ -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()), + } ), } ) @@ -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 diff --git a/tests/test_pipeline/test_pipeline.py b/tests/test_pipeline/test_pipeline.py index 1124adc..c897ea7 100644 --- a/tests/test_pipeline/test_pipeline.py +++ b/tests/test_pipeline/test_pipeline.py @@ -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) diff --git a/tests/test_utils/test_rewards.py b/tests/test_utils/test_rewards.py index 3fcc4e0..2be8269 100644 --- a/tests/test_utils/test_rewards.py +++ b/tests/test_utils/test_rewards.py @@ -27,7 +27,7 @@ def _outcome_payload(auto=None) -> dict: def _trial_outcome_df(trial_times: np.ndarray, autos=None) -> pd.DataFrame: - """Build a trial outcome DataFrame indexed by ``trial_times``.""" + """Build a trial outcome DataFrame with one row per entry of ``trial_times``.""" autos = autos if autos is not None else [None] * len(trial_times) return pd.DataFrame( {"data": [_outcome_payload(auto) for auto in autos]}, @@ -45,14 +45,26 @@ def test_get_annotated_rewards_marks_default_trials_as_earned(): np.testing.assert_array_equal(annotations, np.array(["earned", "earned", "earned"])) -def test_get_annotated_rewards_marks_auto_response_trials_as_automatic(): - """Trials with ``is_auto_reward_right`` set (either side) are ``automatic``.""" +def test_get_annotated_rewards_marks_auto_response_trials_as_auto(): + """Trials with ``is_auto_reward_right`` set (either side) are ``auto``.""" reward_times = np.array([0.15, 0.42]) trial_outcome_df = _trial_outcome_df(np.array([0.1, 0.4]), autos=[True, False]) annotations = get_annotated_rewards(reward_times, trial_outcome_df, np.array([])) - np.testing.assert_array_equal(annotations, np.array(["automatic", "automatic"])) + np.testing.assert_array_equal(annotations, np.array(["auto", "auto"])) + + +def test_get_annotated_rewards_matches_closest_trial_outcome_time(): + """Each delivery takes the annotation of the closest ``TrialOutcome`` event.""" + # Both deliveries sit nearest the second (auto) trial, so both are auto even + # though the first trial is earned. + reward_times = np.array([0.95, 1.05]) + trial_outcome_df = _trial_outcome_df(np.array([0.1, 1.0]), autos=[None, True]) + + annotations = get_annotated_rewards(reward_times, trial_outcome_df, np.array([])) + + np.testing.assert_array_equal(annotations, np.array(["auto", "auto"])) def test_get_annotated_rewards_marks_manual_water_as_manual(): @@ -67,7 +79,7 @@ def test_get_annotated_rewards_marks_manual_water_as_manual(): np.testing.assert_array_equal(annotations, np.array(["earned", "manual", "earned"])) -def test_get_annotated_rewards_manual_takes_precedence_over_automatic(): +def test_get_annotated_rewards_manual_takes_precedence_over_auto(): """A manual delivery is ``manual`` even when the trial has auto-response set.""" reward_times = np.array([0.15, 0.42]) trial_outcome_df = _trial_outcome_df(np.array([0.1, 0.4]), autos=[None, True]) @@ -99,7 +111,7 @@ def test_get_annotated_rewards_accepts_json_and_model_payloads(): annotations = get_annotated_rewards(reward_times, trial_outcome_df, np.array([])) - np.testing.assert_array_equal(annotations, np.array(["automatic", "automatic"])) + np.testing.assert_array_equal(annotations, np.array(["auto", "auto"])) def test_get_annotated_rewards_returns_ndarray(): From 0bdf4c45b714535cfbc3dfb59da113c34903e900 Mon Sep 17 00:00:00 2001 From: arjunsridhar12345 <109561860+arjunsridhar12345@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:19:11 -0700 Subject: [PATCH 3/4] fix: rewarded history should be false for all auto reward trials (#69) * fix: set rewarded history to False for auto reward trials * test: update tests --- .../processing/_trial_table.py | 39 ++++++++++++++----- .../processing/models/trial_config.py | 10 ++++- tests/test_processing/test_trial_table.py | 24 ++++++++++++ 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/dynamic_foraging_processing/processing/_trial_table.py b/src/dynamic_foraging_processing/processing/_trial_table.py index 8158162..fd6a892 100644 --- a/src/dynamic_foraging_processing/processing/_trial_table.py +++ b/src/dynamic_foraging_processing/processing/_trial_table.py @@ -339,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 @@ -358,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) @@ -862,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, diff --git a/src/dynamic_foraging_processing/processing/models/trial_config.py b/src/dynamic_foraging_processing/processing/models/trial_config.py index f34a57a..46af23f 100644 --- a/src/dynamic_foraging_processing/processing/models/trial_config.py +++ b/src/dynamic_foraging_processing/processing/models/trial_config.py @@ -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, diff --git a/tests/test_processing/test_trial_table.py b/tests/test_processing/test_trial_table.py index ebadf13..90de39a 100644 --- a/tests/test_processing/test_trial_table.py +++ b/tests/test_processing/test_trial_table.py @@ -662,6 +662,30 @@ def test_is_baited_forfeited_by_auto_response_on_same_side(): assert TrialTableBuilder._is_baited(trial, is_right=True) is False +def test_rewarded_history_is_earned_reward_only(): + """Rewarded history is the choice side on earned trials and False otherwise.""" + earned = TrialOutcome.model_validate( + _outcome(1.0, 1.0, is_right_choice=True, is_rewarded=True, auto=None) + ).trial + assert TrialTableBuilder._rewarded_history(earned, True, True, is_right=True) is True + assert TrialTableBuilder._rewarded_history(earned, True, True, is_right=False) is False + # An unrewarded trial is False on both sides. + assert TrialTableBuilder._rewarded_history(earned, False, True, is_right=True) is False + # An ignored trial (no choice) is False on both sides. + assert TrialTableBuilder._rewarded_history(earned, True, None, is_right=True) is False + assert TrialTableBuilder._rewarded_history(earned, True, None, is_right=False) is False + + +def test_rewarded_history_false_on_every_auto_reward_trial(): + """Autowater is not earned: an auto-reward trial is False on both sides.""" + for auto in (True, False): + trial = TrialOutcome.model_validate( + _outcome(1.0, 1.0, is_right_choice=auto, is_rewarded=True, auto=auto) + ).trial + assert TrialTableBuilder._rewarded_history(trial, True, auto, is_right=True) is False + assert TrialTableBuilder._rewarded_history(trial, True, auto, is_right=False) is False + + def test_auto_water_encodes_side_from_auto_response(): """A non-null auto response encodes ``1`` on its side and ``0`` on the other.""" trial = TrialOutcome.model_validate( From 2ff007c7ba9d20b1971b39041865fe99776b66cc Mon Sep 17 00:00:00 2001 From: arjunsridhar12345 <109561860+arjunsridhar12345@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:21:58 -0700 Subject: [PATCH 4/4] fix: min reward each block is nan should be 0 (#71) * fix: set min reward to 0 by default * test: update tests * docs: update changelog --- docs/trials_table_mapping.md | 8 +++++--- .../processing/_trial_table.py | 6 +++--- .../processing/models/trial_config.py | 7 +++++-- tests/test_processing/test_trial_table.py | 6 ++++-- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/trials_table_mapping.md b/docs/trials_table_mapping.md index acce921..c69f3db 100644 --- a/docs/trials_table_mapping.md +++ b/docs/trials_table_mapping.md @@ -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) @@ -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`). | diff --git a/src/dynamic_foraging_processing/processing/_trial_table.py b/src/dynamic_foraging_processing/processing/_trial_table.py index fd6a892..724828b 100644 --- a/src/dynamic_foraging_processing/processing/_trial_table.py +++ b/src/dynamic_foraging_processing/processing/_trial_table.py @@ -658,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]: diff --git a/src/dynamic_foraging_processing/processing/models/trial_config.py b/src/dynamic_foraging_processing/processing/models/trial_config.py index 46af23f..22e0e96 100644 --- a/src/dynamic_foraging_processing/processing/models/trial_config.py +++ b/src/dynamic_foraging_processing/processing/models/trial_config.py @@ -139,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 --- diff --git a/tests/test_processing/test_trial_table.py b/tests/test_processing/test_trial_table.py index 90de39a..b213ba1 100644 --- a/tests/test_processing/test_trial_table.py +++ b/tests/test_processing/test_trial_table.py @@ -365,7 +365,8 @@ def test_build_full_dataset(): # Scalar has neither a scale nor truncation parameters -> null bounds. assert pd.isna(first["delay_min"]) assert pd.isna(first["delay_max"]) - assert pd.isna(first["min_reward_each_block"]) # removed from generator schema + # No per-block reward minimum on this generator -> a floor of 0, not null. + assert first["min_reward_each_block"] == 0 assert first["base_reward_probability_sum"] == pytest.approx(0.8) # Lickspout positions from AccumulatedSteps (microsteps * 0.00125 mm), @@ -563,7 +564,8 @@ def test_session_columns_uncoupled_has_null_reward_sum(): assert "ITI_beta" in columns # Coupled-only fields are absent / null for an uncoupled generator. assert columns["base_reward_probability_sum"] is None - assert "min_reward_each_block" not in columns + # No ``min_block_reward`` on this generator -> no per-block minimum (0). + assert columns["min_reward_each_block"] == 0 # --------------------------------------------------------------------------- #