diff --git a/src/dynamic_foraging_processing/pipeline/_pipeline.py b/src/dynamic_foraging_processing/pipeline/_pipeline.py index dfa2d6f..022694a 100644 --- a/src/dynamic_foraging_processing/pipeline/_pipeline.py +++ b/src/dynamic_foraging_processing/pipeline/_pipeline.py @@ -63,6 +63,9 @@ #: Installed version of this package, recorded on the processing ``Code``. _PACKAGE_VERSION = version("dynamic-foraging-processing") +#: Default pipeline name linking the data process to the ``processing.json`` pipeline. +_PIPELINE_NAME = "dynamic-foraging-processing-pipeline" + #: Filename of the NWB (Zarr) store written under the output directory. _NWB_FILENAME = "behavior.nwb.zarr" @@ -270,6 +273,7 @@ def _write_processing( start_date_time, end_date_time : datetime When the NWB packaging started and finished. """ + pipeline_name = os.getenv("PIPELINE_NAME", _PIPELINE_NAME) processing = Processing( data_processes=[ DataProcess( @@ -280,14 +284,14 @@ def _write_processing( experimenters=["Alex Piet", "Micah Woodard", "Bruno Cruz", "Arjun Sridhar"], start_date_time=start_date_time, end_date_time=end_date_time, - pipeline_name="dynamic-foraging-processing-pipeline", + pipeline_name=pipeline_name, ) ], pipelines=[ Code( - url=_CODE_URL, - version=_PACKAGE_VERSION, - name="dynamic-foraging-processing-pipeline", + url=os.getenv("PIPELINE_URL", _CODE_URL), + version=os.getenv("PIPELINE_VERSION", _PACKAGE_VERSION), + name=pipeline_name, input_data=[DataAsset(name=Path(self.loader.path).stem)], ) ], diff --git a/src/dynamic_foraging_processing/qc/_core/result.py b/src/dynamic_foraging_processing/qc/_core/result.py index ae71125..bf86553 100644 --- a/src/dynamic_foraging_processing/qc/_core/result.py +++ b/src/dynamic_foraging_processing/qc/_core/result.py @@ -24,8 +24,10 @@ class QCResult: Metric name. value : Any The computed value. - passed : bool - Whether the check passed. + passed : bool or None + Whether the check passed. ``None`` for a metric with no automated + pass/fail (its value is reported but the status is left ``PENDING`` for + manual review). description : str, optional Human-readable description. reference : str, optional @@ -36,7 +38,7 @@ class QCResult: name: str value: t.Any - passed: bool + passed: t.Optional[bool] description: t.Optional[str] = None reference: t.Optional[str] = None tags: t.Dict[str, str] = dataclasses.field(default_factory=dict) diff --git a/src/dynamic_foraging_processing/qc/_core/schema.py b/src/dynamic_foraging_processing/qc/_core/schema.py index de8b025..58397bd 100644 --- a/src/dynamic_foraging_processing/qc/_core/schema.py +++ b/src/dynamic_foraging_processing/qc/_core/schema.py @@ -51,23 +51,30 @@ def now_utc() -> datetime.datetime: return datetime.datetime.now(datetime.timezone.utc) -def bool_to_status(passed: bool, timestamp: t.Optional[datetime.datetime] = None) -> QCStatus: - """Convert a boolean pass/fail into an automated ``QCStatus``. +def bool_to_status( + passed: t.Optional[bool], timestamp: t.Optional[datetime.datetime] = None +) -> QCStatus: + """Convert a boolean pass/fail (or ``None``) into an automated ``QCStatus``. Parameters ---------- - passed : bool - ``True`` for a passing metric, ``False`` for a failing one. + passed : bool or None + ``True`` for a passing metric, ``False`` for a failing one, and ``None`` + for a metric with no automated pass/fail — the value is reported but the + judgment is deferred, so the status is ``PENDING`` (needs manual review). timestamp : datetime.datetime, optional Timezone-aware evaluation time. Defaults to the current Seattle time. Returns ------- QCStatus - An ``"Automated"`` status with ``PASS`` or ``FAIL``. + An ``"Automated"`` status with ``PASS``, ``FAIL``, or ``PENDING``. """ timestamp = timestamp if timestamp is not None else now_seattle() - status = Status.PASS if passed else Status.FAIL + if passed is None: + status = Status.PENDING + else: + status = Status.PASS if passed else Status.FAIL return QCStatus(evaluator="Automated", status=status, timestamp=timestamp) diff --git a/src/dynamic_foraging_processing/qc/processed/__init__.py b/src/dynamic_foraging_processing/qc/processed/__init__.py index 8a67d12..242796c 100644 --- a/src/dynamic_foraging_processing/qc/processed/__init__.py +++ b/src/dynamic_foraging_processing/qc/processed/__init__.py @@ -3,10 +3,12 @@ from dynamic_foraging_processing.qc.processed.behavior import ( calculate_lick_intervals, lick_interval_results, + lick_latency_result, side_bias_result, ) from dynamic_foraging_processing.qc.processed.plots import ( plot_lick_intervals, + plot_lick_latency, plot_side_bias, ) from dynamic_foraging_processing.qc.processed.results import behavior_qc_results @@ -17,7 +19,9 @@ "behavior_qc_results", "calculate_lick_intervals", "lick_interval_results", + "lick_latency_result", "plot_lick_intervals", + "plot_lick_latency", "plot_side_bias", "side_bias_result", ] diff --git a/src/dynamic_foraging_processing/qc/processed/behavior.py b/src/dynamic_foraging_processing/qc/processed/behavior.py index b9e019e..150044c 100644 --- a/src/dynamic_foraging_processing/qc/processed/behavior.py +++ b/src/dynamic_foraging_processing/qc/processed/behavior.py @@ -17,6 +17,7 @@ #: Reference plot assets shared by the behavior metrics. SIDE_BIAS_PLOT = "side_bias.png" LICK_INTERVALS_PLOT = "lick_intervals.png" +LICK_LATENCY_PLOT = "lick_latency.png" def _plot_reference(plot_name: str, results_folder: t.Optional[str]) -> str: @@ -165,6 +166,105 @@ def side_bias_result(side_bias: np.ndarray, results_folder: t.Optional[str] = No ) +def _first_lick_latency(go_cue: float, licks: np.ndarray) -> float: + """Return the latency (s) from ``go_cue`` to the first lick after it. + + Parameters + ---------- + go_cue : float + The trial's go-cue time (s). ``nan`` yields ``nan`` (no lick compares + greater than ``nan``). + licks : numpy.ndarray + Ascending lick timestamps (s). + + Returns + ------- + float + The first-lick latency, or ``nan`` when no lick follows the go cue. + """ + after = licks[licks > go_cue] + if after.size: + return float(after[0] - go_cue) + return float("nan") + + +def lick_latency_by_side( + go_cue_times: t.Optional[np.ndarray], + animal_response: t.Optional[np.ndarray], + left_lick_times: np.ndarray, + right_lick_times: np.ndarray, +) -> t.Tuple[np.ndarray, np.ndarray]: + """Return per-trial first-lick latency (s) after the go cue, split by chosen side. + + For each trial the latency is the time from the go cue to the first lick on + the *chosen* side — left when ``animal_response == 0``, right when ``== 1``. + Trials with no response (``2``), and any go cue after which the chosen side + never licks, are ``nan``. Slow or one-sided licking is the diagnostic signal + (e.g. deafness, or a non-functional lickport on one side). + + Parameters + ---------- + go_cue_times : numpy.ndarray or None + Per-trial go-cue times (s). ``None`` (column absent) is treated as no + trials. + animal_response : numpy.ndarray or None + Per-trial choice codes (``0`` left, ``1`` right, ``2`` ignore). ``None`` + is treated as no trials. + left_lick_times, right_lick_times : numpy.ndarray + Timestamps (s) of left/right-port licks (need not be sorted). + + Returns + ------- + tuple of numpy.ndarray + The ``(left_latency, right_latency)`` per-trial arrays; each trial has a + latency on at most its chosen side, ``nan`` elsewhere. + """ + if go_cue_times is None or animal_response is None: + return np.empty(0), np.empty(0) + go_cue = np.asarray(go_cue_times, dtype=float) + response = np.asarray(animal_response) + left = np.sort(np.asarray(left_lick_times, dtype=float)) + right = np.sort(np.asarray(right_lick_times, dtype=float)) + left_latency = np.full(go_cue.shape, np.nan) + right_latency = np.full(go_cue.shape, np.nan) + for i, cue in enumerate(go_cue): + if response[i] == 0: + left_latency[i] = _first_lick_latency(cue, left) + elif response[i] == 1: + right_latency[i] = _first_lick_latency(cue, right) + return left_latency, right_latency + + +def lick_latency_result(results_folder: t.Optional[str] = None) -> QCResult: + """Build the review-only first-lick-latency ``QCResult``. + + A single review-only metric surfacing the lick-latency plot (per-side + first-lick latency after the go cue): there is no computed value + (``value=None``) and no automated pass/fail (``passed=None`` -> ``PENDING``). + Tagged ``type="Lick_Interval"`` so it groups with the lick-interval metrics. + + Parameters + ---------- + results_folder : str, optional + Directory the lick-latency plot is written to; used to build the + result's reference. When ``None``, the reference is the bare plot name. + + Returns + ------- + QCResult + The lick-latency result (``PENDING``, no value or auto pass/fail) + referencing the lick-latency plot. + """ + return QCResult( + name="Lick_Latency", + value=None, + passed=None, # no automated pass/fail -> PENDING for manual review + description="First-lick latency (s) after the go cue, by side (review-only).", + reference=_plot_reference(LICK_LATENCY_PLOT, results_folder), + tags={"metric": "Lick_Latency", "type": "Lick_Interval"}, + ) + + def lick_interval_results( left_lick_times: np.ndarray, right_lick_times: np.ndarray, diff --git a/src/dynamic_foraging_processing/qc/processed/plots.py b/src/dynamic_foraging_processing/qc/processed/plots.py index e46e335..7750ace 100644 --- a/src/dynamic_foraging_processing/qc/processed/plots.py +++ b/src/dynamic_foraging_processing/qc/processed/plots.py @@ -17,7 +17,9 @@ from dynamic_foraging_processing.qc.processed.behavior import ( LICK_INTERVALS_PLOT, + LICK_LATENCY_PLOT, SIDE_BIAS_PLOT, + lick_latency_by_side, ) @@ -85,6 +87,57 @@ def plot_lick_intervals( return LICK_INTERVALS_PLOT +def plot_lick_latency( + go_cue_times: t.Optional[np.ndarray], + animal_response: t.Optional[np.ndarray], + left_lick_times: np.ndarray, + right_lick_times: np.ndarray, + results_folder: str, +) -> str: + """Save the per-side first-lick-latency histogram (response to the go cue). + + One overlaid histogram of the time from the go cue to the first lick on the + chosen side (right and left, density-normalized). It shows how quickly the + animal licks each side after the go cue; a shifted or absent distribution on + one side is the diagnostic signal (e.g. deafness or a dead lickport). + + Parameters + ---------- + go_cue_times, animal_response : numpy.ndarray or None + Per-trial go-cue times and choice codes (see ``lick_latency_by_side``). + left_lick_times, right_lick_times : numpy.ndarray + Timestamps (s) of left/right-port licks. + results_folder : str + Directory to write ``lick_latency.png`` into. + + Returns + ------- + str + The plot filename (``lick_latency.png``), for use as a metric + ``reference``. + """ + left_latency, right_latency = lick_latency_by_side( + go_cue_times, animal_response, left_lick_times, right_lick_times + ) + + fig, ax = plt.subplots(figsize=(5, 4)) + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + bins = np.arange(0, 1, 0.05) + ax.hist(right_latency[~np.isnan(right_latency)], bins=bins, alpha=0.5, label="R", density=True) + ax.hist(left_latency[~np.isnan(left_latency)], bins=bins, alpha=0.5, label="L", density=True) + ax.legend() + ax.set_title("lick latency by lick side") + ax.set_xlabel("Time from go cue (s)") + ax.set_ylabel("density %") + ax.set_xlim(left=0) + + fig.tight_layout() + fig.savefig(Path(results_folder) / LICK_LATENCY_PLOT, dpi=300, bbox_inches="tight") + plt.close(fig) + 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.""" ax.set_xlabel("Trial #") diff --git a/src/dynamic_foraging_processing/qc/processed/results.py b/src/dynamic_foraging_processing/qc/processed/results.py index a263f1a..0657e65 100644 --- a/src/dynamic_foraging_processing/qc/processed/results.py +++ b/src/dynamic_foraging_processing/qc/processed/results.py @@ -17,9 +17,14 @@ from dynamic_foraging_processing.qc._core.result import QCResult from dynamic_foraging_processing.qc.processed.behavior import ( lick_interval_results, + lick_latency_result, side_bias_result, ) -from dynamic_foraging_processing.qc.processed.plots import plot_lick_intervals, plot_side_bias +from dynamic_foraging_processing.qc.processed.plots import ( + plot_lick_intervals, + plot_lick_latency, + plot_side_bias, +) # Logical input -> trials-table column name. Centralized so the mapping is easy # to correct against the trial-table builder. The lickspout columns are the @@ -64,9 +69,9 @@ def behavior_qc_results( ) -> t.List[QCResult]: """Build the behavior QC results (side bias + lick intervals). - When ``results_folder`` is provided, the supporting ``side_bias.png`` and - ``lick_intervals.png`` plots are written there so the result references - resolve. Convert the returned results to schema metrics with + When ``results_folder`` is provided, the supporting ``side_bias.png``, + ``lick_intervals.png``, and ``lick_latency.png`` plots are written there so + the result references resolve. Convert the returned results to schema metrics with ``to_metrics`` / ``QCResult.to_metric`` when assembling a ``QualityControl``. Parameters @@ -89,12 +94,16 @@ def behavior_qc_results( Returns ------- list of QCResult - The average-side-bias result followed by the four lick-interval results. + The average-side-bias result, the four lick-interval results, and the + review-only lick-latency result. """ side_bias = _column(trials, "side_bias") + go_cue_times = _column(trials, "go_cue_times") + animal_response = _column(trials, "animal_response") results = [ side_bias_result(side_bias, results_folder), *lick_interval_results(left_lick_times, right_lick_times, results_folder), + lick_latency_result(results_folder), ] if results_folder is not None: plot_side_bias( @@ -116,4 +125,7 @@ def behavior_qc_results( manual_right_times=manual_right_times, ) plot_lick_intervals(left_lick_times, right_lick_times, results_folder) + plot_lick_latency( + go_cue_times, animal_response, left_lick_times, right_lick_times, results_folder + ) return results diff --git a/src/dynamic_foraging_processing/qc/processed/stage.py b/src/dynamic_foraging_processing/qc/processed/stage.py index 113412b..18d5f10 100644 --- a/src/dynamic_foraging_processing/qc/processed/stage.py +++ b/src/dynamic_foraging_processing/qc/processed/stage.py @@ -51,7 +51,8 @@ def run( Returns ------- list of QCMetric - The side-bias metric followed by the four lick-interval metrics. + The side-bias metric, the four lick-interval metrics, and the + review-only lick-latency metric. """ results = behavior_qc_results( trials, diff --git a/tests/test_pipeline/test_pipeline.py b/tests/test_pipeline/test_pipeline.py index 24fe4f1..8967e5e 100644 --- a/tests/test_pipeline/test_pipeline.py +++ b/tests/test_pipeline/test_pipeline.py @@ -350,8 +350,10 @@ def test_read_processed_inputs_reads_from_nwb(): np.testing.assert_array_equal(manual_right, np.array([0.3])) -def test_run_nwb_writes_nwb_and_processing(tmp_path): +def test_run_nwb_writes_nwb_and_processing(tmp_path, monkeypatch): """``run_nwb`` writes the NWB store and a valid ``processing.json``.""" + for name in ("PIPELINE_URL", "PIPELINE_VERSION", "PIPELINE_NAME"): + monkeypatch.delenv(name, raising=False) pipeline = _make_pipeline() acquisition = ["entry"] trials = _trials_frame() @@ -379,8 +381,34 @@ def test_run_nwb_writes_nwb_and_processing(tmp_path): assert loaded.pipelines[0].url == _pipeline._CODE_URL -def test_write_processing_records_input_data_from_loader_path(tmp_path): +def test_write_processing_uses_pipeline_env_overrides(tmp_path, monkeypatch): + """The pipeline ``Code`` url/version/name come from the ``PIPELINE_*`` env vars.""" + monkeypatch.setenv("PIPELINE_URL", "https://codeocean.example/capsule/123") + monkeypatch.setenv("PIPELINE_VERSION", "4.5.6") + monkeypatch.setenv("PIPELINE_NAME", "code-ocean-pipeline") + pipeline = _make_pipeline() + + pipeline._write_processing( + str(tmp_path), + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 1, 2, tzinfo=timezone.utc), + ) + + loaded = Processing.model_validate_json((tmp_path / "processing.json").read_text()) + assert loaded.pipelines[0].url == "https://codeocean.example/capsule/123" + assert loaded.pipelines[0].version == "4.5.6" + assert loaded.pipelines[0].name == "code-ocean-pipeline" + # The data process link follows the renamed pipeline. + assert loaded.data_processes[0].pipeline_name == "code-ocean-pipeline" + # The data process code still records the package defaults. + assert loaded.data_processes[0].code.url == _pipeline._CODE_URL + assert loaded.data_processes[0].code.version == _pipeline._PACKAGE_VERSION + + +def test_write_processing_records_input_data_from_loader_path(tmp_path, monkeypatch): """``_write_processing`` records the loader file stem as the pipeline ``input_data``.""" + for name in ("PIPELINE_URL", "PIPELINE_VERSION", "PIPELINE_NAME"): + monkeypatch.delenv(name, raising=False) pipeline = _make_pipeline() pipeline.loader.path = Path("some/dir/my_session.json") start = datetime(2024, 1, 1, tzinfo=timezone.utc) diff --git a/tests/test_qc/test_behavior.py b/tests/test_qc/test_behavior.py index d366034..389d864 100644 --- a/tests/test_qc/test_behavior.py +++ b/tests/test_qc/test_behavior.py @@ -78,6 +78,50 @@ def test_lick_interval_results_names_and_count(): assert all(r.tags == {"metric": r.name, "type": "Lick_Interval"} for r in results) +def test_first_lick_latency_after_go_cue_and_none(): + """The first lick after the go cue gives the latency; no later lick -> nan.""" + licks = np.array([0.5, 1.2, 2.0]) + assert _behavior._first_lick_latency(1.0, licks) == pytest.approx(0.2) + # No lick after the cue -> nan. + assert np.isnan(_behavior._first_lick_latency(2.5, licks)) + # A nan go cue has no lick strictly greater than it -> nan. + assert np.isnan(_behavior._first_lick_latency(float("nan"), licks)) + + +def test_lick_latency_by_side_splits_on_choice(): + """Latency is measured on the chosen side; other side / ignore trials are nan.""" + go_cue = np.array([0.0, 1.0, 2.0, 3.0]) + response = np.array([0, 1, 2, 1]) # left, right, ignore, right + left_licks = np.array([0.3]) # after the trial-0 cue + right_licks = np.array([1.4, 3.2]) # after the trial-1 and trial-3 cues + left_latency, right_latency = _behavior.lick_latency_by_side( + go_cue, response, left_licks, right_licks + ) + assert left_latency[0] == pytest.approx(0.3) + assert np.isnan(left_latency[1]) # right-choice trial has no left latency + assert right_latency[1] == pytest.approx(0.4) + assert right_latency[3] == pytest.approx(0.2) + assert np.isnan(right_latency[2]) # ignore trial + + +def test_lick_latency_by_side_none_inputs_return_empty(): + """Absent go-cue / response columns yield empty latency arrays.""" + left, right = _behavior.lick_latency_by_side(None, None, np.array([1.0]), np.array([2.0])) + assert left.size == 0 and right.size == 0 + + +def test_lick_latency_result_is_pending_review_only(): + """The single latency result is review-only: no value, PENDING, plot ref.""" + result = _behavior.lick_latency_result("/data/my_results") + assert result.name == "Lick_Latency" + # No computed value yet, and no automated pass/fail (renders as PENDING). + assert result.value is None + assert result.passed is None + assert result.reference == f"my_results/{_behavior.LICK_LATENCY_PLOT}" + # Tagged Lick_Interval so it groups with the lick-interval metrics. + assert result.tags == {"metric": "Lick_Latency", "type": "Lick_Interval"} + + def test_reference_includes_results_folder_name(): """With a results_folder, references are '/'.""" side_bias = _behavior.side_bias_result(np.array([0.1]), "/data/my_results") diff --git a/tests/test_qc/test_builder.py b/tests/test_qc/test_builder.py index bbf97e5..2ef1563 100644 --- a/tests/test_qc/test_builder.py +++ b/tests/test_qc/test_builder.py @@ -13,11 +13,12 @@ def test_behavior_qc_results_without_plots(): - """Five behavior results are produced and no plots are written.""" + """Six behavior results are produced and no plots are written.""" trials = pd.DataFrame( { "animal_response": [0, 1, 2, 1], "side_bias": [-0.1, 0.0, np.nan, 0.1], + "goCue_start_time": [0.5, 1.5, 2.5, 3.5], } ) results = _results.behavior_qc_results( @@ -25,7 +26,7 @@ def test_behavior_qc_results_without_plots(): np.array([1.0, 1.01]), np.array([2.0, 2.01]), ) - assert len(results) == 5 + assert len(results) == 6 assert results[0].name == "average side bias" @@ -46,11 +47,12 @@ def test_lickspout_columns_map_to_trial_table_names(): def test_behavior_qc_results_writes_plots(tmp_path): - """Supplying a results folder writes both behavior plots.""" + """Supplying a results folder writes all three behavior plots.""" trials = pd.DataFrame( { "animal_response": [0, 1, 2, 1], "side_bias": [-0.1, 0.0, np.nan, 0.1], + "goCue_start_time": [0.5, 1.5, 2.5, 3.5], } ) results = _results.behavior_qc_results( @@ -59,9 +61,10 @@ def test_behavior_qc_results_writes_plots(tmp_path): np.array([2.0, 2.01]), str(tmp_path), ) - assert len(results) == 5 + assert len(results) == 6 assert os.path.exists(tmp_path / "side_bias.png") assert os.path.exists(tmp_path / "lick_intervals.png") + assert os.path.exists(tmp_path / "lick_latency.png") def test_build_quality_control_defaults(): diff --git a/tests/test_qc/test_plots.py b/tests/test_qc/test_plots.py index 5109f59..1366b01 100644 --- a/tests/test_qc/test_plots.py +++ b/tests/test_qc/test_plots.py @@ -16,6 +16,27 @@ def test_plot_lick_intervals_writes_file(tmp_path): assert os.path.exists(tmp_path / name) +def test_plot_lick_latency_writes_file(tmp_path): + """The per-side lick-latency histogram is written and its filename returned.""" + go_cue = np.array([0.0, 1.0, 2.0, 3.0]) + animal_response = np.array([0, 1, 2, 1]) + name = _plots.plot_lick_latency( + go_cue, + animal_response, + np.array([0.3]), + np.array([1.4, 3.2]), + str(tmp_path), + ) + assert name == _plots.LICK_LATENCY_PLOT + assert os.path.exists(tmp_path / name) + + +def test_plot_lick_latency_no_trials(tmp_path): + """Absent go-cue / response columns still write an (empty) latency figure.""" + name = _plots.plot_lick_latency(None, None, np.array([1.0]), np.array([2.0]), str(tmp_path)) + assert os.path.exists(tmp_path / name) + + def test_time_to_trial_index_covers_all_branches(): """Empty go cues and early/late event times map to the right indices.""" # No go cues -> every event maps to -1. diff --git a/tests/test_qc/test_schema.py b/tests/test_qc/test_schema.py index ab017ed..20d4941 100644 --- a/tests/test_qc/test_schema.py +++ b/tests/test_qc/test_schema.py @@ -35,6 +35,13 @@ def test_bool_to_status_pass_and_fail_with_default_timestamp(): assert passed.timestamp.tzinfo is not None +def test_bool_to_status_none_is_pending(): + """``None`` (no automated pass/fail) yields a PENDING status.""" + pending = _schema.bool_to_status(None) + assert pending.status == Status.PENDING + assert pending.evaluator == "Automated" + + def test_bool_to_status_uses_supplied_timestamp(): """An explicit timestamp is passed through unchanged.""" ts = datetime.datetime(2026, 6, 11, tzinfo=datetime.timezone.utc) diff --git a/tests/test_qc/test_stages.py b/tests/test_qc/test_stages.py index 11d23bd..b6e2d37 100644 --- a/tests/test_qc/test_stages.py +++ b/tests/test_qc/test_stages.py @@ -45,27 +45,40 @@ def _fake_contract_qc_metrics(dataset, results_folder): def test_processed_qc_run_returns_metrics(): - """``ProcessedQC.run`` produces the five behavior metrics.""" - trials = pd.DataFrame({"animal_response": [0, 1, 2, 1], "side_bias": [-0.1, 0.0, np.nan, 0.1]}) + """``ProcessedQC.run`` produces the six behavior metrics.""" + trials = pd.DataFrame( + { + "animal_response": [0, 1, 2, 1], + "side_bias": [-0.1, 0.0, np.nan, 0.1], + "goCue_start_time": [0.5, 1.5, 2.5, 3.5], + } + ) metrics = ProcessedQC().run( trials, np.array([1.0, 1.01]), np.array([2.0, 2.01]), ) - assert len(metrics) == 5 + assert len(metrics) == 6 assert all(isinstance(m, QCMetric) for m in metrics) assert metrics[0].name == "average side bias" def test_processed_qc_run_writes_plots(tmp_path): """Supplying a results folder writes the supporting plots.""" - trials = pd.DataFrame({"animal_response": [0, 1, 2, 1], "side_bias": [-0.1, 0.0, np.nan, 0.1]}) + trials = pd.DataFrame( + { + "animal_response": [0, 1, 2, 1], + "side_bias": [-0.1, 0.0, np.nan, 0.1], + "goCue_start_time": [0.5, 1.5, 2.5, 3.5], + } + ) metrics = ProcessedQC().run( trials, np.array([1.0, 1.01]), np.array([2.0, 2.01]), str(tmp_path), ) - assert len(metrics) == 5 + assert len(metrics) == 6 assert os.path.exists(tmp_path / "side_bias.png") assert os.path.exists(tmp_path / "lick_intervals.png") + assert os.path.exists(tmp_path / "lick_latency.png") diff --git a/uv.lock b/uv.lock index 676ea93..4f24324 100644 --- a/uv.lock +++ b/uv.lock @@ -98,7 +98,7 @@ wheels = [ [[package]] name = "aind-nwb-utils" -version = "0.2.7" +version = "0.2.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fsspec" }, @@ -108,9 +108,9 @@ dependencies = [ { name = "s3fs" }, { name = "xarray" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/37/1c60c2fbb49b584e3ab78784212f750f2bfef00c0a30fa327e106c7ff3f6/aind_nwb_utils-0.2.7.tar.gz", hash = "sha256:d83a3a2457f167746b2b51b66876f858b7f668f36b547f7d00b415de5eba8a62", size = 39201001, upload-time = "2026-07-28T18:50:46.684Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/f8/f4e66fd3d1c549f24d127de0772f2f23351da644589b7bdf214123bd7242/aind_nwb_utils-0.2.8.tar.gz", hash = "sha256:2467dfc0b4b2e2d8bd2ed7a1c40658c8f10b262b8b598b4adb483c934b7b5870", size = 39201420, upload-time = "2026-07-29T00:37:24.079Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/66/3e652cebf82127253dc9cb138c22bded6214976935687fc87a236025d036/aind_nwb_utils-0.2.7-py3-none-any.whl", hash = "sha256:ed552bf0243df382769f5a9a1c8d795d7154160587730cce8871d8c66f080b1d", size = 14365, upload-time = "2026-07-28T18:50:45.185Z" }, + { url = "https://files.pythonhosted.org/packages/c2/79/fcbc53898cff1ed891556c04af227a12a5bcc0816a5c836cc459eb0a9279/aind_nwb_utils-0.2.8-py3-none-any.whl", hash = "sha256:526aad95a8b34824be4dbdc7e8297c0845f709a843af53bd4d51101d820a919c", size = 14622, upload-time = "2026-07-29T00:37:22.382Z" }, ] [[package]] @@ -363,7 +363,7 @@ name = "cffi" version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser" }, + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } wheels = [ @@ -723,15 +723,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, ] -[[package]] -name = "decorator" -version = "5.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, -] - [[package]] name = "deprecated" version = "1.3.1" @@ -1177,11 +1168,10 @@ wheels = [ [[package]] name = "ipython" -version = "9.15.0" +version = "9.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, { name = "ipython-pygments-lexers" }, { name = "jedi" }, { name = "matplotlib-inline" }, @@ -1192,9 +1182,9 @@ dependencies = [ { name = "stack-data" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/49/04360f83b4d110195751b4171b75dc1cd7b97ba122b18da34b5828172d59/ipython-9.16.0.tar.gz", hash = "sha256:d2f92587b1ef51d84f934dffe05fabb9255f0038ed0a21426f2ea761e39ad09a", size = 4515375, upload-time = "2026-07-31T08:02:51.977Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e", size = 630895, upload-time = "2026-06-26T11:03:33.809Z" }, + { url = "https://files.pythonhosted.org/packages/d1/82/d30656b9eb33b8ed4e421ca55c13c7fff412086f0405bbe53c39a7ee4a3b/ipython-9.16.0-py3-none-any.whl", hash = "sha256:3d02b96de2a59074d153b1ac1c3865de738df114e430e879e6e5ef100a4d470c", size = 625973, upload-time = "2026-07-31T08:02:50.114Z" }, ] [[package]] @@ -2829,11 +2819,11 @@ wheels = [ [[package]] name = "traitlets" -version = "5.15.1" +version = "5.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/a1/d7e7d9f461575d8bb77e3c3bd78a6cdfdd2bb4a06bfbbb8a0e1f51ab7bc2/traitlets-5.16.0.tar.gz", hash = "sha256:7de0a3fabaf5971ff15c8905545f9febfa850309fb8e86e1b42bdb5b46b293ed", size = 165946, upload-time = "2026-07-31T12:23:49.785Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, + { url = "https://files.pythonhosted.org/packages/01/bd/f8607e908605262e4926cbfd2560094bc5d04ef7f8aff1340e7fff503016/traitlets-5.16.0-py3-none-any.whl", hash = "sha256:94a9967ba45e89e837cf9934029c8d019bea9149cfffa115ed8c1900f679beba", size = 86093, upload-time = "2026-07-31T12:23:47.533Z" }, ] [[package]]