Skip to content

Commit d739af2

Browse files
committed
feat: add pointer sequence capture
1 parent f1373c4 commit d739af2

1 file changed

Lines changed: 228 additions & 1 deletion

File tree

psyflow/StimUnit.py

Lines changed: 228 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
mode via :class:`~psyflow.sim.adapter.ResponderAdapter`.
66
"""
77

8-
from psychopy import core, visual, logging, sound
8+
from psychopy import core, event, visual, logging, sound
99
from psychopy.hardware.keyboard import Keyboard
1010
from typing import Callable, Optional, List, Dict, Any, Sequence, TypeAlias, Union
1111
import importlib
@@ -1005,6 +1005,233 @@ def capture_response(
10051005

10061006
self.log_unit()
10071007
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+
10081235
def wait_and_continue(
10091236
self,
10101237
keys: list[str] = ["space"],

0 commit comments

Comments
 (0)