diff --git a/src/dynamic_foraging_processing/qc/processed/behavior.py b/src/dynamic_foraging_processing/qc/processed/behavior.py index 73e6ebe..ffc6e9e 100644 --- a/src/dynamic_foraging_processing/qc/processed/behavior.py +++ b/src/dynamic_foraging_processing/qc/processed/behavior.py @@ -8,6 +8,7 @@ """ import typing as t +from pathlib import Path import numpy as np @@ -18,6 +19,28 @@ LICK_INTERVALS_PLOT = "lick_intervals.png" +def _plot_reference(plot_name: str, results_folder: t.Optional[str]) -> str: + """Build a result's plot reference: ``"/"``. + + Parameters + ---------- + plot_name : str + The plot's file name (e.g. ``"side_bias.png"``). + results_folder : str or None + Directory the plot is written to. When ``None`` (no plot written), the + bare plot name is returned. + + Returns + ------- + str + ``"/"`` when ``results_folder`` is + given, otherwise ``plot_name``. + """ + if results_folder is None: + return plot_name + return f"{Path(results_folder).name}/{plot_name}" + + def calculate_lick_intervals( left_lick_times: np.ndarray, right_lick_times: np.ndarray ) -> t.Dict[str, float]: @@ -102,7 +125,7 @@ def calculate_lick_intervals( } -def side_bias_result(side_bias: np.ndarray) -> QCResult: +def side_bias_result(side_bias: np.ndarray, results_folder: t.Optional[str] = None) -> QCResult: """Build the average-side-bias ``QCResult`` from the trial-table column. The per-trial side bias is read directly from the trial table rather than @@ -115,13 +138,16 @@ def side_bias_result(side_bias: np.ndarray) -> QCResult: means a rightward bias. Computed over a sliding window, so a single no-response trial still has a value; entries are ``nan`` only when the mouse has not responded for many consecutive trials. + results_folder : str, optional + Directory the side-bias plot is written to; used to build the result's + reference. When ``None``, the reference is the bare plot name. Returns ------- QCResult Passes when ``abs(mean_bias) < 0.5``. Fails when the column is empty or all ``nan`` (``mean_bias`` is ``nan``). Tagged ``{"behavior": ...}`` and - referencing ``side_bias.png``. + referencing the side-bias plot. """ values = np.asarray(side_bias, dtype=float) if values.size == 0 or np.all(np.isnan(values)): @@ -134,13 +160,15 @@ def side_bias_result(side_bias: np.ndarray) -> QCResult: value=mean_bias, passed=bool(abs(mean_bias) < 0.5), # nan comparisons are False -> fails description="Average side bias across the session (right is positive).", - reference=SIDE_BIAS_PLOT, + reference=_plot_reference(SIDE_BIAS_PLOT, results_folder), tags={"behavior": name}, ) def lick_interval_results( - left_lick_times: np.ndarray, right_lick_times: np.ndarray + left_lick_times: np.ndarray, + right_lick_times: np.ndarray, + results_folder: t.Optional[str] = None, ) -> t.List[QCResult]: """Build the four inter-lick-interval ``QCResult`` objects. @@ -150,13 +178,16 @@ def lick_interval_results( Timestamps (s) of left-port licks. right_lick_times : numpy.ndarray Timestamps (s) of right-port licks. + results_folder : str, optional + Directory the lick-intervals plot is written to; used to build each + result's reference. When ``None``, the reference is the bare plot name. Returns ------- list of QCResult ``Left``/``Right``/``Cross Side`` lick-interval results (pass ``< 10``) - and ``Artifact Percent`` (pass ``< 1``), all referencing - ``lick_intervals.png``. + and ``Artifact Percent`` (pass ``< 1``), all referencing the + lick-intervals plot. """ results = calculate_lick_intervals(left_lick_times, right_lick_times) specs = [ @@ -171,7 +202,7 @@ def lick_interval_results( value=value, passed=value < limit, description=f"{name} of inter-lick intervals; passes when < {limit}.", - reference=LICK_INTERVALS_PLOT, + reference=_plot_reference(LICK_INTERVALS_PLOT, results_folder), tags={"behavior": name}, ) for name, value, limit in specs diff --git a/src/dynamic_foraging_processing/qc/processed/results.py b/src/dynamic_foraging_processing/qc/processed/results.py index 1047c52..762890f 100644 --- a/src/dynamic_foraging_processing/qc/processed/results.py +++ b/src/dynamic_foraging_processing/qc/processed/results.py @@ -93,8 +93,8 @@ def behavior_qc_results( """ side_bias = _column(trials, "side_bias") results = [ - side_bias_result(side_bias), - *lick_interval_results(left_lick_times, right_lick_times), + side_bias_result(side_bias, results_folder), + *lick_interval_results(left_lick_times, right_lick_times, results_folder), ] if results_folder is not None: plot_side_bias( diff --git a/src/dynamic_foraging_processing/qc/raw/contract_qa.py b/src/dynamic_foraging_processing/qc/raw/contract_qa.py index 7c0ea33..2ee942b 100644 --- a/src/dynamic_foraging_processing/qc/raw/contract_qa.py +++ b/src/dynamic_foraging_processing/qc/raw/contract_qa.py @@ -41,10 +41,11 @@ def _save_asset(result: qc.Result, results_folder: t.Optional[str]) -> t.Optiona Returns ------- str or None - The saved figure's filename, or ``None`` when there is no figure asset - (or no ``results_folder``). The name combines the (sanitized) suite and - test names, e.g. a ``CameraTestSuite`` result for ``test_frame_rate`` - becomes ``"CameraTestSuite_test_frame_rate.png"``. + The saved figure's reference (``"/"``), or + ``None`` when there is no figure asset (or no ``results_folder``). The + file name combines the (sanitized) suite and test names, e.g. a + ``CameraTestSuite`` result for ``test_frame_rate`` in ``.../nwb`` becomes + ``"nwb/CameraTestSuite_test_frame_rate.png"``. """ context = result.context if not isinstance(context, dict): @@ -54,7 +55,9 @@ def _save_asset(result: qc.Result, results_folder: t.Optional[str]) -> t.Optiona return None filename = f"{_sanitize(result.suite_name)}_{_sanitize(result.test_name)}.png" asset.savefig(Path(results_folder) / filename, dpi=300, bbox_inches="tight") - return filename + # Reference is relative to the top-level results folder, where the QC JSON is + # written; the images live in the named subfolder alongside it. + return f"{Path(results_folder).name}/{filename}" def results_to_metrics( diff --git a/tests/test_qc/test_behavior.py b/tests/test_qc/test_behavior.py index 75733c9..8fff460 100644 --- a/tests/test_qc/test_behavior.py +++ b/tests/test_qc/test_behavior.py @@ -76,3 +76,14 @@ def test_lick_interval_results_names_and_count(): ] assert all(r.reference == _behavior.LICK_INTERVALS_PLOT for r in results) assert all(r.tags == {"behavior": r.name} for r in results) + + +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") + assert side_bias.reference == f"my_results/{_behavior.SIDE_BIAS_PLOT}" + + licks = _behavior.lick_interval_results( + np.array([1.0, 1.01]), np.array([2.0, 2.01]), "/data/my_results" + ) + assert all(r.reference == f"my_results/{_behavior.LICK_INTERVALS_PLOT}" for r in licks) diff --git a/tests/test_qc/test_contract_qa.py b/tests/test_qc/test_contract_qa.py index 199e427..2536021 100644 --- a/tests/test_qc/test_contract_qa.py +++ b/tests/test_qc/test_contract_qa.py @@ -55,9 +55,9 @@ def test_save_asset_figure_is_saved(tmp_path): """A figure asset is written and its filename returned.""" fig = plt.figure() result = _result(cqc.Status.PASSED, suite="S", test="t", context={"asset": fig}) - name = _contract_qa._save_asset(result, str(tmp_path)) - assert name == "S_t.png" - assert os.path.exists(tmp_path / name) + reference = _contract_qa._save_asset(result, str(tmp_path)) + assert reference == f"{tmp_path.name}/S_t.png" + assert os.path.exists(tmp_path / "S_t.png") plt.close(fig) @@ -77,7 +77,7 @@ def test_results_to_metrics_grouping_and_status(tmp_path): warn = by_name["HubSuite::t1"] assert warn.status_history[0].status == "Pending" assert warn.tags == {"test_suite": "HubSuite", "HubSuite": "Data contract"} - assert warn.reference == "HubSuite_t1.png" + assert warn.reference == f"{tmp_path.name}/HubSuite_t1.png" fail = by_name["CamSuite::t2"] assert fail.status_history[0].status == "Fail"