|
5 | 5 | mode via :class:`~psyflow.sim.adapter.ResponderAdapter`. |
6 | 6 | """ |
7 | 7 |
|
8 | | -from psychopy import core, visual, logging, sound |
| 8 | +from psychopy import core, event, visual, logging, sound |
9 | 9 | from psychopy.hardware.keyboard import Keyboard |
10 | 10 | from typing import Callable, Optional, List, Dict, Any, Sequence, TypeAlias, Union |
11 | 11 | import importlib |
@@ -1005,6 +1005,233 @@ def capture_response( |
1005 | 1005 |
|
1006 | 1006 | self.log_unit() |
1007 | 1007 | return self |
| 1008 | + |
| 1009 | + def capture_pointer_sequence( |
| 1010 | + self, |
| 1011 | + targets: dict[str, visual.BaseVisualStim], |
| 1012 | + max_selections: int, |
| 1013 | + duration: float, |
| 1014 | + onset_trigger: int = None, |
| 1015 | + selection_trigger: int | dict[str, int] = None, |
| 1016 | + complete_trigger: int = None, |
| 1017 | + timeout_trigger: int = None, |
| 1018 | + highlight_targets: dict[str, visual.BaseVisualStim] | None = None, |
| 1019 | + highlight_duration: float = 0.10, |
| 1020 | + ) -> "StimUnit": |
| 1021 | + """Collect an ordered pointer/touch sequence from named visual targets. |
| 1022 | +
|
| 1023 | + Target hit-testing, timing, trigger emission, simulation injection, and |
| 1024 | + stage data remain framework-owned. Tasks supply only named targets and |
| 1025 | + the required sequence length. |
| 1026 | + """ |
| 1027 | + if not isinstance(targets, dict) or not targets: |
| 1028 | + raise ValueError("targets must be a non-empty mapping of names to visual stimuli") |
| 1029 | + if any(not isinstance(stim, visual.BaseVisualStim) for stim in targets.values()): |
| 1030 | + raise TypeError("all pointer targets must be PsychoPy visual stimuli") |
| 1031 | + |
| 1032 | + required = int(max_selections) |
| 1033 | + if required <= 0: |
| 1034 | + raise ValueError("max_selections must be a positive integer") |
| 1035 | + |
| 1036 | + nominal = float(duration) |
| 1037 | + if nominal <= 0: |
| 1038 | + raise ValueError("duration must be positive") |
| 1039 | + used, n_frames, scaled = self._qa_scale_duration(nominal) |
| 1040 | + if scaled: |
| 1041 | + self.set_state(duration_nominal=nominal, duration_scaled=used) |
| 1042 | + self.set_state(duration=used, target_names=list(targets), max_selections=required) |
| 1043 | + |
| 1044 | + visual_stims = [stim for stim in self.stimuli if hasattr(stim, "draw") and callable(stim.draw)] |
| 1045 | + for stim in visual_stims: |
| 1046 | + stim.draw() |
| 1047 | + self.win.callOnFlip(self.clock.reset) |
| 1048 | + self.win.callOnFlip(self._stamp_onset, onset_trigger) |
| 1049 | + self._emit_trigger( |
| 1050 | + onset_trigger, |
| 1051 | + when="flip", |
| 1052 | + wait=False, |
| 1053 | + name=f"{self.label}_onset", |
| 1054 | + meta={"kind": "onset"}, |
| 1055 | + ) |
| 1056 | + if n_frames == 1: |
| 1057 | + self.win.callOnFlip(self._stamp_close) |
| 1058 | + flip_time = self.win.flip() |
| 1059 | + self.set_state(flip_time=flip_time) |
| 1060 | + |
| 1061 | + ctx = get_context() |
| 1062 | + responder = None |
| 1063 | + if ctx is not None and ctx.mode in ("qa", "sim") and getattr(ctx, "responder", None) is not None: |
| 1064 | + responder = ctx.responder |
| 1065 | + |
| 1066 | + simulated_actions: list[tuple[str, float]] = [] |
| 1067 | + if responder is not None: |
| 1068 | + adapter = ResponderAdapter( |
| 1069 | + policy=str(getattr(getattr(ctx, "config", None), "sim_policy", "warn") or "warn"), |
| 1070 | + default_rt_s=float(getattr(getattr(ctx, "config", None), "default_rt_s", 0.2) or 0.2), |
| 1071 | + clamp_rt=bool(getattr(getattr(ctx, "config", None), "clamp_rt", False)), |
| 1072 | + logger=getattr(ctx, "sim_logger", None), |
| 1073 | + session=getattr(ctx, "session", None), |
| 1074 | + ) |
| 1075 | + base_factors = dict(self.get_state("task_factors", None) or {}) |
| 1076 | + previous_rt = 0.0 |
| 1077 | + for selection_index in range(required): |
| 1078 | + obs = Observation( |
| 1079 | + mode=getattr(ctx, "mode", "qa"), |
| 1080 | + trial_id=self.get_state("trial_id", self.get_state("trial_index", None)), |
| 1081 | + block_id=self.get_state("block_id", None), |
| 1082 | + phase=self.label, |
| 1083 | + deadline_s=used, |
| 1084 | + response_window_open=True, |
| 1085 | + response_window_s=used, |
| 1086 | + valid_keys=list(targets), |
| 1087 | + t_phase_onset=self.get_state("onset_time", None), |
| 1088 | + t_phase_onset_global=self.get_state("onset_time_global", None), |
| 1089 | + stim_id=self.get_state("stim_id", None), |
| 1090 | + stim_features=self.get_state("stim_features", None), |
| 1091 | + condition_id=self.get_state("condition_id", None), |
| 1092 | + task_factors={**base_factors, "selection_index": selection_index}, |
| 1093 | + ) |
| 1094 | + handled = adapter.handle_response(obs, responder) |
| 1095 | + action = handled.used_action |
| 1096 | + if action.key is None or action.rt_s is None: |
| 1097 | + break |
| 1098 | + action_rt = max(float(action.rt_s), previous_rt + (0.05 if simulated_actions else 0.0)) |
| 1099 | + if action_rt > used: |
| 1100 | + break |
| 1101 | + simulated_actions.append((str(action.key), action_rt)) |
| 1102 | + previous_rt = action_rt |
| 1103 | + |
| 1104 | + original_mouse_visible = bool(getattr(self.win, "mouseVisible", False)) |
| 1105 | + mouse = None |
| 1106 | + previous_pressed = False |
| 1107 | + if responder is None: |
| 1108 | + self.win.mouseVisible = True |
| 1109 | + mouse = event.Mouse(win=self.win, visible=True) |
| 1110 | + mouse.clickReset() |
| 1111 | + |
| 1112 | + selections: list[str] = [] |
| 1113 | + selection_times: list[float] = [] |
| 1114 | + selection_positions: list[list[float] | None] = [] |
| 1115 | + selection_triggers: list[int | None] = [] |
| 1116 | + next_simulated = 0 |
| 1117 | + last_highlight: str | None = None |
| 1118 | + highlight_until = 0.0 |
| 1119 | + |
| 1120 | + def accept_selection(name: str, rt: float, position: list[float] | None) -> None: |
| 1121 | + nonlocal last_highlight, highlight_until |
| 1122 | + selections.append(name) |
| 1123 | + selection_times.append(float(rt)) |
| 1124 | + selection_positions.append(position) |
| 1125 | + code = selection_trigger.get(name) if isinstance(selection_trigger, dict) else selection_trigger |
| 1126 | + selection_triggers.append(code) |
| 1127 | + self._emit_trigger( |
| 1128 | + code, |
| 1129 | + when="now", |
| 1130 | + wait=True, |
| 1131 | + name=f"{self.label}_selection", |
| 1132 | + meta={"kind": "pointer_selection", "target": name, "index": len(selections) - 1}, |
| 1133 | + ) |
| 1134 | + last_highlight = name |
| 1135 | + highlight_until = float(rt) + max(0.0, float(highlight_duration)) |
| 1136 | + |
| 1137 | + try: |
| 1138 | + for frame_i in range(max(0, n_frames - 1)): |
| 1139 | + elapsed = float(self.clock.getTime()) |
| 1140 | + for stim in visual_stims: |
| 1141 | + stim.draw() |
| 1142 | + if ( |
| 1143 | + last_highlight is not None |
| 1144 | + and elapsed <= highlight_until |
| 1145 | + and highlight_targets is not None |
| 1146 | + and last_highlight in highlight_targets |
| 1147 | + ): |
| 1148 | + highlight_targets[last_highlight].draw() |
| 1149 | + |
| 1150 | + if frame_i == n_frames - 2: |
| 1151 | + self.win.callOnFlip(self._stamp_close) |
| 1152 | + self.win.flip() |
| 1153 | + elapsed = float(self.clock.getTime()) |
| 1154 | + |
| 1155 | + if responder is not None: |
| 1156 | + while next_simulated < len(simulated_actions) and elapsed >= simulated_actions[next_simulated][1]: |
| 1157 | + target_name, action_rt = simulated_actions[next_simulated] |
| 1158 | + accept_selection(target_name, action_rt, None) |
| 1159 | + next_simulated += 1 |
| 1160 | + if len(selections) >= required: |
| 1161 | + break |
| 1162 | + else: |
| 1163 | + pressed = bool(mouse.getPressed()[0]) if mouse is not None else False |
| 1164 | + if pressed and not previous_pressed and mouse is not None: |
| 1165 | + position = [float(value) for value in mouse.getPos()] |
| 1166 | + for target_name, target_stim in targets.items(): |
| 1167 | + if target_stim.contains(position): |
| 1168 | + accept_selection(target_name, elapsed, position) |
| 1169 | + break |
| 1170 | + previous_pressed = pressed |
| 1171 | + |
| 1172 | + if len(selections) >= required: |
| 1173 | + self._emit_trigger( |
| 1174 | + complete_trigger, |
| 1175 | + when="now", |
| 1176 | + wait=True, |
| 1177 | + name=f"{self.label}_complete", |
| 1178 | + meta={"kind": "pointer_sequence_complete", "count": len(selections)}, |
| 1179 | + ) |
| 1180 | + self.set_state(complete_trigger=complete_trigger) |
| 1181 | + break |
| 1182 | + finally: |
| 1183 | + if responder is None: |
| 1184 | + self.win.mouseVisible = original_mouse_visible |
| 1185 | + |
| 1186 | + completed = len(selections) >= required |
| 1187 | + if not completed: |
| 1188 | + self._emit_trigger( |
| 1189 | + timeout_trigger, |
| 1190 | + when="now", |
| 1191 | + wait=True, |
| 1192 | + name=f"{self.label}_timeout", |
| 1193 | + meta={"kind": "timeout", "count": len(selections)}, |
| 1194 | + ) |
| 1195 | + |
| 1196 | + last_rt = selection_times[-1] if selection_times else None |
| 1197 | + first_rt = selection_times[0] if selection_times else None |
| 1198 | + onset_time_global = self.get_state("onset_time_global", None) |
| 1199 | + self.set_state( |
| 1200 | + response=list(selections) if selections else None, |
| 1201 | + responses=list(selections), |
| 1202 | + response_count=len(selections), |
| 1203 | + response_times=list(selection_times), |
| 1204 | + response_positions=list(selection_positions), |
| 1205 | + selection_triggers=list(selection_triggers), |
| 1206 | + key_press=bool(selections), |
| 1207 | + completed=completed, |
| 1208 | + timed_out=not completed, |
| 1209 | + first_rt=first_rt, |
| 1210 | + rt=last_rt, |
| 1211 | + response_time=last_rt, |
| 1212 | + response_time_global=(onset_time_global + last_rt) if onset_time_global is not None and last_rt is not None else None, |
| 1213 | + timeout_trigger=timeout_trigger if not completed else None, |
| 1214 | + ) |
| 1215 | + if self.get_state("close_time", None) is None: |
| 1216 | + self._stamp_close() |
| 1217 | + |
| 1218 | + if responder is not None and hasattr(responder, "on_feedback"): |
| 1219 | + try: |
| 1220 | + responder.on_feedback( |
| 1221 | + Feedback( |
| 1222 | + trial_id=self.get_state("trial_id", self.get_state("trial_index", "unknown")), |
| 1223 | + phase=self.label, |
| 1224 | + outcome="complete" if completed else "timeout", |
| 1225 | + reward=None, |
| 1226 | + meta={"responses": list(selections), "response_times": list(selection_times)}, |
| 1227 | + ) |
| 1228 | + ) |
| 1229 | + except Exception: |
| 1230 | + pass |
| 1231 | + |
| 1232 | + self.log_unit() |
| 1233 | + return self |
| 1234 | + |
1008 | 1235 | def wait_and_continue( |
1009 | 1236 | self, |
1010 | 1237 | keys: list[str] = ["space"], |
|
0 commit comments