diff --git a/docs/trials_table_mapping.md b/docs/trials_table_mapping.md index f1725e7..23ea0e9 100644 --- a/docs/trials_table_mapping.md +++ b/docs/trials_table_mapping.md @@ -75,7 +75,7 @@ Columns are grouped by the raw source they map from. | Trials column | Source field | | --- | --- | | `ITI_beta`, `ITI_min`, `ITI_max`, `ITI_duration` | `inter_trial_interval_duration` | -| `block_beta`, `block_duration`, `block_min`, `block_max` | `block_length` | +| `block_beta`, `block_duration`, `block_min`, `block_max` | `block_length`. `block_max` is one below the configured maximum, which accounts for the floor applied upstream. | | `delay_beta`, `delay_duration`, `delay_min`, `delay_max` | `quiescent_duration_key` (scalar distribution, so no beta/min/max) | ### From `TrialMetrics.json` (`SoftwareEvents` stream) @@ -100,7 +100,7 @@ Columns are grouped by the raw source they map from. | `auto_waterL` / `auto_waterR` | **Scheduled autowater only**: `1` when `trial.metadata.extra.is_autowater` is `True` **and** `is_auto_reward_right` points to that side. `0` otherwise, including when the trial's free water came from the anti-bias algorithm — that is reported by `anti_bias_left_water` / `anti_bias_right_water`. `is_auto_reward_right` is only the delivery *channel* (free water fired, and to which side); the mechanism comes from the metadata, so the two columns are mutually exclusive. Not gated on `is_rewarded`: the column records what the task did, and free water fires at the go cue regardless of how the animal's own choice resolves. Note this is narrower than the legacy `dynamic-foraging-task` column of the same name, which was the ungated channel ("Autowater given at Left", straight from `B_AutoWaterTrial`) and predates anti-bias water. | | `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 scheduled autowater, so the `is_bias_water_intervention` flag is what distinguishes it and the two columns are mutually exclusive. `False` otherwise. Like `auto_water*`, **not** gated on `is_rewarded`: these columns record what the algorithm did, and the intervention fires at the go cue regardless of how the animal's own choice resolves. The reward-delivery series *is* reward-keyed, so this column can exceed the series' `auto` count. | | `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`. | +| `bait_left` / `bait_right` | Boolean, read straight from `trial.metadata.extra.is_left_baited` / `is_right_baited` — the bait state the acquisition software reports for each port. `False` when the trial carries no extra metadata. | | `response_duration` | `response_deadline_duration`. | | `reward_consumption_duration` | `Trial -> reward_consumption_duration`. | | `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. | @@ -150,14 +150,14 @@ durations track the configured ones (reward consumption ≈ | `reward_consumption_start_time` | `RewardConsumptionPeriod` `timestamp`. | | `reward_consumption_stop_time` | `ItiPeriod` `timestamp`. | | `ITI_start_time` | `ItiPeriod` `timestamp`. | -| `ITI_stop_time` | The **next** trial's `QuiescentPeriod` `timestamp`; `NaN` on the last trial of the session. | +| `ITI_stop_time` | The **next** trial's `QuiescentPeriod` `timestamp`. The last trial has no following quiescent period, so it takes the `EndSession` `timestamp`; `NaN` if that stream is unavailable. | | `delay_start_time` | `QuiescentPeriod` `timestamp` — the legacy name for `quiescent_start_time` (see the note below). | There are no `start_time` / `stop_time` trial columns. NWB's `TimeIntervals` requires a native `start_time` / `stop_time` per trial, so the pipeline derives the trial extent when writing: `start_time` is `quiescent_start_time` and -`stop_time` is `ITI_stop_time`, falling back to `ITI_start_time` on the last -trial. +`stop_time` is `ITI_stop_time` — which on the last trial is the `EndSession` +timestamp. > **`delay` means `quiescent`.** The legacy `delay_*` columns describe the > acquisition software's *quiescence period* — the lick-free interval preceding @@ -231,3 +231,5 @@ These were mapped during exploration but are no longer in scope: | 2026-08-17 | The reward-delivery series is now reward-keyed: a delivery whose matched trial reports `is_rewarded=False` is dropped rather than annotated (manual water exempt), so the retained count equals the metadata mapper's `sum(is_rewarded)`. Free water fires at the go cue and the trial then continues normally, so `is_rewarded` describes the animal's own choice, not the free water — meaning free water on an unrewarded trial is real water the animal received and is excluded from this series. The trials table's `auto_water*` / `anti_bias_*` columns remain ungated and still record it. | | 2026-08-17 | `auto_waterL` / `auto_waterR` now read `trial.metadata.extra.is_autowater` rather than the `is_auto_reward_right` channel, making them **scheduled autowater only** and mutually exclusive with `anti_bias_left_water` / `anti_bias_right_water`. `is_auto_reward_right` says free water fired and on which side but not what kind; the mechanism is in the metadata. Neither column is gated on `is_rewarded`, since both record what the task did. This is narrower than the legacy `dynamic-foraging-task` column of the same name, which was the ungated channel and predates anti-bias water. | | 2026-08-17 | The reward-delivery labels stay `earned` / `auto` / `manual`: free water is `auto` whatever mechanism produced it, so the series does not split scheduled autowater from anti-bias water. That split lives in the trials table. Consequence: the series' `auto` count tracks the channel while `auto_waterL` / `auto_waterR` track `is_autowater`, so the two are not expected to be equal. | +| 2026-08-20 | `block_max` is now one below `block_length`'s configured maximum, which accounts for the floor applied upstream: a block is a whole number of trials, so the configured bound is never itself reachable. `block_min`, `block_beta`, and the `ITI_*` / `delay_*` bounds are unchanged — those durations are continuous and take no such adjustment. | +| 2026-08-20 | `bait_left` / `bait_right` now read `trial.metadata.extra.is_left_baited` / `is_right_baited` from the acquisition software instead of being re-derived from `p_reward_left` / `p_reward_right` and the `is_auto_reward_right` channel. The software is the authority on bait state, so the two can disagree — notably a port with `p_reward == 1` is no longer assumed baited. `False` when the trial carries no extra metadata. | diff --git a/pyproject.toml b/pyproject.toml index 4b27249..456a77f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ readme = "README.md" version = "0.3.2" dependencies = [ - "aind-behavior-dynamic-foraging[data] @ git+https://github.com/AllenNeuralDynamics/Aind.Behavior.DynamicForaging.git@v0.0.2rc36", + "aind-behavior-dynamic-foraging[data] @ git+https://github.com/AllenNeuralDynamics/Aind.Behavior.DynamicForaging.git@10cec2d64f0770405d5fcee40ec01a88d288a6b0", "ipykernel", ] diff --git a/src/dynamic_foraging_processing/pipeline/_pipeline.py b/src/dynamic_foraging_processing/pipeline/_pipeline.py index 2a08af9..6c6f013 100644 --- a/src/dynamic_foraging_processing/pipeline/_pipeline.py +++ b/src/dynamic_foraging_processing/pipeline/_pipeline.py @@ -59,10 +59,12 @@ #: ``TimeIntervals`` requires both, so they are derived here. _NWB_START_COLUMN = "quiescent_start_time" -#: Columns NWB's required native ``stop_time`` is taken from, in order of -#: preference: the end of the ITI, falling back to its start on the last trial -#: of the session (where the ITI end is unknown). -_NWB_STOP_COLUMNS = ("ITI_stop_time", "ITI_start_time") +#: Trials-table column NWB's required native ``stop_time`` is taken from: the +#: end of the ITI, which on the last trial of the session is the ``EndSession`` +#: timestamp. It is ``NaN`` only when that stream is unavailable, and the ``NaN`` +#: is propagated rather than substituted, so an unknown trial end reads as +#: unknown instead of as a shortened trial. +_NWB_STOP_COLUMN = "ITI_stop_time" #: Source repository recorded in the ``processing.json`` data process. _CODE_URL = "https://github.com/AllenNeuralDynamics/dynamic-foraging-processing" @@ -236,8 +238,11 @@ def _trial_extent(row: pd.Series) -> t.Tuple[float, float]: The trials table has no trial start/stop columns of its own, so the trial's extent is taken from its period bounds: it starts with the - quiescent period and ends with the ITI, falling back to the ITI start on - the last trial of the session (whose ITI end is unknown). + quiescent period and ends with the ITI. The last trial of the session has + no following quiescent period, so its ITI — and therefore its stop time — + ends at the ``EndSession`` timestamp. Should that be unavailable the stop + time is ``NaN``: an unknown end is reported as unknown rather than + substituted with an earlier landmark. Parameters ---------- @@ -247,11 +252,10 @@ def _trial_extent(row: pd.Series) -> t.Tuple[float, float]: Returns ------- tuple of (float, float) - The trial start and stop time (seconds). + The trial start and stop time (seconds); the stop time is ``NaN`` + where the ITI end is unknown. """ - stops = [row[column] for column in _NWB_STOP_COLUMNS if pd.notnull(row[column])] - stop = stops[0] if stops else np.nan - return float(row[_NWB_START_COLUMN]), float(stop) + return float(row[_NWB_START_COLUMN]), float(row[_NWB_STOP_COLUMN]) @classmethod def _add_trials(cls, nwb_file: pynwb.NWBFile, trials: pd.DataFrame) -> None: @@ -265,7 +269,7 @@ def _add_trials(cls, nwb_file: pynwb.NWBFile, trials: pd.DataFrame) -> None: (named ``id``) is replicated as each trial's NWB ``id``. An empty table (or one missing the period columns the extent is derived from) is skipped. """ - required = (_NWB_START_COLUMN, *_NWB_STOP_COLUMNS) + required = (_NWB_START_COLUMN, _NWB_STOP_COLUMN) if trials.empty or any(col not in trials.columns for col in required): return descriptions = TrialConfig.column_descriptions() diff --git a/src/dynamic_foraging_processing/processing/_trial_table.py b/src/dynamic_foraging_processing/processing/_trial_table.py index 52974d2..ca0b5b1 100644 --- a/src/dynamic_foraging_processing/processing/_trial_table.py +++ b/src/dynamic_foraging_processing/processing/_trial_table.py @@ -131,6 +131,28 @@ def _event_times(df: t.Optional[pd.DataFrame]) -> np.ndarray: return np.empty(0) return df.sort_index().index.to_numpy(dtype=float) + @classmethod + def _session_end_time(cls, end_session: t.Optional[pd.DataFrame]) -> float: + """Return the session's end timestamp from the ``EndSession`` stream. + + The stream carries a single event marking the end of the session; its + timestamp closes the last trial's ITI, which has no following + ``QuiescentPeriod`` event to end it. + + Parameters + ---------- + end_session : pandas.DataFrame or None + The ``EndSession`` software-event stream's data. + + Returns + ------- + float + The end-of-session timestamp, or ``NaN`` when the stream is absent + or empty. The last event is used if more than one is present. + """ + times = cls._event_times(end_session) + return float(times[-1]) if times.size else np.nan + @staticmethod def _time_at(times: np.ndarray, index: int) -> float: """Return ``times[index]``, or ``NaN`` when the stream is that much shorter. @@ -382,31 +404,19 @@ def _rewarded_history( return is_rewarded and (is_right_choice is is_right) @staticmethod - def _is_baited(trial: Trial, *, is_right: bool) -> bool: + def _is_baited(bias_metadata: BlockBasedTrialMetadata, *, is_right: bool) -> bool: """Return whether the requested lickport is baited on this trial. - A port is "baited" when reward is guaranteed there (its reward - probability is ``1``) *and* the trial was not auto-responded to *that - same* port. - - ``is_auto_reward_right`` encodes the auto-response: ``True`` means the - trial was auto-responded to the right, ``False`` to the left, and - ``None`` means there was no auto-response. - - In plain English, for the right port the conditions are: - - * ``p_reward_right == 1`` — reward is certain on the right, and - * the trial was *not* auto-responded to the right - (``is_auto_reward_right`` is ``None`` or ``False``). - - The left port is the mirror image (``p_reward_left == 1`` and not - auto-responded to the left, i.e. ``is_auto_reward_right`` is ``None`` - or ``True``). + The bait state is reported directly by the acquisition software as + ``is_left_baited`` / ``is_right_baited`` on the trial's extra metadata, + so it is read rather than re-derived from the reward probability and the + auto-response channel. A trial whose metadata does not carry the flags + falls back to the model's ``False`` default (see ``_bias_metadata``). 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. @@ -414,33 +424,10 @@ def _is_baited(trial: Trial, *, is_right: bool) -> bool: ------- bool Whether the requested side is baited. - - Examples - -------- - Right port guaranteed reward, no auto-response → baited: - - >>> trial = Trial(p_reward_right=1, p_reward_left=0, is_auto_reward_right=None) - >>> TrialTableBuilder._is_baited(trial, is_right=True) - True - - Same trial, but auto-responded to the right collects (forfeits) the bait: - - >>> trial = Trial(p_reward_right=1, p_reward_left=0, is_auto_reward_right=True) - >>> TrialTableBuilder._is_baited(trial, is_right=True) - False - - Left port without guaranteed reward → not baited: - - >>> trial = Trial(p_reward_right=1, p_reward_left=0, is_auto_reward_right=None) - >>> TrialTableBuilder._is_baited(trial, is_right=False) - False """ - auto = trial.is_auto_reward_right if is_right: - # Right stays baited unless the animal was auto-responded right. - return trial.p_reward_right == 1 and auto in (None, False) - # Left stays baited unless the animal was auto-responded left. - return trial.p_reward_left == 1 and auto in (None, True) + return bias_metadata.is_right_baited + return bias_metadata.is_left_baited @staticmethod def _auto_water(trial: Trial, bias_metadata: BlockBasedTrialMetadata, *, is_right: bool) -> int: @@ -657,6 +644,9 @@ def _session_columns(self, task_logic: AindDynamicForagingTaskLogic) -> t.Dict[s return columns block_beta, block_min, block_max = self._distribution_stats(generator.block_length) + # Account for the floor applied upstream. + if block_max is not None: + block_max -= 1 iti_beta, iti_min, iti_max = self._distribution_stats( generator.inter_trial_interval_duration ) @@ -832,6 +822,7 @@ def _trial_periods( response_period_times: np.ndarray, consumption_times: np.ndarray, iti_times: np.ndarray, + session_end_time: float = np.nan, ) -> t.Dict[str, float]: """Return the start and stop time of every task period for one trial. @@ -839,7 +830,7 @@ def _trial_periods( back-to-back — quiescent, response, reward consumption, ITI, then the next trial's quiescent — so every period's stop time is the following period's start time. The last trial's ITI has no following quiescent - event, so its stop time is ``NaN``. + event, so it is closed by ``session_end_time`` instead. Parameters ---------- @@ -848,6 +839,10 @@ def _trial_periods( quiescent_times, response_period_times, consumption_times, iti_times : numpy.ndarray Per-trial timestamps of the ``QuiescentPeriod``, ``ResponsePeriod``, ``RewardConsumptionPeriod``, and ``ItiPeriod`` streams. + session_end_time : float, optional + Fallback ``ITI_stop_time`` for a trial with no following + ``QuiescentPeriod`` event — the ``EndSession`` timestamp, passed only + for the last trial. Defaults to ``NaN``, leaving the stop time unset. Returns ------- @@ -858,6 +853,9 @@ def _trial_periods( response_start = cls._time_at(response_period_times, index) consumption_start = cls._time_at(consumption_times, index) iti_start = cls._time_at(iti_times, index) + iti_stop = cls._time_at(quiescent_times, index + 1) + if np.isnan(iti_stop): + iti_stop = session_end_time return { "quiescent_start_time": cls._time_at(quiescent_times, index), "quiescent_stop_time": response_start, @@ -866,7 +864,7 @@ def _trial_periods( "reward_consumption_start_time": consumption_start, "reward_consumption_stop_time": iti_start, "ITI_start_time": iti_start, - "ITI_stop_time": cls._time_at(quiescent_times, index + 1), + "ITI_stop_time": iti_stop, } def _build_row( @@ -908,8 +906,8 @@ def _build_row( 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, - bait_left=self._is_baited(trial, is_right=False), - bait_right=self._is_baited(trial, is_right=True), + bait_left=self._is_baited(bias_metadata, is_right=False), + bait_right=self._is_baited(bias_metadata, is_right=True), reward_probabilityL=self._block_reward_probability(trial, is_right=False), reward_probabilityR=self._block_reward_probability(trial, is_right=True), reward_size_left=trial.reward_size.left, @@ -977,7 +975,9 @@ def build(self) -> pd.DataFrame: rather than silently misaligned rows. Each period event marks the start of its period, so the periods' stop - times come from the next event in sequence (see ``_trial_periods``). + times come from the next event in sequence (see ``_trial_periods``). The + last trial's ITI has no following event, so it is closed by the + ``EndSession`` timestamp. Hardware streams are handled per their nature: the go cue is an event each trial selects within its ``[quiescent_start_time, ITI_start_time)`` @@ -1003,6 +1003,7 @@ def build(self) -> pd.DataFrame: iti = self._load("Behavior", "SoftwareEvents", "ItiPeriod") responses = self._load("Behavior", "SoftwareEvents", "Response") metrics = self._load("Behavior", "SoftwareEvents", "TrialMetrics") + end_session = self._load("Behavior", "SoftwareEvents", "EndSession") pulse_supply_left = self._load("Behavior", "HarpBehavior", "PulseSupplyPort0") pulse_supply_right = self._load("Behavior", "HarpBehavior", "PulseSupplyPort1") @@ -1020,6 +1021,9 @@ def build(self) -> pd.DataFrame: response_payloads = self._event_payloads(responses) metric_payloads = self._event_payloads(metrics) + # Closes the last trial's ITI, which has no following QuiescentPeriod. + session_end_time = self._session_end_time(end_session) + # Guard the positional alignment before we pair streams by index. n_trials = len(outcome_payloads) @@ -1072,6 +1076,9 @@ def build(self) -> pd.DataFrame: response_period_times=response_period_times, consumption_times=consumption_times, iti_times=iti_times, + # Only the last trial's ITI is closed by the session end; an + # earlier gap means a short stream, which _check_aligned reports. + session_end_time=session_end_time if i == n_trials - 1 else np.nan, ) response = response_payloads[i] if i < len(response_payloads) else None side_bias = self._side_bias(metric_payloads[i] if i < len(metric_payloads) else None) diff --git a/src/dynamic_foraging_processing/processing/models/trial_config.py b/src/dynamic_foraging_processing/processing/models/trial_config.py index 94b0f47..20ef2ab 100644 --- a/src/dynamic_foraging_processing/processing/models/trial_config.py +++ b/src/dynamic_foraging_processing/processing/models/trial_config.py @@ -58,7 +58,7 @@ class TrialConfig(BaseModel): ) ITI_stop_time: float = Field( description=( - "End time of the inter-trial interval, i.e. the start of the next trial's quiescent period (the following QuiescentPeriod timestamp); NaN on the last trial of the session." + "End time of the inter-trial interval, i.e. the start of the next trial's quiescent period (the following QuiescentPeriod timestamp). The last trial of the session has no following quiescent period, so it ends at the EndSession timestamp; NaN if that stream is unavailable." ), ) @@ -137,7 +137,10 @@ class TrialConfig(BaseModel): default=None, description="The minimum length allowed for each block" ) block_max: Optional[float] = Field( - default=None, description="The maximum length allowed for each block" + default=None, + description=( + "The maximum length allowed for each block; one below the configured maximum, which accounts for the floor applied upstream" + ), ) min_reward_each_block: float = Field( default=0, diff --git a/tests/test_pipeline/test_pipeline.py b/tests/test_pipeline/test_pipeline.py index c897ea7..9e777d4 100644 --- a/tests/test_pipeline/test_pipeline.py +++ b/tests/test_pipeline/test_pipeline.py @@ -37,8 +37,8 @@ def _make_pipeline() -> Pipeline: def _trials_frame() -> pd.DataFrame: """Two-trial table with the period columns plus one modeled, one unmodeled column. - The second trial's ``ITI_stop_time`` is ``NaN``, as it is for the last trial - of a session. + The second trial's ``ITI_stop_time`` is ``NaN``, as it is on the last trial + of a session whose ``EndSession`` stream was unavailable. """ return pd.DataFrame( { @@ -296,8 +296,8 @@ def test_add_trials_populates_columns_and_rows(): def test_add_trials_derives_native_start_and_stop_from_periods(): """NWB's native trial extent spans the quiescent start to the ITI end. - The last trial has no ITI end (no following quiescent period), so its stop - time falls back to the ITI start. + An unknown ITI end (no following quiescent period and no ``EndSession`` + timestamp) stays ``NaN`` rather than falling back to an earlier landmark. """ nwb_file = MagicMock() @@ -307,7 +307,8 @@ def test_add_trials_derives_native_start_and_stop_from_periods(): (call.kwargs["start_time"], call.kwargs["stop_time"]) for call in nwb_file.add_trial.call_args_list ] - assert extents == [(0.0, 1.0), (1.0, 1.4)] + assert extents[0] == (0.0, 1.0) + assert extents[1][0] == 1.0 and np.isnan(extents[1][1]) def test_add_trials_skips_frame_without_period_columns(): diff --git a/tests/test_processing/test_trial_table.py b/tests/test_processing/test_trial_table.py index f66c0da..fd6aa73 100644 --- a/tests/test_processing/test_trial_table.py +++ b/tests/test_processing/test_trial_table.py @@ -99,6 +99,8 @@ def _outcome( is_autowater=None, is_bias_water_intervention=None, is_bias_stage_intervention=None, + is_left_baited=None, + is_right_baited=None, ): """Build a serialized ``TrialOutcome`` payload (dict, as delivered by the reader). @@ -110,6 +112,8 @@ def _outcome( sets the per-trial horizontal spout displacement (mm), and ``is_autowater`` plus the ``is_bias_*_intervention`` flags populate the ``metadata.extra`` (``BlockBasedTrialMetadata``) block naming the free-water mechanism. + ``is_left_baited`` / ``is_right_baited`` set the per-side bait flags in that + same block (the source of the ``bait_*`` columns). """ trial = { "p_reward_left": p_left, @@ -133,12 +137,16 @@ def _outcome( is_autowater is not None or is_bias_water_intervention is not None or is_bias_stage_intervention is not None + or is_left_baited is not None + or is_right_baited is not None ): metadata = trial.setdefault("metadata", {}) metadata["extra"] = { "is_autowater": bool(is_autowater), "is_bias_water_intervention": bool(is_bias_water_intervention), "is_bias_stage_intervention": bool(is_bias_stage_intervention), + "is_left_baited": bool(is_left_baited), + "is_right_baited": bool(is_right_baited), } return { "trial": trial, @@ -244,6 +252,7 @@ def _full_dataset(): block_p_right=0.1, reward_size_left=2.0, reward_size_right=4.0, + is_left_baited=True, ), _outcome(0.5, 0.5, is_right_choice=None, is_rewarded=False), ], @@ -261,6 +270,8 @@ def _full_dataset(): ) ), "TrialMetrics": _Stream(_events([10.2, 20.2], [{"bias": 0.3}, {"bias": None}])), + # Closes the last trial's ITI, which has no following QuiescentPeriod. + "EndSession": _Stream(_events([30.0], [None])), } ) behavior = _Node( @@ -323,13 +334,14 @@ def test_build_full_dataset(): first, second = table.iloc[0], table.iloc[1] # Period bounds: each period ends where the next one starts, and the ITI - # ends at the next trial's quiescent period (NaN on the last trial). + # ends at the next trial's quiescent period — or, on the last trial, at the + # EndSession timestamp. assert first["quiescent_start_time"] == 10.0 and first["quiescent_stop_time"] == 11.0 assert first["response_start_time"] == 11.0 and first["response_stop_time"] == 12.0 assert first["reward_consumption_start_time"] == 12.0 assert first["reward_consumption_stop_time"] == 15.0 assert first["ITI_start_time"] == 15.0 and first["ITI_stop_time"] == 20.0 - assert second["ITI_start_time"] == 25.0 and np.isnan(second["ITI_stop_time"]) + assert second["ITI_start_time"] == 25.0 and second["ITI_stop_time"] == 30.0 # delay_start_time is the legacy name for the quiescent period start. assert first["delay_start_time"] == first["quiescent_start_time"] == 10.0 @@ -351,9 +363,12 @@ def test_build_full_dataset(): assert bool(second["rewarded_historyL"]) is False assert bool(second["rewarded_historyR"]) is False - # Bait derived from the per-trial p_reward and auto-response. + # Bait read from the acquisition metadata's per-side flags. assert bool(first["bait_left"]) is True assert bool(first["bait_right"]) is False + # The second trial carries no extra metadata -> not baited on either side. + assert bool(second["bait_left"]) is False + assert bool(second["bait_right"]) is False # reward_probability columns are the block probability from trial.metadata, # not the top-level per-trial p_reward (1.0 / 0.2 here). @@ -367,6 +382,9 @@ def test_build_full_dataset(): assert first["ITI_beta"] == pytest.approx(5.0) assert first["ITI_min"] == 1.0 and first["ITI_max"] == 10.0 assert first["block_beta"] == pytest.approx(20.0) + # Only block_max takes the floor adjustment: the configured 20/60 truncation + # yields a longest realizable block of 59 trials. + assert first["block_min"] == 20.0 and first["block_max"] == 59.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"]) @@ -479,6 +497,46 @@ def test_build_raises_on_misaligned_streams_when_configured(): TrialTableBuilder(_misaligned_dataset(), raise_on_error=True).build() +# --------------------------------------------------------------------------- # +# ITI_stop_time on the last trial — the EndSession timestamp +# --------------------------------------------------------------------------- # +def test_build_last_iti_stop_is_nan_without_end_session(): + """Without an ``EndSession`` stream the last trial's ITI end stays unknown.""" + dataset = _full_dataset() + del dataset.children["Behavior"].children["SoftwareEvents"].children["EndSession"] + + table = TrialTableBuilder(dataset).build() + + # Earlier trials are unaffected; only the last one lacks a closing event. + assert table.iloc[0]["ITI_stop_time"] == 20.0 + assert np.isnan(table.iloc[1]["ITI_stop_time"]) + + +def test_build_last_iti_stop_is_nan_when_end_session_is_empty(): + """An ``EndSession`` stream carrying no events leaves the last ITI end unknown.""" + dataset = _full_dataset() + dataset.children["Behavior"].children["SoftwareEvents"].children["EndSession"] = _Stream( + _events([], []) + ) + + table = TrialTableBuilder(dataset).build() + + assert np.isnan(table.iloc[1]["ITI_stop_time"]) + + +def test_build_end_session_does_not_close_a_mid_session_gap(): + """Only the last trial falls back to ``EndSession``. + + A short ``QuiescentPeriod`` stream leaves an earlier trial without a closing + event too, but attributing the session end to it would invent a trial + spanning the rest of the session, so it stays ``NaN``. + """ + table = TrialTableBuilder(_misaligned_dataset()).build() + + assert np.isnan(table.iloc[0]["ITI_stop_time"]) + assert table.iloc[1]["ITI_stop_time"] == 30.0 + + # --------------------------------------------------------------------------- # # _summary_generator — composite trial generators # --------------------------------------------------------------------------- # @@ -661,13 +719,33 @@ def test_animal_response_encoding(): assert TrialTableBuilder._animal_response({"Item1": 1.0}) == 2 -def test_is_baited_forfeited_by_auto_response_on_same_side(): - """A side with guaranteed reward stays baited unless auto-responded to that side.""" +def test_is_baited_reads_the_per_side_metadata_flags(): + """Bait comes from the metadata flags, independent of p_reward and auto-response.""" + trial = TrialOutcome.model_validate( + _outcome( + 0.0, + 1.0, + is_right_choice=True, + is_rewarded=True, + auto=True, + is_left_baited=True, + is_right_baited=False, + ) + ).trial + metadata = TrialTableBuilder._bias_metadata(trial) + # Right has p_reward 1 but the software reports it unbaited; left is the mirror. + assert TrialTableBuilder._is_baited(metadata, is_right=True) is False + assert TrialTableBuilder._is_baited(metadata, is_right=False) is True + + +def test_is_baited_defaults_to_false_without_metadata(): + """A trial carrying no extra metadata is reported as unbaited on both sides.""" trial = TrialOutcome.model_validate( - _outcome(0.0, 1.0, is_right_choice=True, is_rewarded=True, auto=True) + _outcome(1.0, 1.0, is_right_choice=True, is_rewarded=True) ).trial - # Right is guaranteed (p=1) but auto-responded right -> bait collected. - assert TrialTableBuilder._is_baited(trial, is_right=True) is False + metadata = TrialTableBuilder._bias_metadata(trial) + assert TrialTableBuilder._is_baited(metadata, is_right=True) is False + assert TrialTableBuilder._is_baited(metadata, is_right=False) is False def test_rewarded_history_is_earned_reward_only(): diff --git a/uv.lock b/uv.lock index f29d9cf..2a9e29a 100644 --- a/uv.lock +++ b/uv.lock @@ -39,7 +39,7 @@ wheels = [ [[package]] name = "aind-behavior-dynamic-foraging" version = "0.0.2rc36" -source = { git = "https://github.com/AllenNeuralDynamics/Aind.Behavior.DynamicForaging.git?rev=v0.0.2rc36#1eb944f79bfe87e3c4b6cf6f5c1b5b7aa17a5f40" } +source = { git = "https://github.com/AllenNeuralDynamics/Aind.Behavior.DynamicForaging.git?rev=10cec2d64f0770405d5fcee40ec01a88d288a6b0#10cec2d64f0770405d5fcee40ec01a88d288a6b0" } dependencies = [ { name = "aind-behavior-services" }, { name = "contraqctor" }, @@ -283,11 +283,11 @@ wheels = [ [[package]] name = "appnope" -version = "0.1.4" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/f7/a82489c2b6ebe32d3e2831895ae19c77861f0eadf3bb16034484d965dbb2/appnope-1.0.0.tar.gz", hash = "sha256:685db59cb6043c3c2e528adc0b3bce3a5f8d09bcf7492c6ea650d1b7421f3c49", size = 5454, upload-time = "2026-08-20T21:36:13.748Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, + { url = "https://files.pythonhosted.org/packages/46/c7/6b687cb0f83d2a51017d47953f2ee430ffad6fec2a8ebc964e0718a33eb1/appnope-1.0.0-py3-none-any.whl", hash = "sha256:6fe0c04218aab65c54c4ff81638cdbf848d89f5653b74d68638a137f200dd16e", size = 4158, upload-time = "2026-08-20T21:36:12.444Z" }, ] [[package]] @@ -364,7 +364,7 @@ name = "cffi" version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser" }, + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ @@ -884,7 +884,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "aind-behavior-dynamic-foraging", extras = ["data"], git = "https://github.com/AllenNeuralDynamics/Aind.Behavior.DynamicForaging.git?rev=v0.0.2rc36" }, + { name = "aind-behavior-dynamic-foraging", extras = ["data"], git = "https://github.com/AllenNeuralDynamics/Aind.Behavior.DynamicForaging.git?rev=10cec2d64f0770405d5fcee40ec01a88d288a6b0" }, { name = "aind-data-schema", marker = "extra == 'qc'", specifier = ">=2.4.1" }, { name = "aind-nwb-utils", marker = "extra == 'nwb'" }, { name = "dynamic-foraging-processing", extras = ["qc", "nwb"], marker = "extra == 'full'" }, @@ -1736,11 +1736,11 @@ wheels = [ [[package]] name = "narwhals" -version = "2.24.0" +version = "2.25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/1d/58946e5aab18393e793bd4add6985b95d0e01c3a2d832f38f54468b10dcd/narwhals-2.24.0.tar.gz", hash = "sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d", size = 661143, upload-time = "2026-07-13T10:49:19.086Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/7b/6248dada39781db1ab3ebf08943080df0796098515a87f6f8696d14ec744/narwhals-2.25.0.tar.gz", hash = "sha256:62c036c810662bf7820b7737077176313bc59350eeeefb808510f388c743e4b2", size = 677076, upload-time = "2026-08-20T18:10:15.454Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl", hash = "sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489", size = 461030, upload-time = "2026-07-13T10:49:17.571Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dc/55481808fd70ef1567cf13540ffd4702af3f74b112e35427564b03f79c2d/narwhals-2.25.0-py3-none-any.whl", hash = "sha256:1f0f403e8c7e4463cde9bfe78b12fdd809e3ae3dda6d9b2f802934fb9c7a6a8f", size = 467373, upload-time = "2026-08-20T18:10:13.834Z" }, ] [[package]] @@ -2426,45 +2426,50 @@ wheels = [ [[package]] name = "pyzmq" -version = "27.1.0" +version = "27.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "implementation_name == 'pypy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, - { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, - { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, - { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, - { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, - { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, - { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, - { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, - { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, - { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, - { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, - { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, - { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, - { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, - { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, - { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, - { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, - { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, - { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, - { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, - { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/e7/8d/5b3d5631c2f4b4b8862f64cd0c9eb777b5710eeb5125b4be8dd0a200a4c0/pyzmq-27.2.0.tar.gz", hash = "sha256:54d4259d1bfae24ecdb5ca79f7acc2eac6c286a02d6a0ae617797cb45f0726d3", size = 292316, upload-time = "2026-08-20T19:08:21.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/8a/153532fa53db30e116118164f3af269a1f3966b3e2ba32c89b12fe864bd8/pyzmq-27.2.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:591c8de5851c5ea372194469fe97587b97c3b641e9a70f31bb3474acbfde0241", size = 1431074, upload-time = "2026-08-20T19:06:40.601Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ef/c08b91248bb90a9efa81fa00ba81b69c157c74d0c5efbb2c319d91babb62/pyzmq-27.2.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:00e73942ef12cecbc7951c4a9104bb8ffaed742abb13af2da6833d90dd368cef", size = 973915, upload-time = "2026-08-20T19:06:42.037Z" }, + { url = "https://files.pythonhosted.org/packages/b4/78/a3a3a86c2b00fadb92ece1ca4f8f028d62b2ce9ac3526097239ab2d6fba9/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f8079d0521fe94bbb401fe9407578b28f3701627c8be2c9f7e0c5b77dcb0109", size = 697722, upload-time = "2026-08-20T19:06:43.325Z" }, + { url = "https://files.pythonhosted.org/packages/62/2c/d5828306f795e8d34676d266823b74e2101e0ad3760d12083de3e02abbb2/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dea74fd65f1fc5f7fe167916a473ebe6ed6174e5e5d9de11ea6583661be6cf43", size = 872258, upload-time = "2026-08-20T19:06:44.627Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/51253b78fd8739293e283407eeecb14215c02c71b6519af21f6eed8e69cd/pyzmq-27.2.0-cp312-abi3-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcc99ca132b667a4ed750afd42db4ea73288f18425a9b2e3c0af095665c491f5", size = 739591, upload-time = "2026-08-20T19:06:46.214Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3e/142c85b67a4c9678629b0cf6d5125b29663d75be69bfaa57a3cac344d780/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b8d5f66e4a8246cf77f7b8f7902af64f00553368fa0373c89d99b78f0ad79394", size = 1689031, upload-time = "2026-08-20T19:06:47.612Z" }, + { url = "https://files.pythonhosted.org/packages/0e/ee/0776fb0f98ed1eb74d77240087fef0ab045b6ad15cb09555c6c5134c98ad/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:d1526b42a2e725b84ed226f37becedc250c6347594e5ed304e4e9aff68c9aec3", size = 2059547, upload-time = "2026-08-20T19:06:49.064Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/ec77f691a4aebe29ab6329f996fb0e0270c876a3016086e3ca6ef733bcae/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f707bcf2c1d007d14d70531d4dd7b41060881c73efa845580bf6faaf9ea24d42", size = 1910457, upload-time = "2026-08-20T19:06:50.783Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/1f5530ff4fc271b4597048371d5af972c2baab51be132ba15874e0327a6a/pyzmq-27.2.0-cp312-abi3-win32.whl", hash = "sha256:fdaaa4ea3242f6ad298eb5177eb042aea5c73c30e76d20caee7b15af20d24ec2", size = 563450, upload-time = "2026-08-20T19:06:52.307Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b83f7780dad22e0878e4c7bd9158ebd24ed12bc3d5e3a471cd0576f77ded/pyzmq-27.2.0-cp312-abi3-win_amd64.whl", hash = "sha256:2c218c6ab8bc447ba62054b581fd30209689d199c6ecb253f79615ca74a38e12", size = 628633, upload-time = "2026-08-20T19:06:53.809Z" }, + { url = "https://files.pythonhosted.org/packages/52/aa/3918b5ac7f9987bd9c421b065074fd7409ded88f856f2c704a24341877ec/pyzmq-27.2.0-cp312-abi3-win_arm64.whl", hash = "sha256:348d6fd3e4b81ae4580622ea8c2ea60224e84b2ac1b3be4482e6edc7de06e7a3", size = 556006, upload-time = "2026-08-20T19:06:55.242Z" }, + { url = "https://files.pythonhosted.org/packages/83/5e/d0541596b48c5a19f85dcbea83d6673d8e91681cdf853eb194c31fc9766e/pyzmq-27.2.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:c551b9e2f86dc625fcb1a032c0d68042678caf96a8dd7c28796766b673bd5b52", size = 1127193, upload-time = "2026-08-20T19:06:56.545Z" }, + { url = "https://files.pythonhosted.org/packages/50/9f/8c7411bb283982d46e6d56dca6a095678c87eb0398daead12776d9881ac2/pyzmq-27.2.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:288cc790da0e3064a14a38ddc56ba169dada8c8af4cb86518db2bcbd380eedbb", size = 1166833, upload-time = "2026-08-20T19:06:58.011Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/a849161ff88b2de9b991cc8ab332218824741122fdc4fdf222a5b822ac8c/pyzmq-27.2.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:3d45189c0c3c99f817b7fefff0d32eeef684cf33e1e3c0fc4281515357c54702", size = 1134452, upload-time = "2026-08-20T19:06:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/3c/34/ff4aaff0cfba2a4d7ad1a16ffedc52c6deb89fcf673d455085446b23f215/pyzmq-27.2.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d61910b52be5b2cd8b248dbcbe3a1b0275556a7d99fb613fc43323b546e273b8", size = 1167520, upload-time = "2026-08-20T19:07:01.283Z" }, + { url = "https://files.pythonhosted.org/packages/b6/07/42111e9dc1041d78b4443d6eb1b82b027f1a58178dc8a38385effbc72ad5/pyzmq-27.2.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:3ab6eb88590e510ab16715c32dbba12000da9bee989fdadd9ee19a234c492eb7", size = 1466289, upload-time = "2026-08-20T19:07:02.738Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b4/def7a478458da78665840564161772e7e938600c32a89f28e8b221b54d2d/pyzmq-27.2.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1ecbdd131b9669f62d3a45afee5527c7ae9f141e4301267f21714c90bd21725f", size = 975868, upload-time = "2026-08-20T19:07:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/38/d5/e3e85f7fea37153097aaff49db9e33093909cc2a7b22c1ac4ebe546600fc/pyzmq-27.2.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3146385b94a760236c5eceff468a66a296a716ca98a2e0f9217b1518118466b1", size = 706054, upload-time = "2026-08-20T19:07:05.623Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ef/3b7d9449b223183222bf517245e1e53d5f1ab8c10be8b45f6a301b2f994a/pyzmq-27.2.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9846e881620dd62566ca76a53e384c3f37490faf4b9240aebc7498810dfca853", size = 878984, upload-time = "2026-08-20T19:07:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/be/a5/8b49dbd494f6dcfda69dc4cade322a4b02706ef4e3d30cc366d4e369899f/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d9527e3dbaef1edaeeb2446fa7379446814a43ade8adc7c4a5ebe69437815ddd", size = 1697489, upload-time = "2026-08-20T19:07:08.945Z" }, + { url = "https://files.pythonhosted.org/packages/da/5a/4bb8280901130c26ea25f0cbb4a6d39d94250860c6b3dbd912f1cf48fca7/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:56b48fa9d478a3af7254f397697a62f5ad3e1bb677e200b2701f0c290d97e5af", size = 2064236, upload-time = "2026-08-20T19:07:10.384Z" }, + { url = "https://files.pythonhosted.org/packages/de/38/f433af66922554adb2b5f79e897018c8e19a90b9eaeb49c4814f8355ebe4/pyzmq-27.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bf0b6e4ce1bb089751c504c5493d6b0557eabd02dd21b76e9086cf964234b103", size = 1917424, upload-time = "2026-08-20T19:07:11.909Z" }, + { url = "https://files.pythonhosted.org/packages/36/81/ea1c1ae3f801d96ba2c269e056761ebcfe023476e651d3af2a7817962051/pyzmq-27.2.0-cp314-cp314t-win32.whl", hash = "sha256:fba8afcf265c6e9fbe1594cb045d4765c6c9a7d607653a8196067ef23566b843", size = 591103, upload-time = "2026-08-20T19:07:13.451Z" }, + { url = "https://files.pythonhosted.org/packages/8a/04/149a627707e780fa9f2c1ede3590c14fa6b18b5576d15744342622299a50/pyzmq-27.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d1bc1d380a91d954ed5fc9f12915dba014eed0978d2de05ee7ca688bdaac144a", size = 670215, upload-time = "2026-08-20T19:07:15.069Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/f9c3c1536c41ef3dbf765ea04218990e2056e558f98184ecd883767fc501/pyzmq-27.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c7cfb75caa83f5153c687e9d2107f64b5ef0ef0d6edd260d3ff920baaaa69101", size = 582252, upload-time = "2026-08-20T19:07:16.582Z" }, + { url = "https://files.pythonhosted.org/packages/fa/00/78fe097a304a408275747ce43f20428789130b059c5649956277c20f30cf/pyzmq-27.2.0-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:c5129a8fe43ecc49b99eb75616603d483a3c2fcaef504988fafe8ea392aea98b", size = 1134295, upload-time = "2026-08-20T19:07:17.94Z" }, + { url = "https://files.pythonhosted.org/packages/f2/83/1c36270658d2ee56e23a3f9ef5fbcb94cbd2f9fe966a6641f2f38e697162/pyzmq-27.2.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:baa2ce3485145653194d6c8c5beedd1e9f0bf46a0919c9fa2fe2204fc35b74d9", size = 1167492, upload-time = "2026-08-20T19:07:19.476Z" }, + { url = "https://files.pythonhosted.org/packages/58/b2/f0ae223438d7faa991f6feefdc823815f11cc604f898738376b59fd96515/pyzmq-27.2.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:e1ed46048d1920cabc96d952a0d5cfe4127ad8db572c335aae4e3c57b9278d7f", size = 1465992, upload-time = "2026-08-20T19:07:20.941Z" }, + { url = "https://files.pythonhosted.org/packages/59/46/fb56f3f37a6a0937b0e1d2885e808b5eedc171320bac85573cfae78fa9bc/pyzmq-27.2.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e0fa0bc6b1a184aee59b32efcd1b7f0e6d5b8f9387799e4c16a4cb66a86747d6", size = 976118, upload-time = "2026-08-20T19:07:22.577Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/a2c9bfd7c4d34eea1278493cd041bc000d41acb4463c89ceaad29dc813b6/pyzmq-27.2.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4bd6743e8bf854c3bfce892dd6578a514aabf128e37a4b2eafcf01856f7e44", size = 705968, upload-time = "2026-08-20T19:07:24.019Z" }, + { url = "https://files.pythonhosted.org/packages/d6/12/b906b269116b6591dc15c0acc5d04c043957c8a531d336999731f4b1d899/pyzmq-27.2.0-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95369ed6626afcfe2ac89832fb1b917c077fbeb905fbbe5d918349ce0222b89b", size = 879011, upload-time = "2026-08-20T19:07:25.428Z" }, + { url = "https://files.pythonhosted.org/packages/12/13/f96359534bfb77651c15f1fbfc4bfdd7ec3489d23f434706d39598dd0dcd/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40124779c3a56ad5d91902df1ff89159cb414b6c1a0ee697abcc66cf5e6db62d", size = 1697496, upload-time = "2026-08-20T19:07:26.821Z" }, + { url = "https://files.pythonhosted.org/packages/21/b4/2c007ae5f2fe5eca86cbfbc874ed86b5135f2f7812615dfd78606d3c93f6/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:ec8a318dfc27c7d946651b3d9e8025d5734f30c168a822195601827207bac09b", size = 2064347, upload-time = "2026-08-20T19:07:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/9b/88/767af3a6630c15215f3a66700ec79598a375edd1fdc9d75a3ad522178c01/pyzmq-27.2.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:88c0fac061bac269076edeb3a209acefc96cd6167c239daf1c2b404ac48d7012", size = 1917360, upload-time = "2026-08-20T19:07:29.693Z" }, + { url = "https://files.pythonhosted.org/packages/35/c1/80dd2d20d6e57bc68e1dce1e84bf3e76c9577c1bf728199985c8b4ea0fd1/pyzmq-27.2.0-cp315-cp315t-win32.whl", hash = "sha256:ac126d48cf18aa955daabef43bf0009ff76ad4deee437d09ecf15388214b5beb", size = 591073, upload-time = "2026-08-20T19:07:31.341Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b5/33b781666f3f52ae834bc9c8e38f4f0483a826c5a91cccc993292007bf10/pyzmq-27.2.0-cp315-cp315t-win_amd64.whl", hash = "sha256:edce90a1e588ec63adbf612cc0ad582de4169cd216c7ae53c15f42a2ee902f35", size = 670701, upload-time = "2026-08-20T19:07:32.895Z" }, + { url = "https://files.pythonhosted.org/packages/6e/97/bc4f0edefb992df4fdebcf9f0cc40f631cd4ed277e1ed59ef2cd99a5c8c5/pyzmq-27.2.0-cp315-cp315t-win_arm64.whl", hash = "sha256:a843094b4d3d633bc3623e47a2ff50742d6af02bc1f7606aa2e67e971e21878d", size = 581985, upload-time = "2026-08-20T19:07:34.19Z" }, ] [[package]]