diff --git a/docs/index.rst b/docs/index.rst index 8ae75004..99f53410 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2,7 +2,6 @@ Visuomotor Serial Targeting Task (VSTT) ======================================= .. toctree:: - :maxdepth: 2 :caption: Getting started quickstart/install @@ -12,7 +11,12 @@ Visuomotor Serial Targeting Task (VSTT) quickstart/share-experiment .. toctree:: - :maxdepth: 2 + :caption: Reference information + + reference/statistics + reference/framerate + +.. toctree:: :caption: Example jupyter notebooks notebooks/example @@ -20,7 +24,6 @@ Visuomotor Serial Targeting Task (VSTT) notebooks/area_calculation .. toctree:: - :maxdepth: 2 :caption: Developer documentation developer/install diff --git a/docs/reference/framerate.rst b/docs/reference/framerate.rst new file mode 100644 index 00000000..714fc125 --- /dev/null +++ b/docs/reference/framerate.rst @@ -0,0 +1,37 @@ +Framerates and timestamps +========================= + +Introduction +------------ + +During an experiment you see the cursor moving on the screen, but what your screen actually displays +is a series of still images, or frames, which are updated quickly enough that we don't notice the individual frames. + +Typically computer monitors do this at a rate of 60 frames per second (fps), although gaming monitors are available +that offer much higher refresh rates. + +The experiment saves information about the cursor position each time a new frame is displayed. + +How an experiment works +----------------------- + +This loop happens every time the monitor refreshes: + + - store the timestamp and current cursor location + - prepare next frame to display (draw objects such as the cursor, targets, etc) + - wait until the screen is updated with the new frame + +In the stored data, each timestamp is the time when the specified target was displayed on the screen, +and the cursor location at that time. The display on the screen doesn't change until the next timestamp. + +Dropped frames +-------------- + +The sampling frequency of the experimental data is limited by the refresh rate of the monitor. + +In addition, it could sometimes be the case that the code preparing the next frame to be displayed takes too long, +and the monitor refreshes the screen before the next frame is available. +This is known as a "dropped frame", and will be visible in the recorded data as a larger than usual delay +before the next timestamp. + +If this happens, ensuring that nothing else is running on the computer when an experiment is running may help. diff --git a/docs/reference/statistics.rst b/docs/reference/statistics.rst new file mode 100644 index 00000000..e919183c --- /dev/null +++ b/docs/reference/statistics.rst @@ -0,0 +1,20 @@ +Statistics +========== + +Time +---- + + * Time + * Time from target being displayed to the target being reached by the cursor or timeout + * Reaction time + * Time from target being displayed to first cursor movement + * Movement time + * Time from first cursor movement to the target being reached by the cursor or timeout + +Distance +-------- + + * Distance + * Euclidean point-to-point distance travelled by the cursor + * RMSE + * The Root Mean Square Error (RMSE) of the perpendicular distance from each cursor point to the ideal path to the target diff --git a/src/vstt/__init__.py b/src/vstt/__init__.py index 5e7102d0..f5821c39 100644 --- a/src/vstt/__init__.py +++ b/src/vstt/__init__.py @@ -5,4 +5,4 @@ "__version__", ] -__version__ = "0.27.0" +__version__ = "0.28.0" diff --git a/src/vstt/gui.py b/src/vstt/gui.py index 06eab8f5..a9ce8ee9 100644 --- a/src/vstt/gui.py +++ b/src/vstt/gui.py @@ -80,6 +80,7 @@ def _open_file(self, filename: str) -> None: self.experiment.load_file(filename) except Exception as e: logging.warning(f"Failed to load file {filename}: {e}") + logging.exception(e) QtWidgets.QMessageBox.critical( self, "Invalid file", diff --git a/src/vstt/stat.py b/src/vstt/stat.py index 1d0f9364..268a4115 100644 --- a/src/vstt/stat.py +++ b/src/vstt/stat.py @@ -1,6 +1,8 @@ from __future__ import annotations +import logging from typing import Any +from typing import Dict from typing import List from typing import Tuple from typing import Union @@ -38,13 +40,33 @@ def _get_trial_data_columns() -> List[str]: "to_target_timestamps", "to_target_mouse_positions", "to_target_success", + "to_target_num_timestamps_before_visible", "center_pos", "to_center_timestamps", "to_center_mouse_positions", "to_center_success", + "to_center_num_timestamps_before_visible", ] +def _get_dat( + data: Dict, key: str, index: Tuple, i_target: int, default_value: Any +) -> Any: + ar = data.get(key) + if ar is None: + logging.warning( + f"Key '{key}' not found in data, using default value {default_value}" + ) + return default_value + try: + return ar[index][i_target] + except IndexError: + logging.warning( + f"Index error for key '{key}', index '{index}', i_target '{i_target}', using default value {default_value}" + ) + return default_value + + def _get_target_data( trial_handler: TrialHandlerExt, index: Tuple, i_target: int ) -> List: @@ -54,24 +76,28 @@ def _get_target_data( target_pos = np.array(data["target_pos"][index][i_target]) center_pos = np.array([0.0, 0.0]) to_target_timestamps = np.array(data["to_target_timestamps"][index][i_target]) + to_target_num_timestamps_before_visible = _get_dat( + data, "to_target_num_timestamps_before_visible", index, i_target, 0 + ) to_target_mouse_positions = np.stack( np.array(data["to_target_mouse_positions"][index][i_target]) ) # type: ignore to_target_success = np.array(data["to_target_success"][index][i_target]) to_center_success: Union[np.ndarray, bool] - if ( - type(data["to_center_timestamps"][index]) is np.ndarray - and i_target < data["to_center_timestamps"][index].shape[0] - ): - to_center_timestamps = np.array(data["to_center_timestamps"][index][i_target]) - to_center_mouse_positions = np.stack( - np.array(data["to_center_mouse_positions"][index][i_target]) - ) # type: ignore - to_center_success = np.array(data["to_center_success"][index][i_target]) - else: - to_center_timestamps = np.array([]) - to_center_mouse_positions = np.array([]) - to_center_success = True + to_center_timestamps = np.array( + _get_dat(data, "to_center_timestamps", index, i_target, []) + ) + to_center_num_timestamps_before_visible = _get_dat( + data, "to_center_num_timestamps_before_visible", index, i_target, 0 + ) + to_center_mouse_positions = np.array( + _get_dat(data, "to_center_mouse_positions", index, i_target, []) + ) + if to_center_mouse_positions.shape[0] > 0: + to_center_mouse_positions = np.stack(to_center_mouse_positions) # type: ignore + to_center_success = np.array( + _get_dat(data, "to_center_success", index, i_target, True) + ) return [ index[0], index[1], @@ -82,10 +108,12 @@ def _get_target_data( to_target_timestamps, to_target_mouse_positions, to_target_success, + to_target_num_timestamps_before_visible, center_pos, to_center_timestamps, to_center_mouse_positions, to_center_success, + to_center_num_timestamps_before_visible, ] @@ -110,11 +138,16 @@ def stats_dataframe(trial_handler: TrialHandlerExt) -> pd.DataFrame: lambda x: _reaction_time( x[f"to_{destination}_timestamps"], x[f"to_{destination}_mouse_positions"], + x[f"to_{destination}_num_timestamps_before_visible"], ), axis=1, ) - df[f"to_{destination}_time"] = df[f"to_{destination}_timestamps"].map( - lambda x: x[-1] if x.shape[0] > 0 else np.nan + df[f"to_{destination}_time"] = df.apply( + lambda x: _total_time( + x[f"to_{destination}_timestamps"], + x[f"to_{destination}_num_timestamps_before_visible"], + ), + axis=1, ) df[f"to_{destination}_movement_time"] = ( df[f"to_{destination}_time"] - df[f"to_{destination}_reaction_time"] @@ -156,8 +189,8 @@ def append_stats_data_to_excel(df: pd.DataFrame, writer: Any, data_format: str) # one sheet for each row (target) in df, with arrays of time/position data. # arrays transposed to columns, x-y pairs are split into two columns. # 6 columns: - # - to_target_timestamps (negative until target displayed) - # - to_center_timestamps (negative until target displayed) + # - to_target_timestamps + # - to_center_timestamps # - to_target_mouse_positions_x # - to_target_mouse_positions_y # - to_center_mouse_positions_x @@ -189,11 +222,10 @@ def append_stats_data_to_excel(df: pd.DataFrame, writer: Any, data_format: str) # - timestamps (start from zero, increases throughout the trial) # - mouse_x # - mouse_y - # - target_index (-1 if no target is currently displayed) - # - target_x (-1 if no target is currently displayed) - # - target_y (-1 if no target is currently displayed) + # - target_index (-99 if no target is currently displayed) + # - target_x (-99 if no target is currently displayed) + # - target_y (-99 if no target is currently displayed) for i_trial in df.i_trial.unique(): - t0 = 0.0 times = np.array([]) x_positions = np.array([]) y_positions = np.array([]) @@ -204,7 +236,7 @@ def append_stats_data_to_excel(df: pd.DataFrame, writer: Any, data_format: str) for dest in ["target", "center"]: raw_times = getattr(row, f"to_{dest}_timestamps") if raw_times.shape[-1] > 0: - times = np.append(times, raw_times - raw_times[0] + t0) + times = np.append(times, raw_times) points = getattr(row, f"to_{dest}_mouse_positions") x_positions = np.append(x_positions, points[:, 0]) y_positions = np.append(y_positions, points[:, 1]) @@ -212,22 +244,25 @@ def append_stats_data_to_excel(df: pd.DataFrame, writer: Any, data_format: str) if dest == "center": pos = (0.0, 0.0) i_target = -1 # give center target the special index -1 + num_timestamps_before_target_visible = ( + row.to_center_num_timestamps_before_visible + ) else: pos = row.target_pos i_target = row.i_target + num_timestamps_before_target_visible = ( + row.to_target_num_timestamps_before_visible + ) target_i = np.full_like(raw_times, i_target, dtype=np.int64) target_x = np.full_like(raw_times, pos[0]) target_y = np.full_like(raw_times, pos[1]) - # set these to -99 if no target is visible (i.e. raw time is negative) - target_i[raw_times < 0] = -99 - target_x[raw_times < 0] = -99 - target_y[raw_times < 0] = -99 + # if no target is visible use the special index -99 + target_i[0:num_timestamps_before_target_visible] = -99 + target_x[0:num_timestamps_before_target_visible] = -99 + target_y[0:num_timestamps_before_target_visible] = -99 i_targets = np.append(i_targets, target_i) x_targets = np.append(x_targets, target_x) y_targets = np.append(y_targets, target_y) - t0 = ( - times[-1] + 1.0 / 60.0 - ) # add a single window flip between targets (?) df_data = pd.DataFrame( { "timestamps": times, @@ -244,31 +279,84 @@ def append_stats_data_to_excel(df: pd.DataFrame, writer: Any, data_format: str) def _reaction_time( mouse_times: np.ndarray, mouse_positions: np.ndarray, + to_target_num_timestamps_before_visible: int, epsilon: float = 1e-12, ) -> float: - if mouse_times.shape[0] != mouse_positions.shape[0] or mouse_times.shape[0] == 0: + """ + The reaction time is defined as the timestamp where the cursor first moves, + minus the timestamp where the target becomes visible. + This means the reaction time can be negative if the cursor is moved before + the target is made visible. + + :param mouse_times: The array of timestamps + :param mouse_positions: The array of mouse positions + :param to_target_num_timestamps_before_visible: The index of the first timestamp where the target is visible + :param epsilon: The minimum euclidean distance to qualify as moving the cursor + :return: The reaction time + """ + if ( + mouse_times.shape[0] != mouse_positions.shape[0] + or mouse_times.shape[0] == 0 + or mouse_times.shape[0] < to_target_num_timestamps_before_visible + ): return np.nan i = 0 while xydist(mouse_positions[0], mouse_positions[i]) < epsilon and i + 1 < len( mouse_times ): i += 1 - return mouse_times[i] + return mouse_times[i] - mouse_times[to_target_num_timestamps_before_visible] + + +def _total_time( + mouse_times: np.ndarray, + to_target_num_timestamps_before_visible: int, +) -> float: + """ + The time to target is defined as the final timestamp (corresponding either to the target being reached + or a timeout) minus the timestamp where the target becomes visible. + + :param mouse_times: The array of timestamps + :param to_target_num_timestamps_before_visible: The index of the first timestamp where the target is visible + :return: The total time to target + """ + if ( + mouse_times.shape[0] == 0 + or mouse_times.shape[0] < to_target_num_timestamps_before_visible + ): + return np.nan + return mouse_times[-1] - mouse_times[to_target_num_timestamps_before_visible] def _distance(mouse_positions: np.ndarray) -> float: + """ + The euclidean point-to-point distance travelled by the cursor. + + :param mouse_positions: The array of mouse positions + :return: The distance travelled. + """ dist = 0 for i in range(mouse_positions.shape[0] - 1): dist += xydist(mouse_positions[i + 1], mouse_positions[i]) return dist -def _rmse(mouse_positions: np.ndarray, target: np.ndarray) -> float: +def _rmse(mouse_positions: np.ndarray, target_position: np.ndarray) -> float: + """ + The Root Mean Square Error (RMSE) of the perpendicular distance from each mouse point + to the straight line that intersects the initial mouse location and the target. + + See: https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line#Line_defined_by_two_points. + + :param mouse_positions: The array of mouse positions + :param target: The x,y coordinates of the target + :return: The RMSE of the distance from the ideal trajectoty + """ if mouse_positions.shape[0] <= 1: return np.nan # use first mouse position as origin point, so exclude it from RMSE measure x1, y1 = mouse_positions[0] - x2, y2 = target + x2, y2 = target_position norm = np.power(x2 - x1, 2) + np.power(y2 - y1, 2) norm *= mouse_positions.shape[0] - 1 sum_of_squares = 0 diff --git a/src/vstt/task.py b/src/vstt/task.py index 7c169eaf..eedaaf54 100644 --- a/src/vstt/task.py +++ b/src/vstt/task.py @@ -47,7 +47,9 @@ def __init__(self, trial: Dict[str, Any], rng: np.random.Generator): rng.shuffle(self.target_indices) self.target_pos: List[np.ndarray] = [] self.to_target_timestamps: List[np.ndarray] = [] + self.to_target_num_timestamps_before_visible: List[int] = [] self.to_center_timestamps: List[np.ndarray] = [] + self.to_center_num_timestamps_before_visible: List[int] = [] self.to_target_mouse_positions: List[np.ndarray] = [] self.to_center_mouse_positions: List[np.ndarray] = [] self.to_target_success: List[bool] = [] @@ -91,6 +93,7 @@ def __init__(self, win: Window, trial: Dict[str, Any], rng: np.random.Generator) self.prev_cursor_path = ShapeStim( win, vertices=[(0, 0)], lineColor="white", closeShape=False ) + self.clock = Clock() if trial["show_cursor_path"]: self.drawables.append(self.cursor_path) self.drawables.append(self.prev_cursor_path) @@ -179,11 +182,11 @@ def _do_trial( == trial["weight"] ) self.mouse.setPos(tom.cursor.pos) - previous_target_time_taken = 0.0 + self.win.recordFrameIntervals = True + tom.clock.reset() for index in tom.data.target_indices: - previous_target_time_taken = self._do_target( - trial, index, tom, previous_target_time_taken - ) + self._do_target(trial, index, tom) + self.win.recordFrameIntervals = False if trial["automove_cursor_to_center"]: tom.data.to_center_success = [True] * trial["num_targets"] @@ -211,31 +214,23 @@ def _do_trial( self.win, ) - def _do_target( - self, - trial: Dict[str, Any], - index: int, - tm: TrialManager, - previous_target_time_taken: float, - ) -> float: - clock = Clock() + def _do_target(self, trial: Dict[str, Any], index: int, tm: TrialManager) -> None: + minimum_window_for_flip = 1.0 / 60.0 mouse_pos = tm.cursor.pos - current_target_time_taken = 0 + stop_waiting_time = 0.0 + stop_target_time = 0.0 + if trial["fixed_target_intervals"]: + num_completed_targets = len(tm.data.to_target_timestamps) + stop_waiting_time = (num_completed_targets + 1) * trial["target_duration"] + stop_target_time = stop_waiting_time + trial["target_duration"] for target_index in _get_target_indices(index, trial): - # no target displayed - vis.update_target_colors(tm.targets, trial["show_inactive_targets"], None) - if trial["show_target_labels"]: - vis.update_target_label_colors( - tm.target_labels, trial["show_inactive_targets"], None - ) + t0 = tm.clock.getTime() + is_central_target = target_index == trial["num_targets"] mouse_times = [] mouse_positions = [] - is_central_target = target_index == trial["num_targets"] if is_central_target: - mouse_times = [0] - mouse_positions = [tm.cursor_path.vertices[-1]] tm.prev_cursor_path.vertices = tm.cursor_path.vertices - tm.cursor_path.vertices = mouse_positions + tm.cursor_path.vertices = [tm.cursor_path.vertices[-1]] else: if trial["automove_cursor_to_center"]: mouse_pos = np.array([0.0, 0.0]) @@ -243,28 +238,42 @@ def _do_target( tm.cursor.setPos(mouse_pos) tm.cursor_path.vertices = [mouse_pos] tm.prev_cursor_path.vertices = [(0, 0)] - clock.reset() - wait_time = trial["inter_target_duration"] - if trial["fixed_target_intervals"]: - wait_time = trial["target_duration"] - previous_target_time_taken - while clock.getTime() < wait_time: - if trial["freeze_cursor_between_targets"]: - self.mouse.setPos(mouse_pos) - else: - if trial["use_joystick"]: - mouse_pos = tm.joystick_point_updater( - mouse_pos, (self.js.getX(), self.js.getY()) # type: ignore - ) - else: - mouse_pos = tm.point_rotator(self.mouse.getPos()) - mouse_times.append(clock.getTime() - wait_time) - mouse_positions.append(mouse_pos) - if trial["show_cursor"]: - tm.cursor.setPos(mouse_pos) - if trial["show_cursor_path"]: - tm.cursor_path.vertices = mouse_positions - vis.draw_and_flip(self.win, tm.drawables, self.kb) + if not trial["fixed_target_intervals"]: + stop_waiting_time = t0 + trial["inter_target_duration"] + if stop_waiting_time > t0: + # no target displayed + vis.update_target_colors( + tm.targets, trial["show_inactive_targets"], None + ) + if trial["show_target_labels"]: + vis.update_target_label_colors( + tm.target_labels, trial["show_inactive_targets"], None + ) + # ensure we get at least a single flip + should_continue_waiting = True + while should_continue_waiting: + if trial["freeze_cursor_between_targets"]: + self.mouse.setPos(mouse_pos) + vis.draw_and_flip(self.win, tm.drawables, self.kb) + if not trial["freeze_cursor_between_targets"]: + if trial["use_joystick"]: + mouse_pos = tm.joystick_point_updater( + mouse_pos, (self.js.getX(), self.js.getY()) # type: ignore + ) + else: + mouse_pos = tm.point_rotator(self.mouse.getPos()) + mouse_times.append(tm.clock.getTime()) + mouse_positions.append(mouse_pos) + if trial["show_cursor"]: + tm.cursor.setPos(mouse_pos) + if trial["show_cursor_path"]: + tm.cursor_path.vertices = mouse_positions + should_continue_waiting = ( + tm.clock.getTime() + minimum_window_for_flip + < stop_waiting_time + ) # display current target + t0 = tm.clock.getTime() vis.update_target_colors( tm.targets, trial["show_inactive_targets"], target_index ) @@ -274,27 +283,21 @@ def _do_target( ) if trial["play_sound"]: Sound("A", secs=0.3, blockSize=1024, stereo=True).play() - if not is_central_target: - tm.data.target_pos.append(tm.targets.xys[target_index]) - dist_correct, dist_any = to_target_dists( - mouse_pos, - tm.targets.xys, - target_index, - trial["add_central_target"], - ) - if trial["ignore_incorrect_targets"] or is_central_target: - dist = dist_correct + if is_central_target: + tm.data.to_center_num_timestamps_before_visible.append(len(mouse_times)) else: - dist = dist_any - clock.reset() - self.win.recordFrameIntervals = True + tm.data.target_pos.append(tm.targets.xys[target_index]) + tm.data.to_target_num_timestamps_before_visible.append(len(mouse_times)) target_size = trial["target_size"] if is_central_target: target_size = trial["central_target_size"] - max_time = trial["target_duration"] - if trial["fixed_target_intervals"]: - max_time -= current_target_time_taken - while dist > target_size and clock.getTime() < max_time: + if not trial["fixed_target_intervals"]: + stop_target_time = t0 + trial["target_duration"] + dist_correct = 1.0 + # ensure we get at least one flip + should_continue_target = True + while should_continue_target: + vis.draw_and_flip(self.win, tm.drawables, self.kb) if trial["use_joystick"]: mouse_pos = tm.joystick_point_updater( mouse_pos, (self.js.getX(), self.js.getY()) # type: ignore @@ -303,7 +306,7 @@ def _do_target( mouse_pos = tm.point_rotator(self.mouse.getPos()) if trial["show_cursor"]: tm.cursor.setPos(mouse_pos) - mouse_times.append(clock.getTime()) + mouse_times.append(tm.clock.getTime()) mouse_positions.append(mouse_pos) if trial["show_cursor_path"]: tm.cursor_path.vertices = mouse_positions @@ -317,10 +320,14 @@ def _do_target( dist = dist_correct else: dist = dist_any - vis.draw_and_flip(self.win, tm.drawables, self.kb) - current_target_time_taken += clock.getTime() - self.win.recordFrameIntervals = False - success = dist_correct <= target_size and clock.getTime() < max_time + should_continue_target = ( + dist > target_size + and tm.clock.getTime() + minimum_window_for_flip < stop_target_time + ) + success = ( + dist_correct <= target_size + and tm.clock.getTime() + minimum_window_for_flip < stop_target_time + ) if is_central_target: tm.data.to_center_success.append(success) else: @@ -331,7 +338,6 @@ def _do_target( else: tm.data.to_target_timestamps.append(np.array(mouse_times)) tm.data.to_target_mouse_positions.append(np.array(mouse_positions)) - return current_target_time_taken def _clean_up_and_return(self, return_value: bool) -> bool: if self.win is not None and self.close_window_when_done: diff --git a/tests/conftest.py b/tests/conftest.py index 3d37853d..15b1d1e5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,8 +63,8 @@ def make_mouse_positions( return np.array([(pos[0] * t + noise(), pos[1] * t + noise()) for t in time_points]) -def make_timestamps(n_min: int = 8, n_max: int = 20) -> np.ndarray: - return np.linspace(0.0, 1.0, np.random.randint(n_min, n_max)) +def make_timestamps(t0: float, n_min: int = 8, n_max: int = 20) -> np.ndarray: + return np.linspace(t0, t0 + 1.0, np.random.randint(n_min, n_max)) @pytest.fixture @@ -122,18 +122,21 @@ def experiment_with_results() -> Experiment: target_pos = points_on_circle( trial["num_targets"], trial["target_distance"], include_centre=False ) + t0 = 0.0 for pos in target_pos: - to_target_timestamps.append(make_timestamps()) + to_target_timestamps.append(make_timestamps(t0)) to_target_mouse_positions.append( make_mouse_positions(pos, to_target_timestamps[-1]) ) to_target_success.append(True) + t0 = to_target_timestamps[-1][-1] + 1.0 / 60.0 if not trial["automove_cursor_to_center"]: - to_center_timestamps.append(make_timestamps()) + to_center_timestamps.append(make_timestamps(t0)) to_center_mouse_positions.append( list(reversed(make_mouse_positions(pos, to_center_timestamps[-1]))) ) to_center_success.append(True) + t0 = to_center_timestamps[-1][-1] + 1.0 / 60.0 trial_handler.addData("target_indices", np.array(range(len(target_pos)))) trial_handler.addData("target_pos", np.array(target_pos)) trial_handler.addData( diff --git a/tests/test_experiment.py b/tests/test_experiment.py index 8c29b892..94231171 100644 --- a/tests/test_experiment.py +++ b/tests/test_experiment.py @@ -231,13 +231,9 @@ def test_experiment_to_excel_trial_data_format( if dest is not None: # target data from target display time # get correct timestamps - ts = stats[f"to_{dest}_timestamps"][i_row] - # exclude any negative times from before target display - ts_correct = ts[ts >= 0] + ts_correct = stats[f"to_{dest}_timestamps"][i_row] # get imported timestamps ts = df_target.timestamps.to_numpy() - # subtract first timepoint to reset any offset to zero - ts = ts - ts[0] assert np.allclose(ts, ts_correct) # get correct mouse positions xys = stats[f"to_{dest}_mouse_positions"][i_row] diff --git a/tests/test_stat.py b/tests/test_stat.py index 768da30d..faf7b018 100644 --- a/tests/test_stat.py +++ b/tests/test_stat.py @@ -75,12 +75,15 @@ def test_reaction_movement_time() -> None: ]: n = len(times) for n_zeros in range(1, n): - positions = [[-1e-13, 1e-14]] * n_zeros + [[1, 1]] * (n - n_zeros) - reaction_time = times[n_zeros] - assert np.allclose( - vstt.stat._reaction_time(np.array(times), np.array(positions)), - [reaction_time], - ) + for n_before_visible in range(1, n): + positions = [[-1e-13, 1e-14]] * n_zeros + [[1, 1]] * (n - n_zeros) + reaction_time = times[n_zeros] - times[n_before_visible] + assert np.allclose( + vstt.stat._reaction_time( + np.array(times), np.array(positions), n_before_visible + ), + [reaction_time], + ) def test_rmse() -> None: diff --git a/tests/test_task.py b/tests/test_task.py index 1e73d753..b6150e6d 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -2,12 +2,14 @@ import threading from time import sleep +from typing import Dict from typing import List from typing import Tuple import gui_test_utils as gtu import numpy as np import pyautogui +import pytest import vstt from psychopy.visual.window import Window from vstt.experiment import Experiment @@ -74,14 +76,23 @@ def test_task_no_trials(window: Window) -> None: assert experiment_no_trials.trial_handler_with_results is None -def test_task_automove_to_center( - experiment_no_results: Experiment, window: Window +@pytest.mark.parametrize( + "trial_settings", + [ + {"automove_cursor_to_center": True}, + {"add_central_target": False, "automove_cursor_to_center": False, "weight": 1}, + ], + ids=["automove_to_center", "no_central_target"], +) +def test_task( + experiment_no_results: Experiment, window: Window, trial_settings: Dict ) -> None: target_pixels = [] experiment_no_results.has_unsaved_changes = False assert experiment_no_results.trial_handler_with_results is None for trial in experiment_no_results.trial_list: - trial["automove_cursor_to_center"] = True + for key, value in trial_settings.items(): + trial[key] = value # type: ignore target_pixels.append( [ gtu.pos_to_pixels(pos) @@ -100,52 +111,15 @@ def test_task_automove_to_center( assert success is True assert experiment_no_results.has_unsaved_changes is True assert experiment_no_results.trial_handler_with_results is not None + data = experiment_no_results.trial_handler_with_results.data # check that we hit all the targets without timing out - for timestamps in experiment_no_results.trial_handler_with_results.data[ - "to_target_timestamps" - ][0][0]: + for timestamps in data["to_target_timestamps"][0][0]: assert ( - timestamps[-1] - < 0.5 * experiment_no_results.trial_list[0]["target_duration"] - ) - - -def test_task_no_central_target( - experiment_no_results: Experiment, window: Window -) -> None: - target_pixels = [] - experiment_no_results.has_unsaved_changes = False - assert experiment_no_results.trial_handler_with_results is None - for trial in experiment_no_results.trial_list: - trial["add_central_target"] = False - trial["automove_cursor_to_center"] = False - trial["weight"] = 1 - target_pixels.append( - [ - gtu.pos_to_pixels(pos) - for pos in points_on_circle( - trial["num_targets"], - trial["target_distance"], - include_centre=False, - ) - ] - ) - do_task_thread = launch_do_task(experiment_no_results, target_pixels) - task = MotorTask(experiment_no_results, window) - success = task.run() - do_task_thread.join() - # task ran successfully, updated experiment with results - assert success is True - assert experiment_no_results.has_unsaved_changes is True - assert experiment_no_results.trial_handler_with_results is not None - # check that we hit all the targets without timing out - for timestamps in experiment_no_results.trial_handler_with_results.data[ - "to_target_timestamps" - ][0][0]: - assert ( - timestamps[-1] + timestamps[-1] - timestamps[0] < 0.5 * experiment_no_results.trial_list[0]["target_duration"] ) + for dest in ["target", "center"]: + assert np.all(data[f"to_{dest}_success"][0][0]) def test_task_no_automove_to_center( @@ -175,22 +149,21 @@ def test_task_no_automove_to_center( assert experiment_no_results.has_unsaved_changes is True assert experiment_no_results.trial_handler_with_results is not None # check that we hit all the targets + data = experiment_no_results.trial_handler_with_results.data for to_target_timestamps, to_center_timestamps in zip( - experiment_no_results.trial_handler_with_results.data["to_target_timestamps"][ - 0 - ][0], - experiment_no_results.trial_handler_with_results.data["to_center_timestamps"][ - 0 - ][0], + data["to_target_timestamps"][0][0], + data["to_center_timestamps"][0][0], ): assert ( - to_target_timestamps[-1] + to_target_timestamps[-1] - to_target_timestamps[0] < 0.5 * experiment_no_results.trial_list[0]["target_duration"] ) assert ( - to_center_timestamps[-1] + to_center_timestamps[-1] - to_target_timestamps[0] < 0.5 * experiment_no_results.trial_list[0]["target_duration"] ) + for dest in ["target", "center"]: + assert np.all(data[f"to_{dest}_success"][0][0]) def test_task_fixed_intervals_no_user_input(window: Window) -> None: @@ -217,20 +190,19 @@ def test_task_fixed_intervals_no_user_input(window: Window) -> None: assert experiment.trial_handler_with_results is not None # check that we failed to hit all targets expected_success = np.full((trial["num_targets"],), False) + data = experiment.trial_handler_with_results.data for success_name in ["to_target_success", "to_center_success"]: - assert np.all( - experiment.trial_handler_with_results.data[success_name][0][0] - == expected_success - ) - # first to_target timestamps should start at approx -target_duration, - # at 0 the first target is displayed for target_duration secs, so it should end at approx target_duration - # subsequent ones should start at approx 0.0 since previous target is not reached, and end at approx target_duration - all_to_target_timestamps = experiment.trial_handler_with_results.data[ - "to_target_timestamps" - ][0][0] - delta = 0.05 * target_duration - assert abs(all_to_target_timestamps[0][0] + target_duration) < delta - assert abs(all_to_target_timestamps[0][-1] - target_duration) < delta - for to_target_timestamps in all_to_target_timestamps[1:]: - assert abs(to_target_timestamps[0]) < delta - assert abs(to_target_timestamps[-1] - target_duration) < delta + assert np.all(data[success_name][0][0] == expected_success) + # first to_target timestamps should start at ~0, + # at ~target_duration the first target is displayed for target_duration secs, + # subsequent ones should be displayed every ~target_duration starting from ~2*target_duration + all_to_target_timestamps = data["to_target_timestamps"][0][0] + # require timestamps to be accurate within 0.1s + # On CI the fps / dropped frames can be erratic so this is a conservative value. + # If running on real hardware these should actually be within 2/fps + delta = 0.1 + assert abs(all_to_target_timestamps[0][0]) < delta + assert abs(all_to_target_timestamps[0][-1] - 2 * target_duration) < delta + for count, to_target_timestamps in enumerate(all_to_target_timestamps[1:]): + assert abs(to_target_timestamps[0] - (count + 2) * target_duration) < delta + assert abs(to_target_timestamps[-1] - (count + 3) * target_duration) < delta