Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 38 additions & 7 deletions src/dynamic_foraging_processing/qc/processed/behavior.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"""

import typing as t
from pathlib import Path

import numpy as np

Expand All @@ -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: ``"<results-folder-name>/<plot>"``.

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
``"<results-folder-name>/<plot_name>"`` 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]:
Expand Down Expand Up @@ -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
Expand All @@ -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)):
Expand All @@ -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.

Expand All @@ -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 = [
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/dynamic_foraging_processing/qc/processed/results.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
13 changes: 8 additions & 5 deletions src/dynamic_foraging_processing/qc/raw/contract_qa.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (``"<results-folder-name>/<file>"``), 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):
Expand All @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions tests/test_qc/test_behavior.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<folder-name>/<plot>'."""
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)
8 changes: 4 additions & 4 deletions tests/test_qc/test_contract_qa.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand All @@ -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"
Expand Down
Loading