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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **Trials without a response in Visiomode 0.5+ sessions.** Newer Visiomode versions
record misses and correct rejections as a response named `"none"`, with the trial
timeout (4–10 s) as the response time. These trials were treated as having a
response, so the timeout was counted as a reaction time in the session and subject
RT metrics and report plots, the trials got a touch position of 0, and gonogo
regressors gave them a cued lever push response regressor instead of the hold
regressor. They are now treated as having no response
(empty `response`, `response_time` and position). `session.summary()` also blanks
`"none"` responses in trials CSVs written by earlier versions, so `visiomode-analysis
subject` gives correct RTs without re-running `session`. Lever push durations were
not affected.

## [0.4.0] - 2026-10-02

### Added
Expand Down
34 changes: 32 additions & 2 deletions src/visiomode_analysis/session/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@
# outcome vocabulary so downstream logic only has to deal with one set of labels.
LEGACY_OUTCOME_LABELS = {"hit": "correct", "false_alarm": "incorrect", "miss": "no_response"}

# Newer Visiomode versions (0.5+) record a trial without a response (a miss or correct rejection) as a response with
# this name, with the trial timeout as its response time. Older versions leave the response out instead.
NO_RESPONSE_NAME = "none"

# Companion file holding the duration (ms) of every lever push in a session, one row per push in trial order.
LEVER_DURATIONS_SUFFIX = "_lever-durations.csv"
LEVER_DURATION = "lever_duration"
Expand Down Expand Up @@ -397,6 +401,20 @@ def _lever_duration_summary(df: pd.DataFrame) -> dict[str, float]:
return fields


def _blank_none_responses(df: pd.DataFrame) -> pd.DataFrame:
"""Blank out the response and response time of trials whose response is `NO_RESPONSE_NAME`.

`_flatten_trials` already does this for trials parsed from a JSON; this covers trials CSVs written by versions
before 0.4.1, which kept the "none" response and its timeout response time.
"""
no_response = df["response"] == NO_RESPONSE_NAME
if not no_response.any():
return df
df = df.copy()
df.loc[no_response, ["response", "response_time"]] = np.nan
return df


def get_rts(path: str, sdt_type=None, include_corrections=True) -> npt.NDArray:
return _select_rts(get_trials(path=path), sdt_type=sdt_type, include_corrections=include_corrections)

Expand Down Expand Up @@ -427,7 +445,7 @@ def summary(path: str | pd.DataFrame, lever_durations: str | None = None) -> dic
metadata = get_metadata(path)
df = get_trials(path, lever_durations=lever_durations)
else:
df = path if isinstance(path, pd.DataFrame) else pd.read_csv(path)
df = _blank_none_responses(path if isinstance(path, pd.DataFrame) else pd.read_csv(path))
metadata = {
"animal_id": df["animal_id"].iloc[0],
"session_date": df["session_date"].iloc[0],
Expand Down Expand Up @@ -839,11 +857,23 @@ def _normalise_legacy_outcome(trial: Any) -> Any:
return {**trial, "outcome": LEGACY_OUTCOME_LABELS.get(trial["outcome"], trial["outcome"])}


def _normalise_no_response(trial: Any) -> Any:
"""Return a copy of a raw trial with a `NO_RESPONSE_NAME` response rewritten the way older Visiomode versions
record no response: no response object and a response time of -1.

Done up front so the response, response time, touch position, stimulus reconstruction and SDT inference in
`_flatten_trials` all treat the trial as having no response, rather than counting the timeout as a reaction time.
"""
if (trial.get("response") or {}).get("name") == NO_RESPONSE_NAME:
return {**trial, "response": None, "response_time": -1}
return trial


def _flatten_trials(session: dict, metadata: dict) -> Iterator[dict]:
session_start_time = datetime.datetime.fromisoformat(metadata.get("session_start_time", ""))

for trial in session.get("trials", []):
trial = _normalise_legacy_outcome(trial)
trial = _normalise_no_response(_normalise_legacy_outcome(trial))

start_time = (datetime.datetime.fromisoformat(trial["timestamp"]) - session_start_time).total_seconds()

Expand Down
62 changes: 60 additions & 2 deletions tests/test_flatten_trials.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""Tests for `session._flatten_trials`'s handling of older Visiomode JSON formats: sessions
"""Tests for `session._flatten_trials`'s handling of different Visiomode JSON formats: sessions
recorded before an explicit `stimulus`/`sdt_type` field existed on each trial, where the presented
stimulus and signal-detection classification instead have to be reconstructed from the trial's
`outcome`/`response` and the session-level `stimuli` metadata.
`outcome`/`response` and the session-level `stimuli` metadata, and newer (0.5+) sessions that record
a trial without a response as a response named "none".
"""

import numpy as np
import pytest

BASE_TRIAL = dict(
timestamp="2022-01-01T00:00:01",
Expand Down Expand Up @@ -172,3 +174,59 @@ def test_legacy_other_protocol_uses_raw_stimuli_dict_unchanged(flatten_trial):
assert trial["target_id"] == "movinggrating"
assert trial["distractor_id"] == "isoluminantgray"
assert "stim_id" not in trial


# -- Visiomode 0.5+ records "no response" as a response named "none", with the trial timeout as its response time. --

NEWER_STIMULUS = {"id": "movinggrating", "common_name": "Moving Grating"}


@pytest.mark.parametrize(
"outcome, sdt_type, timeout",
[("no_response", "miss", 10.001618658035703), ("correct", "correct_rejection", 4.000969302495185)],
)
def test_none_response_is_treated_as_no_response(flatten_trial, outcome, sdt_type, timeout):
trial = flatten_trial(
{
**BASE_TRIAL,
"outcome": outcome,
"sdt_type": sdt_type,
"response": {"name": "none"},
"response_time": timeout,
"stimulus": NEWER_STIMULUS,
}
)

assert trial["response"] is None
# The timeout is not a reaction time.
assert np.isnan(trial["response_time"])
assert trial["pos_x"] is None and trial["pos_y"] is None
assert trial["dist_x"] is None and trial["dist_y"] is None
assert trial["sdt_type"] == sdt_type
# No response timestamp, so the trial runs to the end of the stimulus.
assert trial["stop_time"] == pytest.approx(1.0 + BASE_TRIAL["iti"] + 4.0)


def test_named_lever_push_response_keeps_its_response_time(flatten_trial):
trial = flatten_trial(
{
**BASE_TRIAL,
"sdt_type": "hit",
"response": {"name": "leverpush", "timestamp": "2022-01-01T00:00:08", "pos_x": 400.0, "pos_y": 240.0},
"response_time": 2.23,
"stimulus": NEWER_STIMULUS,
}
)

assert trial["response"] == "leverpush"
assert trial["response_time"] == pytest.approx(2.23)
assert trial["pos_x"] == 400.0


def test_none_response_is_normalised_before_sdt_inference(flatten_trial):
# Without an explicit sdt_type, a "none" response must still be read as no response,
# so a correct gonogo trial is a correct rejection rather than a hit.
trial = flatten_trial({**BASE_TRIAL, "outcome": "correct", "response": {"name": "none"}, "response_time": 4.0})

assert trial["sdt_type"] == "correct_rejection"
assert trial["stim_id"] == "isoluminantgray"
18 changes: 18 additions & 0 deletions tests/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,3 +512,21 @@ def test_summary_reads_metadata_from_a_filtered_trials_dataframe(gonogo_session_

assert result["animal_id"] == "MM229"
assert result["protocol"] == "gonogo"


def test_summary_ignores_none_responses_in_trials_csvs_written_by_older_versions(tmp_path, write_trials_csv):
# Trials CSVs written before 0.4.1 kept Visiomode's "none" response with the timeout as its response time.
rows = [
dict(outcome="correct", correction=False, sdt_type="hit", response_time=0.5, response="leverpush"),
dict(outcome="incorrect", correction=False, sdt_type="false_alarm", response_time=0.7, response="leverpush"),
dict(outcome="no_response", correction=False, sdt_type="miss", response_time=10.0, response="none"),
dict(outcome="correct", correction=False, sdt_type="correct_rejection", response_time=4.0, response="none"),
]
csv_path = write_trials_csv(tmp_path, "trials.csv", "A1", "2022-01-01", "gonogo", "expX", rows=rows)

result = session.summary(csv_path)

assert result["rt"] == pytest.approx(0.6)
assert result["rt_wc"] == pytest.approx(0.6)
assert result["rt_iqr"] == pytest.approx(0.1)
assert result["misses"] == 1 and result["correct_rejections"] == 1
Loading