From cbf5c8f4678c8207d2037edaf70bfb76835be06b Mon Sep 17 00:00:00 2001 From: mtringi Date: Thu, 30 Oct 2025 13:43:58 -0700 Subject: [PATCH 01/62] play three separate tones for readyset go task and add color change --- built_in_tasks/target_capture_task_xt.py | 26 ++++++++++++++++++++++++ built_in_tasks/target_graphics.py | 4 ++++ tests/test_tasks.py | 4 ++-- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/built_in_tasks/target_capture_task_xt.py b/built_in_tasks/target_capture_task_xt.py index e30b73dc..392a5133 100644 --- a/built_in_tasks/target_capture_task_xt.py +++ b/built_in_tasks/target_capture_task_xt.py @@ -1009,8 +1009,34 @@ def update_report_stats(self): #add holds completed metric to report stats ### State Functions ### def _start_prepbuff(self): self.sync_event('CUE') #integer code 112 + self.prep_start_time = self.get_time() + self.audio_space = 0.5 #seconds between tones + self.set_played = False self.ready_set_player.play() + def _while_prepbuff(self): + self.time_in_prep = self.get_time() - self.prep_start_time + if self.time_in_prep >= self.audio_space and not self.set_played: + self.ready_set_player.play() + self.targets[0].cue_set_tone() + self.set_played = True + #print(self.time_in_prep) + + def _while_delay(self): + super()._while_delay() + self.time_in_delay = self.get_time() - self.prep_start_time + if self.time_in_delay >= self.audio_space and not self.set_played: + self.ready_set_player.play() + self.targets[0].cue_set_tone() + self.set_played = True + #print(self.time_in_delay) + + def _end_delay(self): + super()._end_delay() + self.go_time = self.get_time() - self.prep_start_time + self.ready_set_player.play() + #print(self.go_time) + def _start_leave_center(self): self.sync_event('CURSOR_LEAVE_TARGET') #integer code 96 self.targets[0].cue_fixation() #turn center target blue diff --git a/built_in_tasks/target_graphics.py b/built_in_tasks/target_graphics.py index 27490397..3a78e496 100644 --- a/built_in_tasks/target_graphics.py +++ b/built_in_tasks/target_graphics.py @@ -16,6 +16,7 @@ BLUE = (0.,0.,1.,0.75) GOLD = (1., 0.843, 0., 0.5) YELLOW = (1,1,0,0.75) +ORANGE = (1,0.502,0.,0.75) mm_per_cm = 1./10 target_colors = { @@ -75,6 +76,9 @@ def cue_trial_start(self): def cue_fixation(self): self.sphere.color = BLUE + + def cue_set_tone(self): + self.sphere.color = ORANGE def cue_trial_end_success(self): self.sphere.color = GREEN diff --git a/tests/test_tasks.py b/tests/test_tasks.py index d35d1c7e..01f17a70 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -40,8 +40,8 @@ class TestManualControlTasks(unittest.TestCase): @unittest.skip("") def test_readysetgo(self): seq = ManualControl.centerout_2D() - exp = init_exp(ReadySetGoTask, [MouseControl, Window2D], seq, prepbuff_time = 0.2, - delay_time = 0.8, mustmv_time = 0.4,ready_set_sound = 'tones.wav', + exp = init_exp(ReadySetGoTask, [MouseControl, Window2D], seq, prepbuff_time = 0.8, + delay_time = 0.2, mustmv_time = 0.4,ready_set_sound = 'click.wav', tooslow_penalty_sound = 'buzzer.wav', window_size=(1200,800), fullscreen=False) #exp.rotation = 'yzx' From 427ca2dcd5210750860c6eeff75c4054352d404f Mon Sep 17 00:00:00 2001 From: mtringi Date: Fri, 31 Oct 2025 15:26:00 -0700 Subject: [PATCH 02/62] add toneplayer class to riglib audio. edit readyset task so that tones are generated and played instead of wav file. Add tone length and tone freq features. --- built_in_tasks/target_capture_task_xt.py | 31 +++++++++++++++++++----- riglib/audio/audio.py | 25 ++++++++++++++++++- tests/test_tasks.py | 2 +- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/built_in_tasks/target_capture_task_xt.py b/built_in_tasks/target_capture_task_xt.py index 392a5133..61b18533 100644 --- a/built_in_tasks/target_capture_task_xt.py +++ b/built_in_tasks/target_capture_task_xt.py @@ -9,7 +9,7 @@ from .target_graphics import * from .target_capture_task import ScreenTargetCapture from riglib.experiment import traits -from riglib.audio import AudioPlayer +from riglib.audio import AudioPlayer, TonePlayer audio_path = os.path.join(os.path.dirname(__file__), '../riglib/audio') @@ -936,10 +936,15 @@ class ScreenTargetCapture_ReadySet(ScreenTargetCapture): tooslow_penalty_sound = traits.OptionsList(files, desc="File in riglib/audio to play on each must move penalty") #hold penalty is normally incorrect.wav shadow_periph_radius = traits.Float(0.5, desc = 'additional radius for peripheral target') periph_hold = traits.Float(0.2, desc = "Hold time for peripheral target") + readyset_freq = traits.Float(320, desc="Frequency of the ready-set tone") + go_freq = traits.Float(440, desc="Frequency of the go tone") + tone_duration = traits.Float(0.1, desc="Duration of the ready-set and go tones") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.ready_set_player = AudioPlayer(self.ready_set_sound) + self.readyset_tone = TonePlayer(frequency=self.readyset_freq, duration=self.tone_duration) + self.go_tone = TonePlayer(frequency=self.go_freq, duration=self.tone_duration) self.tooslow_penalty_player = AudioPlayer(self.tooslow_penalty_sound) self.pseudo_reward = 0 @@ -1012,29 +1017,35 @@ def _start_prepbuff(self): self.prep_start_time = self.get_time() self.audio_space = 0.5 #seconds between tones self.set_played = False - self.ready_set_player.play() + #self.ready_set_player.play() + self.readyset_tone.play() def _while_prepbuff(self): self.time_in_prep = self.get_time() - self.prep_start_time if self.time_in_prep >= self.audio_space and not self.set_played: - self.ready_set_player.play() + self.readyset_tone.play() + #self.ready_set_player.play() self.targets[0].cue_set_tone() self.set_played = True #print(self.time_in_prep) + #self.sync_event('CUE') def _while_delay(self): super()._while_delay() self.time_in_delay = self.get_time() - self.prep_start_time if self.time_in_delay >= self.audio_space and not self.set_played: - self.ready_set_player.play() + #self.ready_set_player.play() + self.readyset_tone.play() self.targets[0].cue_set_tone() self.set_played = True #print(self.time_in_delay) + #self.sync_event('CUE') def _end_delay(self): super()._end_delay() self.go_time = self.get_time() - self.prep_start_time - self.ready_set_player.play() + #self.ready_set_player.play() + self.go_tone.play() #print(self.go_time) def _start_leave_center(self): @@ -1047,11 +1058,15 @@ def _start_hold_penalty(self): if hasattr(super(), '_start_hold_penalty'): super()._start_hold_penalty() self.ready_set_player.stop() + self.readyset_tone.stop() + self.go_tone.stop() def _start_delay_penalty(self): if hasattr(super(), '_start_delay_penalty'): super()._start_delay_penalty() self.ready_set_player.stop() + self.readyset_tone.stop() + self.go_tone.stop() def _start_timeout_penalty(self): self.pseudo_success() #run before increment trials to prevent reseting of trial index @@ -1062,6 +1077,8 @@ def _start_tooslow_penalty(self): self.sync_event('OTHER_PENALTY') #integer code 79 self.tooslow_penalty_player.play() self.ready_set_player.stop() + self.readyset_tone.stop() + self.go_tone.stop() self.jack_count = 0 # # Hide targets for target in self.targets: @@ -1080,4 +1097,6 @@ def pseudo_success(self): #function to measure almost success def _start_pause(self): super()._start_pause() - self.ready_set_player.stop() \ No newline at end of file + self.ready_set_player.stop() + self.readyset_tone.stop() + self.go_tone.stop() \ No newline at end of file diff --git a/riglib/audio/audio.py b/riglib/audio/audio.py index 52b633dc..e8414ec3 100644 --- a/riglib/audio/audio.py +++ b/riglib/audio/audio.py @@ -1,5 +1,6 @@ import pygame import os +import numpy as np audio_path = os.path.dirname(__file__) @@ -18,4 +19,26 @@ def play(self): self.effect.play() def stop(self): - self.effect.stop() \ No newline at end of file + self.effect.stop() + +class TonePlayer(): + + def __init__(self, frequency = 440, duration = 0.1, sample_rate = 44100): + self.frequency = frequency + self.duration = duration + self.sample_rate = sample_rate + self.sound = self.generate_tone() + + def generate_tone(self): + # Generate a tone using numpy + t = np.linspace(0, self.duration, int(self.sample_rate * self.duration), False) + tone = 0.5 * np.sin(2 * np.pi * self.frequency * t) + stereo_tone = np.column_stack((tone, tone)) # Make it stereo + sound = pygame.sndarray.make_sound((stereo_tone * 32767).astype(np.int16)) + return sound + + def play(self): + self.sound.play() + + def stop(self): + self.sound.stop() \ No newline at end of file diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 01f17a70..7a560333 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -41,7 +41,7 @@ class TestManualControlTasks(unittest.TestCase): def test_readysetgo(self): seq = ManualControl.centerout_2D() exp = init_exp(ReadySetGoTask, [MouseControl, Window2D], seq, prepbuff_time = 0.8, - delay_time = 0.2, mustmv_time = 0.4,ready_set_sound = 'click.wav', + delay_time = 0.2, mustmv_time = 0.4,ready_set_sound = 'click.wav', readyset_freq = 320, go_freq = 400, tooslow_penalty_sound = 'buzzer.wav', window_size=(1200,800), fullscreen=False) #exp.rotation = 'yzx' From c41e4df84815097e309d8644f4b6364727600acf Mon Sep 17 00:00:00 2001 From: katherineperks Date: Mon, 3 Nov 2025 17:25:57 -0800 Subject: [PATCH 03/62] try to send a nonblocking POST request --- riglib/tablet_reward.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/riglib/tablet_reward.py b/riglib/tablet_reward.py index d5318f01..945f93dc 100644 --- a/riglib/tablet_reward.py +++ b/riglib/tablet_reward.py @@ -1,10 +1,11 @@ from .gpio import ArduinoGPIO from multiprocessing import Process from riglib import singleton -#from pyfirmata import Arduino, util import traceback import time import os +import requests +import threading log_path = os.path.join(os.path.dirname(__file__), '../log/reward.log') @@ -36,18 +37,23 @@ def open(): import builtins traceback.print_exc() -import requests +def send_request(url): + try: + requests.post(url, timeout=3) + except: + traceback.print_exc() + class RemoteReward(): def __init__(self): self.hostName = "192.168.0.200" self.serverPort = 8080 - + def trigger(self, ip_address): url = f"http://{ip_address}:{self.serverPort}" print(url) - try: - requests.post(url, timeout=3) - except: - traceback.print_exc() + + thread = threading.Thread(target=send_request, args=(url)) + thread.daemon = True + thread.start() From cbe67c4488cc7587487857463c6f96247a2f01b4 Mon Sep 17 00:00:00 2001 From: katherineperks Date: Mon, 3 Nov 2025 17:40:01 -0800 Subject: [PATCH 04/62] change printed url --- riglib/tablet_reward.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/riglib/tablet_reward.py b/riglib/tablet_reward.py index 945f93dc..c505dc28 100644 --- a/riglib/tablet_reward.py +++ b/riglib/tablet_reward.py @@ -47,13 +47,13 @@ class RemoteReward(): def __init__(self): - self.hostName = "192.168.0.200" + self.hostName = "192.168.0.150" self.serverPort = 8080 - def trigger(self, ip_address): - url = f"http://{ip_address}:{self.serverPort}" + def trigger(self, ip_address, port_value): + url = f"http://{ip_address}:{port_value}" print(url) thread = threading.Thread(target=send_request, args=(url)) thread.daemon = True - thread.start() + thread.start() \ No newline at end of file From a0d4681fbbe1d3bf0727d34b6e5d6f7d4ceea2c5 Mon Sep 17 00:00:00 2001 From: leo Date: Mon, 3 Nov 2025 17:59:39 -0800 Subject: [PATCH 05/62] delete hard-coded freeze time at reward onset --- features/reward_features.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/features/reward_features.py b/features/reward_features.py index 312bcbe8..f87576b1 100644 --- a/features/reward_features.py +++ b/features/reward_features.py @@ -119,8 +119,7 @@ def _start_reward(self): if self.reportstats['Reward #'] % self.trials_per_reward == 0: for _ in range(self.pellets_per_reward): # call trigger num of pellets_per_reward time - self.reward.trigger(self.ip_address) - time.sleep(0.5) # wait for 0.5 seconds + self.reward.trigger(self.ip_address, self.port_value) def _end_reward(self): if hasattr(super(RewardSystem, self), '_end_reward'): From 0e6cb541a1243be6b5795bc4fef7fa69f9525f21 Mon Sep 17 00:00:00 2001 From: Katherine Date: Tue, 4 Nov 2025 13:42:39 -0800 Subject: [PATCH 06/62] set penalty index --- features/eyetracker_features.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/eyetracker_features.py b/features/eyetracker_features.py index db413a16..d80a9c6c 100644 --- a/features/eyetracker_features.py +++ b/features/eyetracker_features.py @@ -276,7 +276,8 @@ def _start_hold(self): def _start_fixation_penalty(self): self._increment_tries() self.sync_event('FIXATION_PENALTY') - + self.penalty_index = 1 + # Hide targets for target in self.targets: target.hide() From 51ff23459669ec34a0b3ed5cff24b3b5eb1a97a1 Mon Sep 17 00:00:00 2001 From: leo Date: Tue, 4 Nov 2025 14:42:53 -0800 Subject: [PATCH 07/62] fix bug in how arg is passed --- riglib/tablet_reward.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/riglib/tablet_reward.py b/riglib/tablet_reward.py index c505dc28..e1d2e630 100644 --- a/riglib/tablet_reward.py +++ b/riglib/tablet_reward.py @@ -39,9 +39,16 @@ def open(): def send_request(url): try: - requests.post(url, timeout=3) - except: - traceback.print_exc() + response = requests.post(url, timeout=3) + print(f"Request to {url} completed with status code: {response.status_code}") + except requests.exceptions.RequestException as e: + print(f"Error sending request to {url}: {e}") + +def send_nonblocking_request(url): + thread = threading.Thread(target=send_request, args=(url,)) + thread.daemon = True + thread.start() + print("Non-blocking POST request initiated") class RemoteReward(): @@ -50,10 +57,7 @@ def __init__(self): self.hostName = "192.168.0.150" self.serverPort = 8080 - def trigger(self, ip_address, port_value): + def trigger(self, ip_address, port_value): # set some default so the manual reward button works url = f"http://{ip_address}:{port_value}" - print(url) - - thread = threading.Thread(target=send_request, args=(url)) - thread.daemon = True - thread.start() \ No newline at end of file + # url = "http://192.168.0.200:9000" + send_nonblocking_request(url) \ No newline at end of file From 0b1da7ebae95545560d5d24950c1c998f5645b4a Mon Sep 17 00:00:00 2001 From: leo Date: Tue, 4 Nov 2025 15:31:21 -0800 Subject: [PATCH 08/62] system triggers but only works for 1x reward --- riglib/tablet_reward.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/riglib/tablet_reward.py b/riglib/tablet_reward.py index e1d2e630..fa6cdd92 100644 --- a/riglib/tablet_reward.py +++ b/riglib/tablet_reward.py @@ -57,7 +57,6 @@ def __init__(self): self.hostName = "192.168.0.150" self.serverPort = 8080 - def trigger(self, ip_address, port_value): # set some default so the manual reward button works - url = f"http://{ip_address}:{port_value}" - # url = "http://192.168.0.200:9000" + def trigger(self, ip_address): # set some default so the manual reward button works + url = f"http://{ip_address}:{self.serverPort}" send_nonblocking_request(url) \ No newline at end of file From fec2678ce3cf11431abe6fc3217b64b9382ce173 Mon Sep 17 00:00:00 2001 From: leo Date: Tue, 4 Nov 2025 15:35:29 -0800 Subject: [PATCH 09/62] system triggers but only works for 1x reward --- features/reward_features.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/reward_features.py b/features/reward_features.py index f87576b1..bacbeb5d 100644 --- a/features/reward_features.py +++ b/features/reward_features.py @@ -119,7 +119,7 @@ def _start_reward(self): if self.reportstats['Reward #'] % self.trials_per_reward == 0: for _ in range(self.pellets_per_reward): # call trigger num of pellets_per_reward time - self.reward.trigger(self.ip_address, self.port_value) + self.reward.trigger(self.ip_address) def _end_reward(self): if hasattr(super(RewardSystem, self), '_end_reward'): From b8798dfb8268bbba44af57161aba29d5c047ef7e Mon Sep 17 00:00:00 2001 From: katherineperks Date: Tue, 4 Nov 2025 17:50:45 -0800 Subject: [PATCH 10/62] system triggers for any number of rewards --- features/reward_features.py | 8 +++++--- riglib/tablet_reward.py | 22 ++++++++++++---------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/features/reward_features.py b/features/reward_features.py index bacbeb5d..9e7141ce 100644 --- a/features/reward_features.py +++ b/features/reward_features.py @@ -94,7 +94,9 @@ class PelletReward(RewardSystem): Trigger pellet rewards. ''' pellets_per_reward = traits.Int(1, desc='The number of pellets to dispense per reward.') - port_value = traits.Int(8000, desc='The port value to identify which tablet is running.') + port_value = traits.Int(8000, desc='The port value to identify which tablet is running.') + + # TODO: exclude reward_time trait and replace it with 0.5s x pellets_per_reward def __init__(self, *args, **kwargs): from riglib.tablet_reward import RemoteReward @@ -118,8 +120,8 @@ def _start_reward(self): self.reportstats['Reward #'] += 1 if self.reportstats['Reward #'] % self.trials_per_reward == 0: - for _ in range(self.pellets_per_reward): # call trigger num of pellets_per_reward time - self.reward.trigger(self.ip_address) + # for i in range(self.pellets_per_reward): # call trigger as many times as pellets_per_reward + self.reward.trigger(self.ip_address, self.pellets_per_reward) def _end_reward(self): if hasattr(super(RewardSystem, self), '_end_reward'): diff --git a/riglib/tablet_reward.py b/riglib/tablet_reward.py index fa6cdd92..1ff78777 100644 --- a/riglib/tablet_reward.py +++ b/riglib/tablet_reward.py @@ -37,15 +37,17 @@ def open(): import builtins traceback.print_exc() -def send_request(url): - try: - response = requests.post(url, timeout=3) - print(f"Request to {url} completed with status code: {response.status_code}") - except requests.exceptions.RequestException as e: - print(f"Error sending request to {url}: {e}") +def send_request(url, n_trigger): + for i in range(n_trigger): + try: + response = requests.post(url, timeout=3) + print(f"Request to {url} completed with status code: {response.status_code}") + except requests.exceptions.RequestException as e: + print(f"Error sending request to {url}: {e}") + time.sleep(0.5) -def send_nonblocking_request(url): - thread = threading.Thread(target=send_request, args=(url,)) +def send_nonblocking_request(url, n_trigger): + thread = threading.Thread(target=send_request, args=(url, n_trigger)) thread.daemon = True thread.start() print("Non-blocking POST request initiated") @@ -57,6 +59,6 @@ def __init__(self): self.hostName = "192.168.0.150" self.serverPort = 8080 - def trigger(self, ip_address): # set some default so the manual reward button works + def trigger(self, ip_address, n_trigger): # set some default so the manual reward button works url = f"http://{ip_address}:{self.serverPort}" - send_nonblocking_request(url) \ No newline at end of file + send_nonblocking_request(url, n_trigger) \ No newline at end of file From fc52564d66a368b583a8a52de74779ac5c6d13af Mon Sep 17 00:00:00 2001 From: katherineperks Date: Tue, 4 Nov 2025 17:54:43 -0800 Subject: [PATCH 11/62] update comments/print statements --- features/reward_features.py | 3 +-- riglib/tablet_reward.py | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/features/reward_features.py b/features/reward_features.py index 9e7141ce..b9d77560 100644 --- a/features/reward_features.py +++ b/features/reward_features.py @@ -120,8 +120,7 @@ def _start_reward(self): self.reportstats['Reward #'] += 1 if self.reportstats['Reward #'] % self.trials_per_reward == 0: - # for i in range(self.pellets_per_reward): # call trigger as many times as pellets_per_reward - self.reward.trigger(self.ip_address, self.pellets_per_reward) + self.reward.trigger(self.ip_address, self.pellets_per_reward) # triggers as many times as pellets_per_reward def _end_reward(self): if hasattr(super(RewardSystem, self), '_end_reward'): diff --git a/riglib/tablet_reward.py b/riglib/tablet_reward.py index 1ff78777..61e23022 100644 --- a/riglib/tablet_reward.py +++ b/riglib/tablet_reward.py @@ -41,16 +41,16 @@ def send_request(url, n_trigger): for i in range(n_trigger): try: response = requests.post(url, timeout=3) - print(f"Request to {url} completed with status code: {response.status_code}") + print(f"Request to {url} completed: {response.status_code}") except requests.exceptions.RequestException as e: - print(f"Error sending request to {url}: {e}") + print(f"Error sending request to {url}: {e}") # error occurs even when pellet dispenses time.sleep(0.5) def send_nonblocking_request(url, n_trigger): thread = threading.Thread(target=send_request, args=(url, n_trigger)) thread.daemon = True thread.start() - print("Non-blocking POST request initiated") + print("Request initiated") class RemoteReward(): From 714c20cf6890088ff9766c688f8d72f1b48edc4e Mon Sep 17 00:00:00 2001 From: katherineperks Date: Wed, 5 Nov 2025 11:57:06 -0800 Subject: [PATCH 12/62] update web server messages --- features/reward_features.py | 1 + reward_server/web_server.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/features/reward_features.py b/features/reward_features.py index b9d77560..14b02e41 100644 --- a/features/reward_features.py +++ b/features/reward_features.py @@ -93,6 +93,7 @@ class PelletReward(RewardSystem): ''' Trigger pellet rewards. ''' + exclude_parent_traits = ['reward_time'] pellets_per_reward = traits.Int(1, desc='The number of pellets to dispense per reward.') port_value = traits.Int(8000, desc='The port value to identify which tablet is running.') diff --git a/reward_server/web_server.py b/reward_server/web_server.py index f889527b..4d865222 100644 --- a/reward_server/web_server.py +++ b/reward_server/web_server.py @@ -24,11 +24,12 @@ def do_GET(self): def do_POST(self): tab = tablet_reward.open() tab.dispense() - print('I got rest') + print('OK') self.send_response(200) self.send_header("Content-type", "text/html") self.end_headers() + self.wfile.write(b"POST request received") if __name__ == "__main__": From 08270d71abb8f01e34e35be63ab0b802524683c8 Mon Sep 17 00:00:00 2001 From: katherineperks Date: Wed, 5 Nov 2025 17:04:52 -0800 Subject: [PATCH 13/62] set reward time based on # pellets per reward, and move hard coded sleep time --- features/reward_features.py | 5 +++-- riglib/tablet_reward.py | 16 ++++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/features/reward_features.py b/features/reward_features.py index 14b02e41..44414a0e 100644 --- a/features/reward_features.py +++ b/features/reward_features.py @@ -97,7 +97,8 @@ class PelletReward(RewardSystem): pellets_per_reward = traits.Int(1, desc='The number of pellets to dispense per reward.') port_value = traits.Int(8000, desc='The port value to identify which tablet is running.') - # TODO: exclude reward_time trait and replace it with 0.5s x pellets_per_reward + pellet_dispense_time = 0.5 # in sec, for a single pellet dispense + reward_time = pellet_dispense_time * pellets_per_reward def __init__(self, *args, **kwargs): from riglib.tablet_reward import RemoteReward @@ -121,7 +122,7 @@ def _start_reward(self): self.reportstats['Reward #'] += 1 if self.reportstats['Reward #'] % self.trials_per_reward == 0: - self.reward.trigger(self.ip_address, self.pellets_per_reward) # triggers as many times as pellets_per_reward + self.reward.trigger(self.ip_address, self.pellet_dispense_time, self.pellets_per_reward) # triggers as many times as pellets_per_reward def _end_reward(self): if hasattr(super(RewardSystem, self), '_end_reward'): diff --git a/riglib/tablet_reward.py b/riglib/tablet_reward.py index 61e23022..b5e9532f 100644 --- a/riglib/tablet_reward.py +++ b/riglib/tablet_reward.py @@ -37,17 +37,17 @@ def open(): import builtins traceback.print_exc() -def send_request(url, n_trigger): +def send_request(url, dispense_time, n_trigger): for i in range(n_trigger): try: response = requests.post(url, timeout=3) - print(f"Request to {url} completed: {response.status_code}") + print(f"Request to {url} completed: {response.status_code}") # successful response is status code 200 except requests.exceptions.RequestException as e: - print(f"Error sending request to {url}: {e}") # error occurs even when pellet dispenses - time.sleep(0.5) + print(f"Error sending request to {url}: {e}") + time.sleep(dispense_time) -def send_nonblocking_request(url, n_trigger): - thread = threading.Thread(target=send_request, args=(url, n_trigger)) +def send_nonblocking_request(url, dispense_time, n_trigger): + thread = threading.Thread(target=send_request, args=(url, dispense_time, n_trigger)) thread.daemon = True thread.start() print("Request initiated") @@ -59,6 +59,6 @@ def __init__(self): self.hostName = "192.168.0.150" self.serverPort = 8080 - def trigger(self, ip_address, n_trigger): # set some default so the manual reward button works + def trigger(self, ip_address, dispense_time, n_trigger): # TODO: set a default ip address so the manual reward button works url = f"http://{ip_address}:{self.serverPort}" - send_nonblocking_request(url, n_trigger) \ No newline at end of file + send_nonblocking_request(url, dispense_time, n_trigger) \ No newline at end of file From 79a116a94e365b022783f6917064eb85d1c4306a Mon Sep 17 00:00:00 2001 From: Katherine Date: Wed, 5 Nov 2025 17:35:41 -0800 Subject: [PATCH 14/62] Added sound for fixation penalty --- features/reward_features.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/features/reward_features.py b/features/reward_features.py index 44414a0e..6271a075 100644 --- a/features/reward_features.py +++ b/features/reward_features.py @@ -184,6 +184,11 @@ def _start_timeout_penalty(self): super()._start_timeout_penalty() self.penalty_player.play() + def _start_fixation_penalty(self): + if hasattr(super(), '_start_fixation_penalty'): + super()._start_fixation_penalty() + self.penalty_player.play() + class PenaltyAudioMulti(traits.HasTraits): ''' Separate penalty sounds for each type of penalty. From 89f50a1ab9fcba3195addf3ca2c42c2574951d90 Mon Sep 17 00:00:00 2001 From: Katherine Date: Wed, 5 Nov 2025 17:36:45 -0800 Subject: [PATCH 15/62] Fixed --- features/eyetracker_features.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/features/eyetracker_features.py b/features/eyetracker_features.py index d80a9c6c..49bf6680 100644 --- a/features/eyetracker_features.py +++ b/features/eyetracker_features.py @@ -242,8 +242,8 @@ def _test_fixation_break(self,ts): ''' if self.target_index <= 0: d = np.linalg.norm(self.calibrated_eye_pos) - return (d > self.fixation_dist) or self.pause - + return (d > self.fixation_dist) + def _test_fixation_penalty_end(self,ts): # d = np.linalg.norm(self.calibrated_eye_pos) return (ts > self.fixation_penalty_time) # (d < self.fixation_dist) and @@ -274,9 +274,13 @@ def _start_hold(self): self.num_fixation_state = 0 # because target state comes again after hold state in a trial def _start_fixation_penalty(self): + if hasattr(super(), '_start_fixation_penalty'): + super()._start_fixation_penalty() + self._increment_tries() self.sync_event('FIXATION_PENALTY') self.penalty_index = 1 + self.num_fixation_state = 0 # Hide targets for target in self.targets: From e934b1f08cb11f0e998cf38344b6f8e42f1f7eef Mon Sep 17 00:00:00 2001 From: mtringi Date: Fri, 7 Nov 2025 17:29:50 -0800 Subject: [PATCH 16/62] Leo's comments from pull request. Update the readyset task to be driven by tone logic. Add color change feature. Edit readysetmedley feature to match updated task. --- built_in_tasks/target_capture_task_xt.py | 112 ++++++++++++----------- features/__init__.py | 3 +- features/generator_features.py | 36 +++++++- riglib/audio/audio.py | 10 +- tests/test_tasks.py | 19 ++-- 5 files changed, 111 insertions(+), 69 deletions(-) diff --git a/built_in_tasks/target_capture_task_xt.py b/built_in_tasks/target_capture_task_xt.py index 61b18533..f9b4e1ba 100644 --- a/built_in_tasks/target_capture_task_xt.py +++ b/built_in_tasks/target_capture_task_xt.py @@ -928,22 +928,24 @@ class ScreenTargetCapture_ReadySet(ScreenTargetCapture): # the sum of the prepbuff & the delay time should be equal to the length of the ready_set_sound file # the delay time corresponds to the amount of time the peripheral target is displayed, and the prepbuff time then makes up the difference wait_time = traits.Float(1., desc="Length of time in wait state (inter-trial interval)") - prepbuff_time = traits.Float(.2, desc="How long after completing center target hold before peripheral target appears") mustmv_time = traits.Float(.2, desc="Must leave center target within this time after auditory go cue.") tooslow_penalty_time = traits.Float(1, desc="Length of penalty time for too slow error") files = [f for f in os.listdir(audio_path) if '.wav' in f] - ready_set_sound = traits.OptionsList(files, desc="File in riglib/audio to play on each trial for the go cue") tooslow_penalty_sound = traits.OptionsList(files, desc="File in riglib/audio to play on each must move penalty") #hold penalty is normally incorrect.wav shadow_periph_radius = traits.Float(0.5, desc = 'additional radius for peripheral target') periph_hold = traits.Float(0.2, desc = "Hold time for peripheral target") - readyset_freq = traits.Float(320, desc="Frequency of the ready-set tone") + ready_freq = traits.Float(320, desc="Frequency of the ready-set tone") + set_freq = traits.Float(320, desc="Frequency of the set tone") go_freq = traits.Float(440, desc="Frequency of the go tone") tone_duration = traits.Float(0.1, desc="Duration of the ready-set and go tones") + tone_space = traits.Float(0.5, desc="Time between start tone and start of next tone") + early_move_time = traits.Float(0.0, desc = "Time prior to go cue that user is allowed to start moving") #difference between end of delay state and go tone def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.ready_set_player = AudioPlayer(self.ready_set_sound) - self.readyset_tone = TonePlayer(frequency=self.readyset_freq, duration=self.tone_duration) + self.prepbuff_time = 2*self.tone_space - self.delay_time - self.early_move_time + self.ready_tone = TonePlayer(frequency=self.ready_freq, duration=self.tone_duration) + self.set_tone = TonePlayer(frequency=self.set_freq, duration=self.tone_duration) self.go_tone = TonePlayer(frequency=self.go_freq, duration=self.tone_duration) self.tooslow_penalty_player = AudioPlayer(self.tooslow_penalty_sound) self.pseudo_reward = 0 @@ -989,7 +991,7 @@ def _test_prepbuff_complete(self, time_in_state): The delay state will display the peripheral target so this state just requires a center hold. ''' - return time_in_state > self.prepbuff_time + return time_in_state > self.prepbuff_time def _test_mustmv_complete(self, time_in_state): ''' @@ -1001,7 +1003,7 @@ def _test_mustmv_complete(self, time_in_state): - Sensorized object moved to the required location - Manually triggered by experimenter ''' - return time_in_state > self.mustmv_time + return time_in_state > self.mustmv_time + self.early_move_time def _test_tooslow_penalty_end(self, time_in_state): return time_in_state > self.tooslow_penalty_time @@ -1012,79 +1014,76 @@ def update_report_stats(self): #add holds completed metric to report stats self.reportstats['Pseudo Reward'] = self.pseudo_reward + self.reward_count ### State Functions ### + def _start_wait(self): + super()._start_wait() + self.ready_played = False + self.set_played = False + self.go_played = False + def _start_prepbuff(self): self.sync_event('CUE') #integer code 112 self.prep_start_time = self.get_time() - self.audio_space = 0.5 #seconds between tones - self.set_played = False - #self.ready_set_player.play() - self.readyset_tone.play() - - def _while_prepbuff(self): - self.time_in_prep = self.get_time() - self.prep_start_time - if self.time_in_prep >= self.audio_space and not self.set_played: - self.readyset_tone.play() - #self.ready_set_player.play() - self.targets[0].cue_set_tone() - self.set_played = True - #print(self.time_in_prep) - #self.sync_event('CUE') - - def _while_delay(self): - super()._while_delay() - self.time_in_delay = self.get_time() - self.prep_start_time - if self.time_in_delay >= self.audio_space and not self.set_played: - #self.ready_set_player.play() - self.readyset_tone.play() - self.targets[0].cue_set_tone() + self.ready_tone.play() + self.ready_played = True + + def _cycle(self): + super()._cycle() + + if not self.set_played and self.ready_played and (self.get_time() - self.prep_start_time) >= self.tone_space: + self.set_tone.play() self.set_played = True - #print(self.time_in_delay) - #self.sync_event('CUE') + self.sync_event('CUE', 1) #integer code 113 + + if not self.go_played and self.set_played and (self.get_time() - self.prep_start_time) >= 2 * self.tone_space: + self.go_tone.play() + self.sync_event('CUE', 2) #integer code 114 + if self.target_index + 1 < self.chain_length: + self.targets[self.target_index % 2].hide() + self.go_played = True - def _end_delay(self): - super()._end_delay() - self.go_time = self.get_time() - self.prep_start_time - #self.ready_set_player.play() - self.go_tone.play() - #print(self.go_time) - - def _start_leave_center(self): - self.sync_event('CURSOR_LEAVE_TARGET') #integer code 96 - self.targets[0].cue_fixation() #turn center target blue - - def _start_hold_penalty(self): self.pseudo_success() #run before increment trials to prevent reseting of trial index if hasattr(super(), '_start_hold_penalty'): super()._start_hold_penalty() - self.ready_set_player.stop() - self.readyset_tone.stop() + self.ready_tone.stop() + self.set_tone.stop() self.go_tone.stop() + self.ready_played = False + self.set_played = False + self.go_played = False def _start_delay_penalty(self): if hasattr(super(), '_start_delay_penalty'): super()._start_delay_penalty() - self.ready_set_player.stop() - self.readyset_tone.stop() + self.ready_tone.stop() + self.set_tone.stop() self.go_tone.stop() + self.ready_played = False + self.set_played = False + self.go_played = False def _start_timeout_penalty(self): self.pseudo_success() #run before increment trials to prevent reseting of trial index super()._start_timeout_penalty() + self.ready_played = False + self.set_played = False + self.go_played = False def _start_tooslow_penalty(self): self._increment_tries() self.sync_event('OTHER_PENALTY') #integer code 79 self.tooslow_penalty_player.play() - self.ready_set_player.stop() - self.readyset_tone.stop() + self.ready_tone.stop() + self.set_tone.stop() self.go_tone.stop() self.jack_count = 0 - # # Hide targets - for target in self.targets: + self.ready_played = False + self.set_played = False + self.go_played = False + for target in self.targets: #Hide Targets target.hide() target.reset() - + def _end_tooslow_penalty(self): self.sync_event('TRIAL_END') @@ -1097,6 +1096,9 @@ def pseudo_success(self): #function to measure almost success def _start_pause(self): super()._start_pause() - self.ready_set_player.stop() - self.readyset_tone.stop() - self.go_tone.stop() \ No newline at end of file + self.ready_tone.stop() + self.set_tone.stop() + self.go_tone.stop() + self.ready_played = False + self.set_played = False + self.go_played = False \ No newline at end of file diff --git a/features/__init__.py b/features/__init__.py index 8be58ef8..41e7948f 100644 --- a/features/__init__.py +++ b/features/__init__.py @@ -8,7 +8,7 @@ from features.quattrocento_features import QuattBMI from riglib.stereo_opengl.window import WindowWithExperimenterDisplay, Window2D from riglib.stereo_opengl.openxr import WindowVR -from .generator_features import Autostart, RandomDelay, AdaptiveGenerator, IgnoreCorrectness, PoissonWait, Progressbar_fixation, HideLeftTrajectory, StartTrialBelowSpeedThr, ReadysetMedley +from .generator_features import Autostart, RandomDelay, AdaptiveGenerator, IgnoreCorrectness, PoissonWait, Progressbar_fixation, HideLeftTrajectory, StartTrialBelowSpeedThr, ReadysetMedley, ReadysetColorChange from .peripheral_device_features import Button, Joystick, DualJoystick, Joystick_plus_TouchSensor, KeyboardControl, MouseControl, ForceControl from .reward_features import RewardSystem, RewardSystemPulse, TTLReward, JuiceLogging, PelletReward, JackpotRewards, ProgressBar, TrackingRewards, RewardAudio, PenaltyAudio, ScoreRewards, ConsecutiveJackpot from .eyetracker_features import EyeCursor, EyeData, CalibratedEyeData, PupilLabStreaming, SimulatedEyeData, FixationStart, EyeConstrained, EyeCalibration, EyeStreaming @@ -100,6 +100,7 @@ readyset_medley=ReadysetMedley, start_trial_below_speed_thr=StartTrialBelowSpeedThr, reward_system_pulse = RewardSystemPulse, + readyset_colorchange = ReadysetColorChange, ) # >>> features.built_in_features['autostart'].__module__ diff --git a/features/generator_features.py b/features/generator_features.py index 5e1c49a7..30f73010 100644 --- a/features/generator_features.py +++ b/features/generator_features.py @@ -342,10 +342,10 @@ class ReadysetMedley(traits.HasTraits): ''' Allows for mulitple different prepbuff and delay times to be used within a single experiment. - Replaces the prepbuff_time and delay_time parameters with a list of possible values and correspondniig probabilities. + Replaces the prepbuff_time and delay_time parameters with a list of possible values and corresponding probabilities. ''' - exclude_parent_traits = ['prepbuff_time', 'delay_time'] + exclude_parent_traits = ['delay_time'] display_times = traits.List([0,], desc = 'Possible peripheral target display times') frac_times = traits.List([0.1,], desc = 'Proportion of each type of display time. Need to be equal length to delay_times and sum to 1') #should sum to 1.0 @@ -358,7 +358,35 @@ def _start_wait(self): At the start of the 'wait' state, determine which prepbuff & delay_time to use ''' self.delay_time = np.random.choice((self.display_times), p = (self.frac_times)) - audio_length = 1.0 # length of the audio cue in seconds. need to automate this - self.prepbuff_time = audio_length - self.delay_time + audio_length = 2*self.tone_space - self.early_move_time #use the same value but update to account for the different delay times + self.prepbuff_time = audio_length - self.delay_time #update the prepbuff time accordingly super()._start_wait() +class ReadysetColorChange(traits.HasTraits): + ''' + Change the color to go along with the set cue + ''' + def _cycle(self): + super()._cycle() + if self.set_played and self.ready_played and not self.go_played and (self.get_time() - self.prep_start_time) >= self.tone_space: + self.targets[0].cue_set_tone() #turn target orange on set tone + + def _start_leave_center(self): + self.targets[0].show() + self.targets[0].cue_fixation() + + def _start_tooslow_penalty(self): + self.tooslow_start = self.get_time() + super()._start_tooslow_penalty() + self.targets[0].show() + self.targets[0].cue_trial_end_failure() + self.targets[1].hide() + + def _while_tooslow_penalty(self): + if (self.get_time() - self.tooslow_start) >= 0.5 * self.tooslow_penalty_time: + for targets in self.targets: + targets.hide() + targets.reset() + + def _end_tooslow_penalty(self): + self.sync_event('TRIAL_END') diff --git a/riglib/audio/audio.py b/riglib/audio/audio.py index e8414ec3..04a443c4 100644 --- a/riglib/audio/audio.py +++ b/riglib/audio/audio.py @@ -2,14 +2,17 @@ import os import numpy as np +def init_mixer(): + if not pygame.mixer.get_init(): + pygame.mixer.pre_init(44100, -16, 2, 2048) + pygame.mixer.init() + audio_path = os.path.dirname(__file__) class AudioPlayer(): def __init__(self, filename='click.wav'): - if not pygame.mixer.get_init(): - pygame.mixer.pre_init(44100, -16, 2, 2048) - pygame.mixer.init() + init_mixer() self.effect = pygame.mixer.Sound(os.path.join(audio_path, filename)) def get_length(self): @@ -24,6 +27,7 @@ def stop(self): class TonePlayer(): def __init__(self, frequency = 440, duration = 0.1, sample_rate = 44100): + init_mixer() self.frequency = frequency self.duration = duration self.sample_rate = sample_rate diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 7a560333..2e0f028e 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -5,7 +5,7 @@ from built_in_tasks.target_capture_task import ScreenTargetCapture from built_in_tasks.passivetasks import YouTube from built_in_tasks.example_task import ExampleSequenceTask -from features.generator_features import Autostart, HideLeftTrajectory +from features.generator_features import Autostart, HideLeftTrajectory, ReadysetMedley, ReadysetColorChange from features.hdf_features import SaveHDF from features.touch_features import MouseEmulateTouch from riglib.stereo_opengl.environment import Grid @@ -40,15 +40,22 @@ class TestManualControlTasks(unittest.TestCase): @unittest.skip("") def test_readysetgo(self): seq = ManualControl.centerout_2D() - exp = init_exp(ReadySetGoTask, [MouseControl, Window2D], seq, prepbuff_time = 0.8, - delay_time = 0.2, mustmv_time = 0.4,ready_set_sound = 'click.wav', readyset_freq = 320, go_freq = 400, - tooslow_penalty_sound = 'buzzer.wav', window_size=(1200,800), + exp = init_exp(ReadySetGoTask, [MouseControl, Window2D], seq, early_move_time = 0.1, + delay_time = 0.2, mustmv_time = 0.8, ready_freq = 320, set_freq = 360, go_freq = 400, + tooslow_penalty_sound = 'buzzer.wav', window_size=(1200,800), fullscreen=False) - #exp.rotation = 'yzx' exp.rotation = 'xzy' - #exp.offset = [-20, -95, -2] exp.run() + @unittest.skip("") + def test_readysetgo_feat(self): + seq = ManualControl.centerout_2D() + exp = init_exp(ReadySetGoTask, [MouseControl, Window2D, ReadysetMedley, ReadysetColorChange], seq, early_move_time = 0, + display_times = [0.9, 0.1], frac_times = [0.1, 0.9], mustmv_time = 0.4, ready_freq = 320, set_freq = 360, go_freq = 400, tone_space = 0.5, + tooslow_penalty_sound = 'buzzer.wav', window_size=(1200,800), + fullscreen=False) + exp.rotation = 'xzy' + exp.run() @unittest.skip("") def test_exp(self): From 3ef93abf6c98db1aa0ad57ceea046baf4233bc35 Mon Sep 17 00:00:00 2001 From: mtringi Date: Fri, 7 Nov 2025 19:37:18 -0800 Subject: [PATCH 17/62] modify _cycle state for better integration with the colorchange feature --- built_in_tasks/target_capture_task_xt.py | 8 ++++++++ features/generator_features.py | 14 ++++++-------- tests/test_tasks.py | 6 +++--- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/built_in_tasks/target_capture_task_xt.py b/built_in_tasks/target_capture_task_xt.py index f9b4e1ba..d342aff7 100644 --- a/built_in_tasks/target_capture_task_xt.py +++ b/built_in_tasks/target_capture_task_xt.py @@ -1033,6 +1033,7 @@ def _cycle(self): self.set_tone.play() self.set_played = True self.sync_event('CUE', 1) #integer code 113 + self.color_set_cue() if not self.go_played and self.set_played and (self.get_time() - self.prep_start_time) >= 2 * self.tone_space: self.go_tone.play() @@ -1040,6 +1041,13 @@ def _cycle(self): if self.target_index + 1 < self.chain_length: self.targets[self.target_index % 2].hide() self.go_played = True + self.color_go_cue() + + def color_set_cue(self): + pass + + def color_go_cue(self): + pass def _start_hold_penalty(self): self.pseudo_success() #run before increment trials to prevent reseting of trial index diff --git a/features/generator_features.py b/features/generator_features.py index 30f73010..b828d217 100644 --- a/features/generator_features.py +++ b/features/generator_features.py @@ -366,15 +366,13 @@ class ReadysetColorChange(traits.HasTraits): ''' Change the color to go along with the set cue ''' - def _cycle(self): - super()._cycle() - if self.set_played and self.ready_played and not self.go_played and (self.get_time() - self.prep_start_time) >= self.tone_space: - self.targets[0].cue_set_tone() #turn target orange on set tone - - def _start_leave_center(self): - self.targets[0].show() - self.targets[0].cue_fixation() + def color_set_cue(self): + self.targets[0].cue_set_tone() #turn target orange on set tone + def color_go_cue(self): + self.targets[0].show() + self.targets[0].cue_fixation() #turn target green on go tone + def _start_tooslow_penalty(self): self.tooslow_start = self.get_time() super()._start_tooslow_penalty() diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 2e0f028e..07371d17 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -41,7 +41,7 @@ class TestManualControlTasks(unittest.TestCase): def test_readysetgo(self): seq = ManualControl.centerout_2D() exp = init_exp(ReadySetGoTask, [MouseControl, Window2D], seq, early_move_time = 0.1, - delay_time = 0.2, mustmv_time = 0.8, ready_freq = 320, set_freq = 360, go_freq = 400, + delay_time = 0.8, mustmv_time = 0.8, ready_freq = 320, set_freq = 360, go_freq = 400, tooslow_penalty_sound = 'buzzer.wav', window_size=(1200,800), fullscreen=False) exp.rotation = 'xzy' @@ -50,8 +50,8 @@ def test_readysetgo(self): @unittest.skip("") def test_readysetgo_feat(self): seq = ManualControl.centerout_2D() - exp = init_exp(ReadySetGoTask, [MouseControl, Window2D, ReadysetMedley, ReadysetColorChange], seq, early_move_time = 0, - display_times = [0.9, 0.1], frac_times = [0.1, 0.9], mustmv_time = 0.4, ready_freq = 320, set_freq = 360, go_freq = 400, tone_space = 0.5, + exp = init_exp(ReadySetGoTask, [MouseControl, Window2D, ReadysetMedley, ReadysetColorChange], seq, early_move_time = 0.5, + display_times = [0.9, 1.5], frac_times = [0.1, 0.9], mustmv_time = 0.8, ready_freq = 320, set_freq = 360, go_freq = 400, tone_space = 1.0, tooslow_penalty_sound = 'buzzer.wav', window_size=(1200,800), fullscreen=False) exp.rotation = 'xzy' From 7f6318d1d318e69314476450690e434b8d0494bd Mon Sep 17 00:00:00 2001 From: mtringi Date: Wed, 12 Nov 2025 10:12:41 -0800 Subject: [PATCH 18/62] update comments to describe color change logic --- built_in_tasks/target_capture_task_xt.py | 8 ++++---- features/generator_features.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/built_in_tasks/target_capture_task_xt.py b/built_in_tasks/target_capture_task_xt.py index d342aff7..a41902fe 100644 --- a/built_in_tasks/target_capture_task_xt.py +++ b/built_in_tasks/target_capture_task_xt.py @@ -1033,7 +1033,7 @@ def _cycle(self): self.set_tone.play() self.set_played = True self.sync_event('CUE', 1) #integer code 113 - self.color_set_cue() + self.color_set_cue() #placeholder function for the color change feature if not self.go_played and self.set_played and (self.get_time() - self.prep_start_time) >= 2 * self.tone_space: self.go_tone.play() @@ -1041,12 +1041,12 @@ def _cycle(self): if self.target_index + 1 < self.chain_length: self.targets[self.target_index % 2].hide() self.go_played = True - self.color_go_cue() + self.color_go_cue() #placeholder function for the color change feature - def color_set_cue(self): + def color_set_cue(self): #do nothing in normal version (no color change) pass - def color_go_cue(self): + def color_go_cue(self): #do nothing in normal version (no color change) pass def _start_hold_penalty(self): diff --git a/features/generator_features.py b/features/generator_features.py index b828d217..811b30dc 100644 --- a/features/generator_features.py +++ b/features/generator_features.py @@ -366,12 +366,12 @@ class ReadysetColorChange(traits.HasTraits): ''' Change the color to go along with the set cue ''' - def color_set_cue(self): + def color_set_cue(self): #update function to have color change self.targets[0].cue_set_tone() #turn target orange on set tone - def color_go_cue(self): - self.targets[0].show() - self.targets[0].cue_fixation() #turn target green on go tone + def color_go_cue(self): #update function to have color change + self.targets[0].show() #need to show as the target will be hidden by the baseline logic + self.targets[0].cue_fixation() #turn target blue on go tone def _start_tooslow_penalty(self): self.tooslow_start = self.get_time() From 87225f246ea8ab10a82fb29e6cf731cc6c19fb45 Mon Sep 17 00:00:00 2001 From: mtringi Date: Wed, 12 Nov 2025 10:23:39 -0800 Subject: [PATCH 19/62] Add parameter assertation errors --- built_in_tasks/target_capture_task_xt.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/built_in_tasks/target_capture_task_xt.py b/built_in_tasks/target_capture_task_xt.py index a41902fe..6122ced7 100644 --- a/built_in_tasks/target_capture_task_xt.py +++ b/built_in_tasks/target_capture_task_xt.py @@ -949,6 +949,11 @@ def __init__(self, *args, **kwargs): self.go_tone = TonePlayer(frequency=self.go_freq, duration=self.tone_duration) self.tooslow_penalty_player = AudioPlayer(self.tooslow_penalty_sound) self.pseudo_reward = 0 + + # Assert that parameters are set logically + assert self.tone_duration < self.tone_space, "Tone duration must be less than time between tones." + assert self.delay_time + self.early_move_time <= 2*self.tone_space, "Time of peripheral target display (delay_time) plus early move allowance should be less than or equal to length of tone sequence" + assert self.mustmv_time >= self.tone_duration, "Allow at least length of tone duration to move after the onset of the go cue" ###Test Functions ### From 7245a803cd041eff596df538e2d2b4644eb98fce Mon Sep 17 00:00:00 2001 From: mtringi Date: Wed, 12 Nov 2025 11:17:16 -0800 Subject: [PATCH 20/62] fix target off logic. --- built_in_tasks/target_capture_task_xt.py | 8 ++++++-- tests/test_tasks.py | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/built_in_tasks/target_capture_task_xt.py b/built_in_tasks/target_capture_task_xt.py index 6122ced7..60b92b81 100644 --- a/built_in_tasks/target_capture_task_xt.py +++ b/built_in_tasks/target_capture_task_xt.py @@ -1043,8 +1043,7 @@ def _cycle(self): if not self.go_played and self.set_played and (self.get_time() - self.prep_start_time) >= 2 * self.tone_space: self.go_tone.play() self.sync_event('CUE', 2) #integer code 114 - if self.target_index + 1 < self.chain_length: - self.targets[self.target_index % 2].hide() + self.targets[0].hide() self.go_played = True self.color_go_cue() #placeholder function for the color change feature @@ -1054,6 +1053,11 @@ def color_set_cue(self): #do nothing in normal version (no color change) def color_go_cue(self): #do nothing in normal version (no color change) pass + def _start_targ_transition(self): + super()._start_targ_transition() + if self.target_index == -1: # Came from a penalty state + pass + def _start_hold_penalty(self): self.pseudo_success() #run before increment trials to prevent reseting of trial index if hasattr(super(), '_start_hold_penalty'): diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 07371d17..c194169b 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -41,7 +41,7 @@ class TestManualControlTasks(unittest.TestCase): def test_readysetgo(self): seq = ManualControl.centerout_2D() exp = init_exp(ReadySetGoTask, [MouseControl, Window2D], seq, early_move_time = 0.1, - delay_time = 0.8, mustmv_time = 0.8, ready_freq = 320, set_freq = 360, go_freq = 400, + delay_time = 0.5, mustmv_time = 0.4, ready_freq = 320, set_freq = 360, go_freq = 400, tooslow_penalty_sound = 'buzzer.wav', window_size=(1200,800), fullscreen=False) exp.rotation = 'xzy' @@ -51,7 +51,7 @@ def test_readysetgo(self): def test_readysetgo_feat(self): seq = ManualControl.centerout_2D() exp = init_exp(ReadySetGoTask, [MouseControl, Window2D, ReadysetMedley, ReadysetColorChange], seq, early_move_time = 0.5, - display_times = [0.9, 1.5], frac_times = [0.1, 0.9], mustmv_time = 0.8, ready_freq = 320, set_freq = 360, go_freq = 400, tone_space = 1.0, + display_times = [0.9, 1.5], frac_times = [0.1, 0.9], mustmv_time = 0.5, ready_freq = 320, set_freq = 360, go_freq = 400, tone_space = 1.0, tooslow_penalty_sound = 'buzzer.wav', window_size=(1200,800), fullscreen=False) exp.rotation = 'xzy' From 8cb0b5ca2f3010c49c20f8c165a338b1552586db Mon Sep 17 00:00:00 2001 From: mtringi Date: Wed, 12 Nov 2025 12:47:21 -0800 Subject: [PATCH 21/62] add check to make sure that the tone cues are not within one frame of other relevant cues (target on, target off) --- built_in_tasks/target_capture_task_xt.py | 11 ++++++++++- tests/test_tasks.py | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/built_in_tasks/target_capture_task_xt.py b/built_in_tasks/target_capture_task_xt.py index 60b92b81..a08cca19 100644 --- a/built_in_tasks/target_capture_task_xt.py +++ b/built_in_tasks/target_capture_task_xt.py @@ -1019,13 +1019,22 @@ def update_report_stats(self): #add holds completed metric to report stats self.reportstats['Pseudo Reward'] = self.pseudo_reward + self.reward_count ### State Functions ### - def _start_wait(self): + def _start_wait(self): #necessary reset so that these parameters exist at the beginning of each trial super()._start_wait() self.ready_played = False self.set_played = False self.go_played = False + + def _start_hold(self): #this addresses a potential issue if the task is solved too quickly so that the go tone is not played after the trial has been rewarded + super()._start_hold() + self.ready_played = False + self.set_played = False + self.go_played = False def _start_prepbuff(self): + self.epsilon = 1/self.fps #small value to account for floating point precision errors. can set this to 1/(2*self.fps) to be more permissive + + assert abs(self.prepbuff_time - (self.tone_space - 1/self.fps)) > self.epsilon, "Prep buffer time must not be within the time between tones minus one frame rate to avoid timing issues." self.sync_event('CUE') #integer code 112 self.prep_start_time = self.get_time() self.ready_tone.play() diff --git a/tests/test_tasks.py b/tests/test_tasks.py index c194169b..daa0f2fd 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -41,7 +41,7 @@ class TestManualControlTasks(unittest.TestCase): def test_readysetgo(self): seq = ManualControl.centerout_2D() exp = init_exp(ReadySetGoTask, [MouseControl, Window2D], seq, early_move_time = 0.1, - delay_time = 0.5, mustmv_time = 0.4, ready_freq = 320, set_freq = 360, go_freq = 400, + delay_time = 0.45, mustmv_time = 0.3, ready_freq = 320, set_freq = 360, go_freq = 400, tooslow_penalty_sound = 'buzzer.wav', window_size=(1200,800), fullscreen=False) exp.rotation = 'xzy' From e7dfb683c84b17a4883d6dd711c0b1cd95f41cf0 Mon Sep 17 00:00:00 2001 From: mtringi Date: Wed, 12 Nov 2025 15:10:40 -0800 Subject: [PATCH 22/62] move tooslow audio penalty to PenaltyAudioMulti & PenaltyAudio features --- built_in_tasks/target_capture_task_xt.py | 4 ---- features/reward_features.py | 12 ++++++++++++ tests/test_tasks.py | 8 ++++---- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/built_in_tasks/target_capture_task_xt.py b/built_in_tasks/target_capture_task_xt.py index a08cca19..e2cb5362 100644 --- a/built_in_tasks/target_capture_task_xt.py +++ b/built_in_tasks/target_capture_task_xt.py @@ -930,8 +930,6 @@ class ScreenTargetCapture_ReadySet(ScreenTargetCapture): wait_time = traits.Float(1., desc="Length of time in wait state (inter-trial interval)") mustmv_time = traits.Float(.2, desc="Must leave center target within this time after auditory go cue.") tooslow_penalty_time = traits.Float(1, desc="Length of penalty time for too slow error") - files = [f for f in os.listdir(audio_path) if '.wav' in f] - tooslow_penalty_sound = traits.OptionsList(files, desc="File in riglib/audio to play on each must move penalty") #hold penalty is normally incorrect.wav shadow_periph_radius = traits.Float(0.5, desc = 'additional radius for peripheral target') periph_hold = traits.Float(0.2, desc = "Hold time for peripheral target") ready_freq = traits.Float(320, desc="Frequency of the ready-set tone") @@ -947,7 +945,6 @@ def __init__(self, *args, **kwargs): self.ready_tone = TonePlayer(frequency=self.ready_freq, duration=self.tone_duration) self.set_tone = TonePlayer(frequency=self.set_freq, duration=self.tone_duration) self.go_tone = TonePlayer(frequency=self.go_freq, duration=self.tone_duration) - self.tooslow_penalty_player = AudioPlayer(self.tooslow_penalty_sound) self.pseudo_reward = 0 # Assert that parameters are set logically @@ -1098,7 +1095,6 @@ def _start_timeout_penalty(self): def _start_tooslow_penalty(self): self._increment_tries() self.sync_event('OTHER_PENALTY') #integer code 79 - self.tooslow_penalty_player.play() self.ready_tone.stop() self.set_tone.stop() self.go_tone.stop() diff --git a/features/reward_features.py b/features/reward_features.py index 6271a075..cfc5537b 100644 --- a/features/reward_features.py +++ b/features/reward_features.py @@ -183,6 +183,11 @@ def _start_timeout_penalty(self): if hasattr(super(), '_start_timeout_penalty'): super()._start_timeout_penalty() self.penalty_player.play() + + def _start_tooslow_penalty(self): + if hasattr(super(), '_start_tooslow_penalty'): + super()._start_tooslow_penalty() + self.penalty_player.play() def _start_fixation_penalty(self): if hasattr(super(), '_start_fixation_penalty'): @@ -199,6 +204,7 @@ class PenaltyAudioMulti(traits.HasTraits): timeout_penalty_sound = "incorrect.wav" reach_penalty_sound = "incorrect.wav" tracking_out_penalty_sound = "buzzer.wav" + tooslow_penalty_sound = "buzzer.wav" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -207,6 +213,7 @@ def __init__(self, *args, **kwargs): self.timeout_penalty_player = AudioPlayer(self.timeout_penalty_sound) self.reach_penalty_player = AudioPlayer(self.reach_penalty_sound) self.tracking_out_penalty_player = AudioPlayer(self.tracking_out_penalty_sound) + self.tooslow_penalty_player = AudioPlayer(self.tooslow_penalty_sound) def _start_hold_penalty(self): if hasattr(super(), '_start_hold_penalty'): @@ -233,6 +240,11 @@ def _start_tracking_out_penalty(self): super()._start_tracking_out_penalty() self.tracking_out_penalty_player.play() + def _start_tooslow_penalty(self): + if hasattr(super(), '_start_tooslow_penalty'): + super()._start_tooslow_penalty() + self.tooslow_penalty_player.play() + class HoldCompleteRewards(traits.HasTraits): ''' Trigger an extra reward (duration set by hold_reward_time) after successful holds diff --git a/tests/test_tasks.py b/tests/test_tasks.py index daa0f2fd..41a83857 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -14,7 +14,7 @@ from riglib import audio from features.peripheral_device_features import ForceControl, MouseControl from features.optitrack_features import OptitrackSimulate, Optitrack, SpheresToCylinders -from features.reward_features import ProgressBar, ScoreRewards +from features.reward_features import ProgressBar, ScoreRewards, PenaltyAudioMulti import cProfile import pstats from riglib.stereo_opengl.window import Window, Window2D @@ -42,7 +42,7 @@ def test_readysetgo(self): seq = ManualControl.centerout_2D() exp = init_exp(ReadySetGoTask, [MouseControl, Window2D], seq, early_move_time = 0.1, delay_time = 0.45, mustmv_time = 0.3, ready_freq = 320, set_freq = 360, go_freq = 400, - tooslow_penalty_sound = 'buzzer.wav', window_size=(1200,800), + window_size=(1200,800), fullscreen=False) exp.rotation = 'xzy' exp.run() @@ -50,9 +50,9 @@ def test_readysetgo(self): @unittest.skip("") def test_readysetgo_feat(self): seq = ManualControl.centerout_2D() - exp = init_exp(ReadySetGoTask, [MouseControl, Window2D, ReadysetMedley, ReadysetColorChange], seq, early_move_time = 0.5, + exp = init_exp(ReadySetGoTask, [MouseControl, Window2D, ReadysetMedley, ReadysetColorChange, PenaltyAudioMulti], seq, early_move_time = 0.5, display_times = [0.9, 1.5], frac_times = [0.1, 0.9], mustmv_time = 0.5, ready_freq = 320, set_freq = 360, go_freq = 400, tone_space = 1.0, - tooslow_penalty_sound = 'buzzer.wav', window_size=(1200,800), + window_size=(1200,800), fullscreen=False) exp.rotation = 'xzy' exp.run() From 808491d1ab57b9fb9832d4784fa6962b4317f598 Mon Sep 17 00:00:00 2001 From: katherineperks Date: Mon, 17 Nov 2025 15:17:24 -0800 Subject: [PATCH 23/62] move some lines to fix reward time --- features/reward_features.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/features/reward_features.py b/features/reward_features.py index cfc5537b..d07e16b2 100644 --- a/features/reward_features.py +++ b/features/reward_features.py @@ -97,14 +97,14 @@ class PelletReward(RewardSystem): pellets_per_reward = traits.Int(1, desc='The number of pellets to dispense per reward.') port_value = traits.Int(8000, desc='The port value to identify which tablet is running.') - pellet_dispense_time = 0.5 # in sec, for a single pellet dispense - reward_time = pellet_dispense_time * pellets_per_reward - def __init__(self, *args, **kwargs): from riglib.tablet_reward import RemoteReward super(RewardSystem, self).__init__(*args, **kwargs) self.reward = RemoteReward() self.reportstats['Reward #'] = 0 + + self.pellet_dispense_time = 0.5 # in sec, for a single pellet dispense + self.reward_time = self.pellet_dispense_time * self.pellets_per_reward if self.port_value == 8000: self.ip_address = "192.168.0.100" From ef9188ef575ba5daeab8d7490e27dd0a91efc533 Mon Sep 17 00:00:00 2001 From: Katherine Date: Wed, 3 Dec 2025 16:50:25 -0800 Subject: [PATCH 24/62] online_eye_plot and fix reward bug --- analysis/online_analysis.py | 85 +++++++++++++++++++++++++++++++-- features/debug_features.py | 2 + features/eyetracker_features.py | 15 +++++- features/reward_features.py | 5 ++ 4 files changed, 100 insertions(+), 7 deletions(-) diff --git a/analysis/online_analysis.py b/analysis/online_analysis.py index 89187a17..fa2f8d16 100644 --- a/analysis/online_analysis.py +++ b/analysis/online_analysis.py @@ -231,7 +231,7 @@ def handle_data(self, key, values): self.targets[event_data] = 1 elif event_name == 'TARGET_OFF': self.targets[event_data] = 0 - elif event_name in ['PAUSE', 'TRIAL_END', 'HOLD_PENALTY', 'DELAY_PENALTY', 'TIMEOUT_PENALTY']: + elif event_name in ['PAUSE', 'TRIAL_END', 'HOLD_PENALTY', 'DELAY_PENALTY', 'TIMEOUT_PENALTY','FIXATION_PENALTY']: # Clear targets at the end of the trial self.targets = {} elif event_name == 'REWARD': @@ -247,9 +247,19 @@ def handle_data(self, key, values): self.eye_pos = np.array(values[0])[:2] # Update eye diameter - self.temp = np.array(values[0])[[0,1,4]] - self.eye_diam = np.roll(self.eye_diam, -1, axis=0) - self.eye_diam[-1] = self.temp + if self.eye_pos.size > 2: + self.temp = np.array(values[0])[[0,1,4]] + self.eye_diam = np.roll(self.eye_diam, -1, axis=0) + self.eye_diam[-1] = self.temp + + elif key == 'calibrated_eye_pos': + self.calibrated_eye_pos = np.array(values[0])[:2] + + # Update eye diameter + if self.calibrated_eye_pos.size > 2: + self.temp = np.array(values[0])[[0,1,4]] + self.eye_diam = np.roll(self.eye_diam, -1, axis=0) + self.eye_diam[-1] = self.temp elif key == 'target_location': target_idx, target_location = values @@ -284,6 +294,64 @@ def cleanup(self): if self.calibration_flag and not np.array_equal(self.eye_coeff, np.array([[1,0],[1,0]])): aopy.data.pkl_write(self.calibration_filename, (self.eye_coeff, self.eye_coeff_corr), self.calibration_dir) +class SaccadeAnalysisWorker(BehaviorAnalysisWorker): + ''' + Plots eye, cursor, and target data from experiments that have them. Performs automatic + calibration of eye data to target locations when the cursor enters the target if no + calibration coefficients are available. + ''' + + def __init__(self, task_params, data_queue, calibration_dir='/var/tmp', buffer_time=1, ylim=1, px_per_cm=51.67, **kwargs): + super().__init__(task_params, data_queue, **kwargs) + + def init(self): + super().init() + self.calibrated_eye_pos = np.zeros(2) + self.calibration_flag = False + + def get_current_pos(self): + ''' + Get the current cursor, eye, and target positions + + Returns: + cursor_pos ((2,) tuple): Current cursor position + eye_pos ((2,) tuple): Current eye position and diameters + targets (list): List of active targets in (position, radius, color) format + ''' + try: + radius = self.task_params['target_radius'] + color = 'orange' + targets = [(self.target_pos[k], radius, color if v == 1 else 'green') for k, v in self.targets.items() if v] + except: + targets = [] + return self.cursor_pos, self.calibrated_eye_pos, targets + + def draw(self): + cursor_pos, calibrated_eye_pos, targets = self.get_current_pos() + cursor_radius = self.task_params.get('cursor_radius', 0.25) + if 'fixation_radius_buffer' in self.task_params: + buffer = self.task_params['fixation_radius_buffer'] + elif 'fixation_dist' in self.task_params: + buffer = self.task_params['fixation_dist'] - self.task_params['target_radius'] + eye_radius = 0.2 + + patches1 = [plt.Circle(pos, radius+buffer) for pos, radius, _ in targets] + patches2 = [plt.Circle(cursor_pos, cursor_radius), plt.Circle(calibrated_eye_pos, eye_radius)] + patches3 = [plt.Circle(pos, radius) for pos, radius, _ in targets] + patches = patches1 + patches2 + patches3 + self.circles.set_paths(patches) + colors = [[0.8,0.8,0.8] for _, _, c in targets] + ['darkblue', 'darkgreen'] + [c for _, _, c in targets] + self.circles.set_facecolor(colors) + self.circles.set_alpha(0.5) + + # Update eye diameter plot + self.x_plot.set_data(np.arange(len(self.eye_diam)) * 1/(int(self.task_params['fps'])) - self.buffer_time, + self.eye_diam[:, 0]) + self.y_plot.set_data(np.arange(len(self.eye_diam)) * 1/(int(self.task_params['fps'])) - self.buffer_time, + self.eye_diam[:, 1]) + self.diam_plot.set_data(np.arange(len(self.eye_diam)) * 1/(int(self.task_params['fps'])) - self.buffer_time, + self.eye_diam[:, 2]/self.px_per_cm) + class ERPAnalysisWorker(AnalysisWorker): ''' Plots ERP data from experiments with an ECoG244 array. Automatically calculates @@ -569,7 +637,14 @@ def init(self): # Always start with the behavior analysis worker print('init in state', self.state) data_queue = mp.Queue() - self.analysis_workers.append((BehaviorAnalysisWorker(self.task_params, data_queue), data_queue)) + if self.task_params['experiment_name'] == 'ManualControl' and 'fixation_dist' not in self.task_params: + self.analysis_workers.append((BehaviorAnalysisWorker(self.task_params, data_queue), data_queue)) + + elif self.task_params['experiment_name'] == 'ManualControl' and 'fixation_dist' in self.task_params: + self.analysis_workers.append((SaccadeAnalysisWorker(self.task_params, data_queue), data_queue)) + + elif self.task_params['experiment_name'] == 'SaccadeTask': + self.analysis_workers.append((SaccadeAnalysisWorker(self.task_params, data_queue), data_queue)) # Is there ecube neural data? if 'record_headstage' in self.task_params and self.task_params['record_headstage']: diff --git a/features/debug_features.py b/features/debug_features.py index 35ae0147..1a066b26 100644 --- a/features/debug_features.py +++ b/features/debug_features.py @@ -125,6 +125,8 @@ def _cycle(self): self._send_online_analysis_msg('cursor', self.plant.get_endpoint_pos()) if hasattr(self, 'eye_pos'): self._send_online_analysis_msg('eye_pos', self.eye_pos) + if hasattr(self, 'calibrated_eye_pos'): + self._send_online_analysis_msg('calibrated_eye_pos', self.calibrated_eye_pos) if hasattr(self, 'task_data') and 'decoder_state' in self.task_data.dtype.names: self._send_online_analysis_msg('decoder_state', self.task_data['decoder_state'].flatten().tolist()) if hasattr(self, 'task_data') and hasattr(self, 'extractor') and self.extractor.feature_type in self.task_data.dtype.names: diff --git a/features/eyetracker_features.py b/features/eyetracker_features.py index 49bf6680..a3479231 100644 --- a/features/eyetracker_features.py +++ b/features/eyetracker_features.py @@ -34,6 +34,7 @@ class EyeCalibration(traits.HasTraits): taskid_for_eye_calibration = traits.Int(0, desc="directory where hdf file lives") show_eye_pos = traits.Bool(False, desc="Whether to show eye positions") eye_target_calibration = traits.Bool(False, desc="Whether to regress eye positions against target positions") + center_eye_data = traits.Bool(False, desc="Whether to demean eye data with eye position for the center target") def __init__(self, *args, **kwargs): #, start_pos, calibration): super(EyeCalibration,self).__init__(*args, **kwargs) @@ -46,6 +47,7 @@ def __init__(self, *args, **kwargs): #, start_pos, calibration): files = {} files['hdf'] = hdf_file files['ecube'] = ecube_file + self.eye_center = np.zeros((4,)) print(files) if not self.keyboard_control: @@ -81,7 +83,16 @@ def get_target_locations(data, target_indices): target_pos = get_target_locations(bmi3d_data, [1,2,3,4,5,6,7,8]) - self.eye_coeff, _ = aopy.preproc.calc_eye_target_calibration(eye_interp[:,:4], \ + # Get eye_pos data when subjects gaze at the center. Target position doesn't matter for this computation + if self.center_eye_data: + _, _, eye_center = aopy.preproc.calc_eye_target_calibration(eye_interp[:,:4], \ + bmi3d_metadata['cursor_interp_samplerate'], events['timestamp'], events['code'], target_pos, \ + offset=0.1, duration=0.2, align_events=80, return_datapoints=True) + + self.eye_center = np.nanmedian(eye_center, axis=0) + + # Calculate coefficient by linear regression between targets and centered eye positions + self.eye_coeff, _ = aopy.preproc.calc_eye_target_calibration(eye_interp[:,:4]-self.eye_center, \ bmi3d_metadata['cursor_interp_samplerate'], events['timestamp'], events['code'], target_pos) print("Calibration complete:", self.eye_coeff) @@ -113,7 +124,7 @@ def _cycle(self): # Do calibration ave_pos = self.eye_pos if not self.keyboard_control: - calibrated_pos = aopy.postproc.get_calibrated_eye_data(self.eye_pos[:4],self.eye_coeff) + calibrated_pos = aopy.postproc.get_calibrated_eye_data(self.eye_pos[:4]-self.eye_center, self.eye_coeff) ave_pos = np.array([(calibrated_pos[0] + calibrated_pos[2])/2, (calibrated_pos[1] + calibrated_pos[3])/2]) # Save calibration diff --git a/features/reward_features.py b/features/reward_features.py index d07e16b2..009d85d9 100644 --- a/features/reward_features.py +++ b/features/reward_features.py @@ -296,6 +296,11 @@ def _start_timeout_penalty(self): super()._start_timeout_penalty() self.jack_count = 0 + def _start_fixation_penalty(self): + if hasattr(super(), '_start_fixation_penalty'): + super()._start_fixation_penalty() + self.jack_count = 0 + def _start_reward(self): super()._start_reward() self.jack_count += 1 #add one for each rewarded trial From 10987c48b76d85b0a0161dae9f0a66a4beee1a71 Mon Sep 17 00:00:00 2001 From: Katherine Date: Wed, 3 Dec 2025 17:46:42 -0800 Subject: [PATCH 25/62] Added a new file for eye related task --- built_in_tasks/manualcontrolmultitasks.py | 9 +- built_in_tasks/target_capture_task_eye.py | 442 ++++++++++++++++++++++ 2 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 built_in_tasks/target_capture_task_eye.py diff --git a/built_in_tasks/manualcontrolmultitasks.py b/built_in_tasks/manualcontrolmultitasks.py index 6ee6e051..92ed268e 100644 --- a/built_in_tasks/manualcontrolmultitasks.py +++ b/built_in_tasks/manualcontrolmultitasks.py @@ -9,7 +9,8 @@ from .target_graphics import * from .target_capture_task import ScreenTargetCapture -from .target_capture_task_xt import ScreenReachAngle, ScreenReachLine, SequenceCapture, HandConstrainedEyeCapture, ScreenTargetCapture_Saccade, ScreenTargetCapture_ReadySet +from .target_capture_task_xt import ScreenReachAngle, ScreenReachLine, SequenceCapture, ScreenTargetCapture_ReadySet +from .target_capture_task_eye import EyeConstrainedTargetCapture, HandConstrainedEyeCapture, ScreenTargetCapture_Saccade from .target_tracking_task import ScreenTargetTracking from .rotation_matrices import * @@ -201,6 +202,12 @@ class SequenceTask(ManualControlMixin, SequenceCapture): ''' pass +class EyeConstrainedManualControl(ManualControlMixin, EyeConstrainedTargetCapture): + ''' + Slightly refactored original manual control task + ''' + pass + class HandConstrainedSaccadeTask(ManualControlMixin, HandConstrainedEyeCapture): ''' Saccade task while holding different targets by hand diff --git a/built_in_tasks/target_capture_task_eye.py b/built_in_tasks/target_capture_task_eye.py new file mode 100644 index 00000000..edce1995 --- /dev/null +++ b/built_in_tasks/target_capture_task_eye.py @@ -0,0 +1,442 @@ +''' +Target capture tasks with eye position requirement +''' +import numpy as np +import random +import os + +from .target_graphics import * +from .target_capture_task import ScreenTargetCapture +from riglib.experiment import traits + +class EyeConstrainedTargetCapture(ScreenTargetCapture): + ''' + Add a penalty state when subjects looks away. + ''' + + fixation_penalty_time = traits.Float(0., desc="Time in fixation penalty state") + fixation_target_color = traits.OptionsList("cyan", *target_colors, desc="Color of the center target under fixation state", bmi3d_input_options=list(target_colors.keys())) + fixation_radius_buffer = traits.Float(.5, desc="additional radius for eye target") + + status = dict( + wait = dict(start_trial="target"), + target = dict(timeout="timeout_penalty",gaze_target="fixation"), + fixation = dict(enter_target="hold", fixation_break="target"), + hold = dict(leave_target="hold_penalty", hold_complete="delay", fixation_break="fixation_penalty"), + delay = dict(leave_target="delay_penalty", delay_complete="targ_transition", fixation_break="fixation_penalty"), + targ_transition = dict(trial_complete="reward", trial_abort="wait", trial_incomplete="target"), + timeout_penalty = dict(timeout_penalty_end="targ_transition", end_state=True), + hold_penalty = dict(hold_penalty_end="targ_transition", end_state=True), + delay_penalty = dict(delay_penalty_end="targ_transition", end_state=True), + fixation_penalty = dict(fixation_penalty_end="targ_transition",end_state=True), + reward = dict(reward_end="wait", stoppable=False, end_state=True) + ) + + def _test_gaze_target(self,ts): + ''' + Check whether eye positions are within the fixation distance + Only apply this to the first target (1st target) + ''' + if self.target_index <= 0: + d = np.linalg.norm(self.calibrated_eye_pos) + return d < self.target_radius + self.fixation_radius_buffer + else: + return True + + def _test_fixation_break(self,ts): + ''' + Triggers the fixation_penalty state when eye positions are outside fixation distance + Only apply this to the first hold and delay period + ''' + if self.target_index <= 0: + d = np.linalg.norm(self.calibrated_eye_pos) + return (d > self.target_radius + self.fixation_radius_buffer) + + def _test_fixation_penalty_end(self,ts): + return (ts > self.fixation_penalty_time) + + def _start_wait(self): + super()._start_wait() + self.num_fixation_state = 0 # Initializa fixation state + + def _start_target(self): + if self.num_fixation_state == 0: + super()._start_target() # target index shouldn't be incremented after fixation break loop + else: + self.sync_event('FIXATION', 0) + self.targets[0].reset() # reset target color after fixation break + + def _start_fixation(self): + self.num_fixation_state = 1 + self.targets[0].sphere.color = target_colors[self.fixation_target_color] # change target color in fixation state + if self.target_index == 0: + self.sync_event('FIXATION', 1) + + def _start_timeout_penalty(self): + super()._start_timeout_penalty() + self.num_fixation_state = 0 + + def _start_hold(self): + super()._start_hold() + self.num_fixation_state = 0 # because target state comes again after hold state in a trial + + def _start_fixation_penalty(self): + self._increment_tries() + self.sync_event('FIXATION_PENALTY') + + # Hide targets + for target in self.targets: + target.hide() + target.reset() + + def _end_fixation_penalty(self): + self.sync_event('TRIAL_END') + +class HandConstrainedEyeCapture(ScreenTargetCapture): + ''' + Saccade task with holding another target with hand. Subjects need to hold an initial target with their hand. + Then they need to fixate the first eye target and make a saccade for the second eye target + ''' + + fixation_radius = traits.Float(2.5, desc="Distance from center that is considered a broken fixation") + fixation_penalty_time = traits.Float(1.0, desc="Time in fixation penalty state") + fixation_target_color = traits.OptionsList("cyan", *target_colors, desc="Color of the eye target under fixation state", bmi3d_input_options=list(target_colors.keys())) + eye_target_color = traits.OptionsList("white", *target_colors, desc="Color of the eye target", bmi3d_input_options=list(target_colors.keys())) + fixation_radius_buffer = traits.Float(.5, desc="additional radius for eye target") + + status = dict( + wait = dict(start_trial="target", start_pause="pause"), + target = dict(start_pause="pause", leave_target2="hold_penalty",timeout="timeout_penalty",enter_target="hold"), + hold = dict(start_pause="pause", leave_target2="hold_penalty",leave_target="target", gaze_target="fixation"), # must hold an initial hand-target and eye-target + fixation = dict(start_pause="pause", leave_target="delay_penalty",hold_complete="delay", fixation_break="fixation_penalty"), # must hold an initial hand-target and eye-target to initiate a trial + delay = dict(leave_target="delay_penalty", delay_complete="targ_transition", fixation_break="fixation_penalty", start_pause="pause"), + targ_transition = dict(trial_complete="reward", trial_abort="wait", trial_incomplete="target", start_pause="pause"), + timeout_penalty = dict(timeout_penalty_end="targ_transition", start_pause="pause", end_state=True), + hold_penalty = dict(hold_penalty_end="targ_transition", start_pause="pause", end_state=True), + delay_penalty = dict(delay_penalty_end="targ_transition", start_pause="pause", end_state=True), + fixation_penalty = dict(fixation_penalty_end="targ_transition", start_pause="pause", end_state=True), + reward = dict(reward_end="wait", start_pause="pause", stoppable=False, end_state=True), + pause = dict(end_pause="wait", end_state=True), + ) + + sequence_generators = ['row_target','sac_hand_2d'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Instantiate the targets + instantiate_targets = kwargs.pop('instantiate_targets', True) + if instantiate_targets: + + # Target 1 and 2 are for saccade. Target 3 is for hand + target1 = VirtualCircularTarget(target_radius=self.fixation_radius, target_color=target_colors[self.eye_target_color]) + target2 = VirtualCircularTarget(target_radius=self.fixation_radius, target_color=target_colors[self.eye_target_color]) + target3 = VirtualCircularTarget(target_radius=self.target_radius, target_color=target_colors[self.target_color]) + + self.targets = [target1, target2] + self.targets_hand = [target3] + + def _parse_next_trial(self): + '''Check that the generator has the required data''' + self.gen_indices, self.targs = self.next_trial # 2 target positions for hand and eye + + # Update the data sinks with trial information + self.trial_record['trial'] = self.calc_trial_num() # TODO save both eye and hand target positions + for i in range(len(self.gen_indices)): + self.trial_record['index'] = self.gen_indices[i] + self.trial_record['target'] = self.targs[i] + self.sinks.send("trials", self.trial_record) + + def _test_gaze_target(self,ts): + ''' + Check whether eye positions from a target are within the fixation distance + ''' + # Distance of an eye position from a target position + eye_pos = self.calibrated_eye_pos + target_pos = np.delete(self.targs[self.target_index],1) + d_eye = np.linalg.norm(eye_pos - target_pos) + return (d_eye <= self.fixation_radius + self.fixation_radius_buffer) or self.pause + + def _test_fixation_break(self,ts): + ''' + Triggers the fixation_penalty state when eye positions are outside fixation distance + ''' + # Distance of an eye position from a target position + eye_pos = self.calibrated_eye_pos + target_pos = np.delete(self.targs[self.target_index],1) + d_eye = np.linalg.norm(eye_pos - target_pos) + return (d_eye > self.fixation_radius + self.fixation_radius_buffer) or self.pause + + def _test_fixation_penalty_end(self,ts): + return (ts > self.fixation_penalty_time) + + def _test_enter_target(self, ts): + ''' + return true if the distance between center of cursor and target is smaller than the cursor radius + ''' + cursor_pos = self.plant.get_endpoint_pos() + d = np.linalg.norm(cursor_pos - self.targs[-1]) # hand must be within the initial target + return d <= (self.target_radius - self.cursor_radius) or self.pause + + def _test_leave_target(self, ts): + ''' + return true if cursor moves outside the exit radius + ''' + cursor_pos = self.plant.get_endpoint_pos() + d = np.linalg.norm(cursor_pos - self.targs[-1]) # hand must be within the initial target + return d > (self.target_radius - self.cursor_radius) or self.pause + + def _test_leave_target2(self, ts): + ''' + return true if cursor moves outside the exit radius (This is for the second target state) + ''' + if self.target_index > 0: + cursor_pos = self.plant.get_endpoint_pos() + d = np.linalg.norm(cursor_pos - self.targs[-1]) # hand must be within the initial target + return d > (self.target_radius - self.cursor_radius) or self.pause + + def _start_wait(self): + super()._start_wait() + # Redefine chain length because targs in this task has both eye and hand targets + self.chain_length = len(self.targets) + + # Initialize fixation state + self.num_hold_state = 0 + + if self.calc_trial_num() == 0: + + # Instantiate the targets here so they don't show up in any states that might come before "wait" + for target in self.targets: + for model in target.graphics_models: + self.add_model(model) + target.hide() + + for target in self.targets_hand: + for model in target.graphics_models: + self.add_model(model) + target.hide() + + def _start_target(self): + if self.num_hold_state == 0: + self.target_index += 1 # target index shouldn't be incremented after hold break loop + + # Show target if it is hidden (this is the first target, or previous state was a penalty) + target_hand = self.targets_hand[0] + if self.target_index == 0: + target_hand.move_to_position(self.targs[-1]) + target_hand.show() + self.sync_event('TARGET_ON', self.gen_indices[-1]) + + else: + target = self.targets[self.target_index % 2] + target.hide() # hide hand target + self.sync_event('EYE_TARGET_OFF', self.gen_indices[self.target_index % 2]) + + def _start_hold(self): + #self.sync_event('CURSOR_ENTER_TARGET', self.gen_indices[self.target_index]) + self.num_hold_state = 1 + + # Show target if it is hidden (this is the first target, or previous state was a penalty) + target = self.targets[self.target_index % 2] + if self.target_index == 0: + target.move_to_position(self.targs[self.target_index]) + target.show() + self.sync_event('EYE_TARGET_ON', self.gen_indices[self.target_index]) + + def _start_fixation(self): + self.num_hold_state = 0 + self.targets[self.target_index].sphere.color = target_colors[self.fixation_target_color] # change target color in fixation state + self.sync_event('FIXATION', self.gen_indices[self.target_index]) + + def _while_fixation(self): + pass + + def _end_fixation(self): + pass + + def _start_delay(self): + # Make next target visible unless this is the final target in the trial + next_idx = (self.target_index + 1) + if next_idx < self.chain_length: + target = self.targets[next_idx % 2] + target.move_to_position(self.targs[next_idx % 2]) + target.show() + self.sync_event('EYE_TARGET_ON', self.gen_indices[next_idx % 2]) + else: + # This delay state should only last 1 cycle, don't sync anything + pass + + def _start_targ_transition(self): + if self.target_index == -1: + + # Came from a penalty state + pass + elif self.target_index + 1 < self.chain_length: + + # Hide the current target if there are more + self.targets[self.target_index % 2].hide() + self.sync_event('EYE_TARGET_OFF', self.gen_indices[self.target_index]) + + def _start_timeout_penalty(self): + super()._start_timeout_penalty() + self.num_hold_state = 0 + for target in self.targets_hand: + target.hide() + target.reset() + + def _start_hold_penalty(self): + super()._start_hold_penalty() + self.num_hold_state = 0 + # Hide targets + for target in self.targets_hand: + target.hide() + target.reset() + + def _start_delay_penalty(self): + super()._start_delay_penalty() + # Hide targets + for target in self.targets_hand: + target.hide() + target.reset() + + def _start_fixation_penalty(self): + self._increment_tries() + self.sync_event('FIXATION_PENALTY') + + # Hide targets + for target in self.targets: + target.hide() + target.reset() + + for target in self.targets_hand: + target.hide() + target.reset() + + def _end_fixation_penalty(self): + self.sync_event('TRIAL_END') + + def _start_reward(self): + super()._start_reward() + # Hide targets + for target in self.targets_hand: + target.hide() + target.reset() + + def _start_pause(self): + super()._start_pause() + + # Hide targets + for target in self.targets_hand: + target.hide() + target.reset() + + # Generator functions + @staticmethod + def row_target(nblocks=20, ntargets=3, dx=5.,offset1=(0,0,-2),offset2=(0,0,6.),offset3=(0,0,-7.5),origin=(0,0,0),seed=0): + ''' + Generates a sequence of 3D for 2 eye targets and 1 hand target at a given distance from the origin + + Parameters + ---------- + nblocks : int + The number of ntarget pairs in the sequence. + ntargets : int + The number of equally spaced targets + distance : float + The distance in cm between targets + offset1 : 3-tuple + y location of the first eye target + offset2 : 3-tuple + y location of the second eye target + offset3 : 3-tuple + y location of the hand target + origin : 3-tuple + Location of the central targets + + Returns + ------- + [nblocks*ntargets x 1] array of tuples containing trial indices and [1 x 3] target coordinates + + ''' + rng = np.random.default_rng(seed=seed) + for _ in range(nblocks): + order = np.arange(ntargets**3) + rng.shuffle(order) + x_pos_candidate = [-dx,0,dx] + for t in range(ntargets**3): + idx = np.base_repr(order[t],3).zfill(3) # convert a decimal number to ternary + + # Target index for hand target, initial eye target, final eye target + idx1 = int(idx[0]) + idx2 = int(idx[1]) + idx3 = int(idx[2]) + + # Get positions for each target + x_pos1 = x_pos_candidate[idx1] + x_pos2 = x_pos_candidate[idx2] + x_pos3 = x_pos_candidate[idx3] + pos1 = np.array([x_pos1,0,0]).T + pos2 = np.array([x_pos2,0,0]).T + pos3 = np.array([x_pos3,0,0]).T + + yield [idx1],[idx2],[idx3],[pos1+offset1+origin],[pos2+offset2+origin],[pos3+offset3+origin] + + @staticmethod + def sac_hand_2d(nblocks=20, ntargets=3, dx=10,offset1=(0,0,-2),offset2=(0,0,6.),offset3=(0,0,-7.5),origin=(0,0,0),seed=0): + ''' + Pairs of hand targets and eye targets + + Returns + ------- + [nblocks*ntargets x 1] array of tuples containing trial indices and [3 x 3] target coordinates + ''' + + gen = HandConstrainedEyeCapture.row_target(nblocks=nblocks,ntargets=ntargets,dx=dx,offset1=offset1,offset2=offset2,offset3=offset3,origin=origin,seed=seed) + for _ in range(nblocks*(ntargets**3)): + idx1,idx2,idx3,pos1,pos2,pos3 = next(gen) + + targs = np.zeros([3, 3]) + targs[0,:] = pos1[0] + targs[1,:] = pos2[0] + targs[2,:] = pos3[0] + + indices = np.zeros([3,1]) + indices[0] = idx1[0] + indices[1] = idx2[0] + ntargets + indices[2] = idx3[0] + + yield indices, targs + +class ScreenTargetCapture_Saccade(ScreenTargetCapture): + ''' + Center-out saccade task. The controller for the cursor position is eye position. + Hand cursor is also visible. You should remove the hand cursor by setting cursor_radius to 0 as needed. + ''' + + fixation_radius_buffer = traits.Float(.5, desc="additional radius for eye target") + target_color = traits.OptionsList("white", *target_colors, desc="Color of the target", bmi3d_input_options=list(target_colors.keys())) + fixation_target_color = traits.OptionsList("cyan", *target_colors, desc="Color of the eye target under fixation state", bmi3d_input_options=list(target_colors.keys())) + + def _test_enter_target(self, ts): + ''' + Check whether eye positions from a target are within the fixation distance + ''' + # Distance of an eye position from a target position + eye_pos = self.calibrated_eye_pos + target_pos = np.delete(self.targs[self.target_index],1) + d_eye = np.linalg.norm(eye_pos - target_pos) + return (d_eye <= self.target_radius + self.fixation_radius_buffer) or self.pause + + def _test_leave_target(self, ts): + ''' + Check whether eye positions from a target are outside the fixation distance + ''' + # Distance of an eye position from a target position + eye_pos = self.calibrated_eye_pos + target_pos = np.delete(self.targs[self.target_index],1) + d_eye = np.linalg.norm(eye_pos - target_pos) + return (d_eye > self.target_radius + self.fixation_radius_buffer) or self.pause + + def _start_hold(self): + super()._start_hold() + self.targets[self.target_index].sphere.color = target_colors[self.fixation_target_color] # change target color in fixating the target \ No newline at end of file From 875a9d3acbe9edc7e3f73fb099c372299c7feb04 Mon Sep 17 00:00:00 2001 From: Katherine Date: Wed, 3 Dec 2025 17:53:48 -0800 Subject: [PATCH 26/62] Modified --- analysis/online_analysis.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/analysis/online_analysis.py b/analysis/online_analysis.py index fa2f8d16..99db0e20 100644 --- a/analysis/online_analysis.py +++ b/analysis/online_analysis.py @@ -637,10 +637,10 @@ def init(self): # Always start with the behavior analysis worker print('init in state', self.state) data_queue = mp.Queue() - if self.task_params['experiment_name'] == 'ManualControl' and 'fixation_dist' not in self.task_params: + if self.task_params['experiment_name'] == 'ManualControl': self.analysis_workers.append((BehaviorAnalysisWorker(self.task_params, data_queue), data_queue)) - elif self.task_params['experiment_name'] == 'ManualControl' and 'fixation_dist' in self.task_params: + elif self.task_params['experiment_name'] == 'EyeConstrainedManualControl': self.analysis_workers.append((SaccadeAnalysisWorker(self.task_params, data_queue), data_queue)) elif self.task_params['experiment_name'] == 'SaccadeTask': From aefe8331c2a60f561b85b15a4019f45f1fc5dfaa Mon Sep 17 00:00:00 2001 From: Katherine Date: Wed, 3 Dec 2025 18:05:03 -0800 Subject: [PATCH 27/62] removed old tasks --- built_in_tasks/target_capture_task_xt.py | 355 ----------------------- 1 file changed, 355 deletions(-) diff --git a/built_in_tasks/target_capture_task_xt.py b/built_in_tasks/target_capture_task_xt.py index e2cb5362..c5b04b50 100644 --- a/built_in_tasks/target_capture_task_xt.py +++ b/built_in_tasks/target_capture_task_xt.py @@ -547,361 +547,6 @@ def sequence_2D(nblocks=100, distance=6.8): yield indices, targs - - -class HandConstrainedEyeCapture(ScreenTargetCapture): - ''' - Saccade task with holding another target with hand. Subjects need to hold an initial target with their hand. - Then they need to fixate the first eye target and make a saccade for the second eye target - ''' - - fixation_radius = traits.Float(2.5, desc="Distance from center that is considered a broken fixation") - fixation_penalty_time = traits.Float(1.0, desc="Time in fixation penalty state") - fixation_target_color = traits.OptionsList("cyan", *target_colors, desc="Color of the eye target under fixation state", bmi3d_input_options=list(target_colors.keys())) - eye_target_color = traits.OptionsList("white", *target_colors, desc="Color of the eye target", bmi3d_input_options=list(target_colors.keys())) - fixation_radius_buffer = traits.Float(.5, desc="additional radius for eye target") - - status = dict( - wait = dict(start_trial="target", start_pause="pause"), - target = dict(start_pause="pause", leave_target2="hold_penalty",timeout="timeout_penalty",enter_target="hold"), - hold = dict(start_pause="pause", leave_target2="hold_penalty",leave_target="target", gaze_target="fixation"), # must hold an initial hand-target and eye-target - fixation = dict(start_pause="pause", leave_target="delay_penalty",hold_complete="delay", fixation_break="fixation_penalty"), # must hold an initial hand-target and eye-target to initiate a trial - delay = dict(leave_target="delay_penalty", delay_complete="targ_transition", fixation_break="fixation_penalty", start_pause="pause"), - targ_transition = dict(trial_complete="reward", trial_abort="wait", trial_incomplete="target", start_pause="pause"), - timeout_penalty = dict(timeout_penalty_end="targ_transition", start_pause="pause", end_state=True), - hold_penalty = dict(hold_penalty_end="targ_transition", start_pause="pause", end_state=True), - delay_penalty = dict(delay_penalty_end="targ_transition", start_pause="pause", end_state=True), - fixation_penalty = dict(fixation_penalty_end="targ_transition", start_pause="pause", end_state=True), - reward = dict(reward_end="wait", start_pause="pause", stoppable=False, end_state=True), - pause = dict(end_pause="wait", end_state=True), - ) - - sequence_generators = ['row_target','sac_hand_2d'] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Instantiate the targets - instantiate_targets = kwargs.pop('instantiate_targets', True) - if instantiate_targets: - - # Target 1 and 2 are for saccade. Target 3 is for hand - target1 = VirtualCircularTarget(target_radius=self.fixation_radius, target_color=target_colors[self.eye_target_color]) - target2 = VirtualCircularTarget(target_radius=self.fixation_radius, target_color=target_colors[self.eye_target_color]) - target3 = VirtualCircularTarget(target_radius=self.target_radius, target_color=target_colors[self.target_color]) - - self.targets = [target1, target2] - self.targets_hand = [target3] - - def _parse_next_trial(self): - '''Check that the generator has the required data''' - self.gen_indices, self.targs = self.next_trial # 2 target positions for hand and eye - - # Update the data sinks with trial information - self.trial_record['trial'] = self.calc_trial_num() # TODO save both eye and hand target positions - for i in range(len(self.gen_indices)): - self.trial_record['index'] = self.gen_indices[i] - self.trial_record['target'] = self.targs[i] - self.sinks.send("trials", self.trial_record) - - def _test_gaze_target(self,ts): - ''' - Check whether eye positions from a target are within the fixation distance - ''' - # Distance of an eye position from a target position - eye_pos = self.calibrated_eye_pos - target_pos = np.delete(self.targs[self.target_index],1) - d_eye = np.linalg.norm(eye_pos - target_pos) - return (d_eye <= self.fixation_radius + self.fixation_radius_buffer) or self.pause - - def _test_fixation_break(self,ts): - ''' - Triggers the fixation_penalty state when eye positions are outside fixation distance - ''' - # Distance of an eye position from a target position - eye_pos = self.calibrated_eye_pos - target_pos = np.delete(self.targs[self.target_index],1) - d_eye = np.linalg.norm(eye_pos - target_pos) - return (d_eye > self.fixation_radius + self.fixation_radius_buffer) or self.pause - - def _test_fixation_penalty_end(self,ts): - return (ts > self.fixation_penalty_time) - - def _test_enter_target(self, ts): - ''' - return true if the distance between center of cursor and target is smaller than the cursor radius - ''' - cursor_pos = self.plant.get_endpoint_pos() - d = np.linalg.norm(cursor_pos - self.targs[-1]) # hand must be within the initial target - return d <= (self.target_radius - self.cursor_radius) or self.pause - - def _test_leave_target(self, ts): - ''' - return true if cursor moves outside the exit radius - ''' - cursor_pos = self.plant.get_endpoint_pos() - d = np.linalg.norm(cursor_pos - self.targs[-1]) # hand must be within the initial target - return d > (self.target_radius - self.cursor_radius) or self.pause - - def _test_leave_target2(self, ts): - ''' - return true if cursor moves outside the exit radius (This is for the second target state) - ''' - if self.target_index > 0: - cursor_pos = self.plant.get_endpoint_pos() - d = np.linalg.norm(cursor_pos - self.targs[-1]) # hand must be within the initial target - return d > (self.target_radius - self.cursor_radius) or self.pause - - def _start_wait(self): - super()._start_wait() - # Redefine chain length because targs in this task has both eye and hand targets - self.chain_length = len(self.targets) - - # Initialize fixation state - self.num_hold_state = 0 - - if self.calc_trial_num() == 0: - - # Instantiate the targets here so they don't show up in any states that might come before "wait" - for target in self.targets: - for model in target.graphics_models: - self.add_model(model) - target.hide() - - for target in self.targets_hand: - for model in target.graphics_models: - self.add_model(model) - target.hide() - - def _start_target(self): - if self.num_hold_state == 0: - self.target_index += 1 # target index shouldn't be incremented after hold break loop - - # Show target if it is hidden (this is the first target, or previous state was a penalty) - target_hand = self.targets_hand[0] - if self.target_index == 0: - target_hand.move_to_position(self.targs[-1]) - target_hand.show() - self.sync_event('TARGET_ON', self.gen_indices[-1]) - - else: - target = self.targets[self.target_index % 2] - target.hide() # hide hand target - self.sync_event('EYE_TARGET_OFF', self.gen_indices[self.target_index % 2]) - - def _start_hold(self): - #self.sync_event('CURSOR_ENTER_TARGET', self.gen_indices[self.target_index]) - self.num_hold_state = 1 - - # Show target if it is hidden (this is the first target, or previous state was a penalty) - target = self.targets[self.target_index % 2] - if self.target_index == 0: - target.move_to_position(self.targs[self.target_index]) - target.show() - self.sync_event('EYE_TARGET_ON', self.gen_indices[self.target_index]) - - def _start_fixation(self): - self.num_hold_state = 0 - self.targets[self.target_index].sphere.color = target_colors[self.fixation_target_color] # change target color in fixation state - self.sync_event('FIXATION', self.gen_indices[self.target_index]) - - def _while_fixation(self): - pass - - def _end_fixation(self): - pass - - def _start_delay(self): - # Make next target visible unless this is the final target in the trial - next_idx = (self.target_index + 1) - if next_idx < self.chain_length: - target = self.targets[next_idx % 2] - target.move_to_position(self.targs[next_idx % 2]) - target.show() - self.sync_event('EYE_TARGET_ON', self.gen_indices[next_idx % 2]) - else: - # This delay state should only last 1 cycle, don't sync anything - pass - - def _start_targ_transition(self): - if self.target_index == -1: - - # Came from a penalty state - pass - elif self.target_index + 1 < self.chain_length: - - # Hide the current target if there are more - self.targets[self.target_index % 2].hide() - self.sync_event('EYE_TARGET_OFF', self.gen_indices[self.target_index]) - - def _start_timeout_penalty(self): - super()._start_timeout_penalty() - self.num_hold_state = 0 - for target in self.targets_hand: - target.hide() - target.reset() - - def _start_hold_penalty(self): - super()._start_hold_penalty() - self.num_hold_state = 0 - # Hide targets - for target in self.targets_hand: - target.hide() - target.reset() - - def _start_delay_penalty(self): - super()._start_delay_penalty() - # Hide targets - for target in self.targets_hand: - target.hide() - target.reset() - - def _start_fixation_penalty(self): - self._increment_tries() - self.sync_event('FIXATION_PENALTY') - - # Hide targets - for target in self.targets: - target.hide() - target.reset() - - for target in self.targets_hand: - target.hide() - target.reset() - - def _end_fixation_penalty(self): - self.sync_event('TRIAL_END') - - def _start_reward(self): - super()._start_reward() - # Hide targets - for target in self.targets_hand: - target.hide() - target.reset() - - def _start_pause(self): - super()._start_pause() - - # Hide targets - for target in self.targets_hand: - target.hide() - target.reset() - - # Generator functions - @staticmethod - def row_target(nblocks=20, ntargets=3, dx=5.,offset1=(0,0,-2),offset2=(0,0,6.),offset3=(0,0,-7.5),origin=(0,0,0),seed=0): - ''' - Generates a sequence of 3D for 2 eye targets and 1 hand target at a given distance from the origin - - Parameters - ---------- - nblocks : int - The number of ntarget pairs in the sequence. - ntargets : int - The number of equally spaced targets - distance : float - The distance in cm between targets - offset1 : 3-tuple - y location of the first eye target - offset2 : 3-tuple - y location of the second eye target - offset3 : 3-tuple - y location of the hand target - origin : 3-tuple - Location of the central targets - - Returns - ------- - [nblocks*ntargets x 1] array of tuples containing trial indices and [1 x 3] target coordinates - - ''' - rng = np.random.default_rng(seed=seed) - for _ in range(nblocks): - order = np.arange(ntargets**3) - rng.shuffle(order) - x_pos_candidate = [-dx,0,dx] - for t in range(ntargets**3): - idx = np.base_repr(order[t],3).zfill(3) # convert a decimal number to ternary - - # Target index for hand target, initial eye target, final eye target - idx1 = int(idx[0]) - idx2 = int(idx[1]) - idx3 = int(idx[2]) - - # Get positions for each target - x_pos1 = x_pos_candidate[idx1] - x_pos2 = x_pos_candidate[idx2] - x_pos3 = x_pos_candidate[idx3] - pos1 = np.array([x_pos1,0,0]).T - pos2 = np.array([x_pos2,0,0]).T - pos3 = np.array([x_pos3,0,0]).T - - yield [idx1],[idx2],[idx3],[pos1+offset1+origin],[pos2+offset2+origin],[pos3+offset3+origin] - - @staticmethod - def sac_hand_2d(nblocks=20, ntargets=3, dx=10,offset1=(0,0,-2),offset2=(0,0,6.),offset3=(0,0,-7.5),origin=(0,0,0),seed=0): - ''' - Pairs of hand targets and eye targets - - Returns - ------- - [nblocks*ntargets x 1] array of tuples containing trial indices and [3 x 3] target coordinates - ''' - - gen = HandConstrainedEyeCapture.row_target(nblocks=nblocks,ntargets=ntargets,dx=dx,offset1=offset1,offset2=offset2,offset3=offset3,origin=origin,seed=seed) - for _ in range(nblocks*(ntargets**3)): - idx1,idx2,idx3,pos1,pos2,pos3 = next(gen) - - targs = np.zeros([3, 3]) - targs[0,:] = pos1[0] - targs[1,:] = pos2[0] - targs[2,:] = pos3[0] - - indices = np.zeros([3,1]) - indices[0] = idx1[0] - indices[1] = idx2[0] + ntargets - indices[2] = idx3[0] - - yield indices, targs - - - -class ScreenTargetCapture_Saccade(ScreenTargetCapture): - ''' - Center-out saccade task. The controller for the cursor position is eye position. - Hand cursor is also visible. You should remove the hand cursor by setting cursor_radius to 0 as needed. - ''' - - fixation_radius_buffer = traits.Float(.5, desc="additional radius for eye target") - target_color = traits.OptionsList("white", *target_colors, desc="Color of the target", bmi3d_input_options=list(target_colors.keys())) - fixation_target_color = traits.OptionsList("cyan", *target_colors, desc="Color of the eye target under fixation state", bmi3d_input_options=list(target_colors.keys())) - - def _test_enter_target(self, ts): - ''' - Check whether eye positions from a target are within the fixation distance - ''' - # Distance of an eye position from a target position - eye_pos = self.calibrated_eye_pos - target_pos = np.delete(self.targs[self.target_index],1) - d_eye = np.linalg.norm(eye_pos - target_pos) - return (d_eye <= self.target_radius + self.fixation_radius_buffer) or self.pause - - def _test_leave_target(self, ts): - ''' - Check whether eye positions from a target are outside the fixation distance - ''' - # Distance of an eye position from a target position - eye_pos = self.calibrated_eye_pos - target_pos = np.delete(self.targs[self.target_index],1) - d_eye = np.linalg.norm(eye_pos - target_pos) - return (d_eye > self.target_radius + self.fixation_radius_buffer) or self.pause - - def _start_hold(self): - super()._start_hold() - self.targets[self.target_index].sphere.color = target_colors[self.fixation_target_color] # change target color in fixating the target - - - class ScreenTargetCapture_ReadySet(ScreenTargetCapture): ''' From ea4da30e5e59aae19e509315ed75bc68b98cfda8 Mon Sep 17 00:00:00 2001 From: katherineperks Date: Fri, 9 Jan 2026 14:27:50 -0800 Subject: [PATCH 28/62] make changes to graphics --- tests/test_graphics.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_graphics.py b/tests/test_graphics.py index 84d7bee3..ec05c792 100644 --- a/tests/test_graphics.py +++ b/tests/test_graphics.py @@ -7,7 +7,9 @@ from riglib.pupillabs import utils from riglib.stereo_opengl.textures import Texture -os.environ['DISPLAY'] = ':0' +# os.environ['DISPLAY'] = ':0' +os.environ['LIBGL_ALWAYS_INDIRECT'] = '1' +os.environ['LIBGL_DEBUG'] = 'verbose' from riglib.stereo_opengl.environment import Grid from riglib.stereo_opengl.window import Window, Window2D, FPScontrol From 11448fdf6b5237bff86dee05b3d5f7673a90f2b4 Mon Sep 17 00:00:00 2001 From: katherineperks Date: Fri, 9 Jan 2026 15:23:21 -0800 Subject: [PATCH 29/62] undo changes to graphics --- tests/test_graphics.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_graphics.py b/tests/test_graphics.py index ec05c792..84d7bee3 100644 --- a/tests/test_graphics.py +++ b/tests/test_graphics.py @@ -7,9 +7,7 @@ from riglib.pupillabs import utils from riglib.stereo_opengl.textures import Texture -# os.environ['DISPLAY'] = ':0' -os.environ['LIBGL_ALWAYS_INDIRECT'] = '1' -os.environ['LIBGL_DEBUG'] = 'verbose' +os.environ['DISPLAY'] = ':0' from riglib.stereo_opengl.environment import Grid from riglib.stereo_opengl.window import Window, Window2D, FPScontrol From 1c3f742875c4dc9fa3f732f56a3d8d18db710a45 Mon Sep 17 00:00:00 2001 From: katherineperks Date: Mon, 12 Jan 2026 12:59:34 -0800 Subject: [PATCH 30/62] started new two chocie center out task --- built_in_tasks/target_capture_multiple_choice.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 built_in_tasks/target_capture_multiple_choice.py diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py new file mode 100644 index 00000000..e69de29b From c73ce64ecd091c3ef448d7a4e85d1642492a3c4f Mon Sep 17 00:00:00 2001 From: katherineperks Date: Mon, 12 Jan 2026 14:14:46 -0800 Subject: [PATCH 31/62] outline new task --- .../target_capture_multiple_choice.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index e69de29b..87ec0678 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -0,0 +1,40 @@ +import numpy as np +import random +import os + +from .target_graphics import * +from .target_capture_task import ScreenTargetCapture +from riglib.experiment import traits + +class TwoChoiceTargetCapture(ScreenTargetCapture): + ''' + Add a penalty state when subjects looks away. + ''' + + status = dict( + wait = dict(start_trial="target"), + target = dict(timeout="timeout_penalty", + enter_target="hold"), + hold = dict(leave_target="target"), + delay = dict(leave_target="delay_penalty", + delay_complete="targ_transition"), + targ_transition = dict(trial_complete="reward", + trial_abort="wait", + trial_incomplete="target"), + timeout_penalty = dict(timeout_penalty_end="targ_transition", + end_state=True), + hold_penalty = dict(hold_penalty_end="targ_transition", + end_state=True), + delay_penalty = dict(delay_penalty_end="targ_transition", + end_state=True), + reward = dict(reward_end="wait", + stoppable=False, + end_state=True), + ) + + def _start_target(self): + super()._start_target() + + #show two targets at trial start + + \ No newline at end of file From 74a035b0cce0ce8633d72e708f8fb9cb3ce20b7e Mon Sep 17 00:00:00 2001 From: AJ Date: Mon, 12 Jan 2026 14:31:51 -0800 Subject: [PATCH 32/62] added task structure and variables --- .../target_capture_multiple_choice.py | 159 +++++++++++++++++- 1 file changed, 156 insertions(+), 3 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index 87ec0678..a1fbc0ee 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -15,7 +15,7 @@ class TwoChoiceTargetCapture(ScreenTargetCapture): wait = dict(start_trial="target"), target = dict(timeout="timeout_penalty", enter_target="hold"), - hold = dict(leave_target="target"), + hold = dict(leave_target="hold_penalty", hold_complete="delay"), delay = dict(leave_target="delay_penalty", delay_complete="targ_transition"), targ_transition = dict(trial_complete="reward", @@ -32,9 +32,162 @@ class TwoChoiceTargetCapture(ScreenTargetCapture): end_state=True), ) + sequence_generators = ['dual_peripheral'] + + def __init__(self, *args, **kwargs): + kwargs['instantiate_targets'] = False + super().__init__(*args, **kwargs) + + # Create three targets: center + two peripherals + target_center = VirtualCircularTarget(target_radius=self.target_radius, + target_color=target_colors[self.target_color]) + target_periph1 = VirtualCircularTarget(target_radius=self.target_radius, + target_color=target_colors[self.target_color]) + target_periph2 = VirtualCircularTarget(target_radius=self.target_radius, + target_color=target_colors[self.target_color]) + + self.targets = [target_center, target_periph1, target_periph2] + self.chosen_target = None + def _start_target(self): super()._start_target() + + if self.target_index == 0: + # Show only the center target initially + self.targets[0].move_to_position(self.targs[0]) + self.targets[0].show() + self.sync_event('TARGET_ON', 0) + + def _start_delay(self): + super()._start_delay() + + # After holding center, show BOTH peripheral targets + if self.target_index == 0: # Just finished holding center + self.targets[1].move_to_position(self.targs[1]) + self.targets[1].show() + + self.targets[2].move_to_position(self.targs[2]) + self.targets[2].show() + + self.sync_event('TARGET_ON', [1, 2]) + + def _start_hold(self): + super()._start_hold() + + if self.target_index == 0: + # Just entered center target + self.sync_event('CURSOR_ENTER_TARGET', 0) + else: + # Entered one of the peripheral targets - record which one + cursor_pos = self.plant.get_endpoint_pos() + d1 = np.linalg.norm(cursor_pos - self.targs[1]) + d2 = np.linalg.norm(cursor_pos - self.targs[2]) + + if d1 < d2: + self.chosen_target = 1 + self.targets[2].hide() # Hide unchosen target + else: + self.chosen_target = 2 + self.targets[1].hide() # Hide unchosen target + + self.sync_event('CURSOR_ENTER_TARGET', self.chosen_target) + + def _start_reward(self): + super()._start_reward() + # Hide all targets + for target in self.targets: + target.hide() + target.reset() + + @staticmethod + def dual_peripheral(nblocks=100, distance=10, origin=(0,0,0)): + ''' + Generates center target + two peripheral targets + ''' + rng = np.random.default_rng() + for _ in range(nblocks): + # Generate two random angles for peripheral targets + angles = rng.uniform(0, 2*np.pi, size=2) + + # Target 0: center + center = np.array(origin) + + # Target 1: first peripheral + pos1 = np.array([ + distance*np.cos(angles[0]), + 0, + distance*np.sin(angles[0]) + ]) + origin + + # Target 2: second peripheral + pos2 = np.array([ + distance*np.cos(angles[1]), + 0, + distance*np.sin(angles[1]) + ]) + origin + + # Yield indices and positions for all three targets + yield [0, 1, 2], [center, pos1, pos2] + + + def _test_enter_target(self, ts): + ''' + Check if cursor is in the appropriate target(s) + ''' + cursor_pos = self.plant.get_endpoint_pos() + + if self.target_index == 0: + # First target: check only center + d = np.linalg.norm(cursor_pos - self.targs[0]) + return d <= (self.target_radius - self.cursor_radius) + + else: + # After center hold: check if in EITHER peripheral target + d1 = np.linalg.norm(cursor_pos - self.targs[1]) + in_target1 = d1 <= (self.target_radius - self.cursor_radius) + + d2 = np.linalg.norm(cursor_pos - self.targs[2]) + in_target2 = d2 <= (self.target_radius - self.cursor_radius) + + return in_target1 or in_target2 + + def _test_leave_target(self, ts): + ''' + Check if cursor leaves current target + ''' + cursor_pos = self.plant.get_endpoint_pos() + + if self.target_index == 0: + # Leaving center target + d = np.linalg.norm(cursor_pos - self.targs[0]) + else: + # Leaving whichever peripheral was chosen + d = np.linalg.norm(cursor_pos - self.targs[self.chosen_target]) + + rad = self.target_radius - self.cursor_radius + return d > rad or super()._test_leave_target(ts) - #show two targets at trial start + def _test_hold_complete(self, time_in_state): + ''' + Hold complete after holding center OR after holding chosen peripheral + ''' + return time_in_state > self.hold_time + + def _test_trial_complete(self, time_in_state): + ''' + Trial complete after acquiring either peripheral target (index 1) + ''' + return self.target_index == 1 - \ No newline at end of file + def _test_leave_target(self, ts): + cursor_pos = self.plant.get_endpoint_pos() + + if self.target_index == 0: + d = np.linalg.norm(cursor_pos - self.targs[0]) + elif self.chosen_target is not None: # Add safety check + d = np.linalg.norm(cursor_pos - self.targs[self.chosen_target]) + else: + return False # No target chosen yet, can't have left it + + rad = self.target_radius - self.cursor_radius + return d > rad or super()._test_leave_target(ts) \ No newline at end of file From 791fb78c23a2344190a34bd51cb2052306d86400 Mon Sep 17 00:00:00 2001 From: AJ Date: Tue, 13 Jan 2026 12:44:32 -0800 Subject: [PATCH 33/62] some smalle updates --- .../target_capture_multiple_choice.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index a1fbc0ee..e4499370 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -58,19 +58,6 @@ def _start_target(self): self.targets[0].show() self.sync_event('TARGET_ON', 0) - def _start_delay(self): - super()._start_delay() - - # After holding center, show BOTH peripheral targets - if self.target_index == 0: # Just finished holding center - self.targets[1].move_to_position(self.targs[1]) - self.targets[1].show() - - self.targets[2].move_to_position(self.targs[2]) - self.targets[2].show() - - self.sync_event('TARGET_ON', [1, 2]) - def _start_hold(self): super()._start_hold() @@ -91,6 +78,19 @@ def _start_hold(self): self.targets[1].hide() # Hide unchosen target self.sync_event('CURSOR_ENTER_TARGET', self.chosen_target) + + def _start_delay(self): + super()._start_delay() + + # After holding center, show BOTH peripheral targets + if self.target_index == 0: # Just finished holding center + self.targets[1].move_to_position(self.targs[1]) + self.targets[1].show() + + self.targets[2].move_to_position(self.targs[2]) + self.targets[2].show() + + self.sync_event('TARGET_ON', [1, 2]) def _start_reward(self): super()._start_reward() From 0b84099a110ce5f69927675513fa4ebbd1cbee2a Mon Sep 17 00:00:00 2001 From: AJ Date: Tue, 13 Jan 2026 13:07:32 -0800 Subject: [PATCH 34/62] updated the two choice task --- .../target_capture_multiple_choice.py | 24 ++++--------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index e4499370..0380adf8 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -21,11 +21,11 @@ class TwoChoiceTargetCapture(ScreenTargetCapture): targ_transition = dict(trial_complete="reward", trial_abort="wait", trial_incomplete="target"), - timeout_penalty = dict(timeout_penalty_end="targ_transition", + timeout_penalty = dict(timeout_penalty_end="wait", end_state=True), - hold_penalty = dict(hold_penalty_end="targ_transition", + hold_penalty = dict(hold_penalty_end="wait", end_state=True), - delay_penalty = dict(delay_penalty_end="targ_transition", + delay_penalty = dict(delay_penalty_end="wait", end_state=True), reward = dict(reward_end="wait", stoppable=False, @@ -58,6 +58,7 @@ def _start_target(self): self.targets[0].show() self.sync_event('TARGET_ON', 0) + def _start_hold(self): super()._start_hold() @@ -78,7 +79,7 @@ def _start_hold(self): self.targets[1].hide() # Hide unchosen target self.sync_event('CURSOR_ENTER_TARGET', self.chosen_target) - + def _start_delay(self): super()._start_delay() @@ -151,21 +152,6 @@ def _test_enter_target(self, ts): return in_target1 or in_target2 - def _test_leave_target(self, ts): - ''' - Check if cursor leaves current target - ''' - cursor_pos = self.plant.get_endpoint_pos() - - if self.target_index == 0: - # Leaving center target - d = np.linalg.norm(cursor_pos - self.targs[0]) - else: - # Leaving whichever peripheral was chosen - d = np.linalg.norm(cursor_pos - self.targs[self.chosen_target]) - - rad = self.target_radius - self.cursor_radius - return d > rad or super()._test_leave_target(ts) def _test_hold_complete(self, time_in_state): ''' From 4863bad5d0354a659d4f55857a1b38d79d030404 Mon Sep 17 00:00:00 2001 From: katherineperks Date: Wed, 14 Jan 2026 10:33:43 -0800 Subject: [PATCH 35/62] most recent updates to two choice --- built_in_tasks/manualcontrolmultitasks.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/built_in_tasks/manualcontrolmultitasks.py b/built_in_tasks/manualcontrolmultitasks.py index 92ed268e..a2b92311 100644 --- a/built_in_tasks/manualcontrolmultitasks.py +++ b/built_in_tasks/manualcontrolmultitasks.py @@ -13,6 +13,7 @@ from .target_capture_task_eye import EyeConstrainedTargetCapture, HandConstrainedEyeCapture, ScreenTargetCapture_Saccade from .target_tracking_task import ScreenTargetTracking from .rotation_matrices import * +from .target_capture_multiple_choice import TwoChoiceTargetCapture class ManualControlMixin(traits.HasTraits): '''Target capture task where the subject operates a joystick @@ -225,4 +226,9 @@ class ReadySetGoTask(ManualControlMixin, ScreenTargetCapture_ReadySet): Center out task with ready set go auditory cues. Cues separated by 500 ms and participant is expected to move on final go cue. Additionally, participant must move out of center circle (mustmv_time) parameter or there will be an error. ''' - pass \ No newline at end of file + pass + +class TwoChoiceManualControl(ManualControlMixin, TwoChoiceTargetCapture): + ''' + Center out task with two peripheral targets + ''' \ No newline at end of file From 040d922600c97b8a3feb12b78945801ec677030c Mon Sep 17 00:00:00 2001 From: katherineperks Date: Wed, 14 Jan 2026 10:46:29 -0800 Subject: [PATCH 36/62] small updates --- .../target_capture_multiple_choice.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index 0380adf8..2b909a61 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -7,8 +7,8 @@ from riglib.experiment import traits class TwoChoiceTargetCapture(ScreenTargetCapture): - ''' - Add a penalty state when subjects looks away. +''' ''' + #Add a penalty state when subjects looks away. ''' status = dict( @@ -103,7 +103,7 @@ def _start_reward(self): @staticmethod def dual_peripheral(nblocks=100, distance=10, origin=(0,0,0)): ''' - Generates center target + two peripheral targets + #Generates center target + two peripheral targets ''' rng = np.random.default_rng() for _ in range(nblocks): @@ -128,12 +128,14 @@ def dual_peripheral(nblocks=100, distance=10, origin=(0,0,0)): ]) + origin # Yield indices and positions for all three targets - yield [0, 1, 2], [center, pos1, pos2] - + yield [0, 1, 2], [cself.pts = (unit*[-30/1.36,self.radius,self.radius])+intial[0] + for i in range(1,len(intial)): + self.pts = np.vstack([self.pts, (unit*[(i-30)/3,self.radius,self.radius])+intial[i]])enter, pos1, pos2] +BMIControlMultiDirectionConstraint def _test_enter_target(self, ts): ''' - Check if cursor is in the appropriate target(s) + #Check if cursor is in the appropriate target(s) ''' cursor_pos = self.plant.get_endpoint_pos() @@ -155,13 +157,13 @@ def _test_enter_target(self, ts): def _test_hold_complete(self, time_in_state): ''' - Hold complete after holding center OR after holding chosen peripheral + #Hold complete after holding center OR after holding chosen peripheral ''' return time_in_state > self.hold_time def _test_trial_complete(self, time_in_state): ''' - Trial complete after acquiring either peripheral target (index 1) + #Trial complete after acquiring either peripheral target (index 1) ''' return self.target_index == 1 @@ -176,4 +178,4 @@ def _test_leave_target(self, ts): return False # No target chosen yet, can't have left it rad = self.target_radius - self.cursor_radius - return d > rad or super()._test_leave_target(ts) \ No newline at end of file + return d > rad or super()._test_leave_target(ts)''' \ No newline at end of file From 0282fa3d2a932c43b78cbcc841b058d99e5ce8c5 Mon Sep 17 00:00:00 2001 From: katherineperks Date: Wed, 14 Jan 2026 11:02:25 -0800 Subject: [PATCH 37/62] errors in the duaal peripheral generator --- built_in_tasks/target_capture_multiple_choice.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index 2b909a61..b40a635a 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -7,7 +7,7 @@ from riglib.experiment import traits class TwoChoiceTargetCapture(ScreenTargetCapture): -''' ''' + ''' #Add a penalty state when subjects looks away. ''' @@ -128,10 +128,9 @@ def dual_peripheral(nblocks=100, distance=10, origin=(0,0,0)): ]) + origin # Yield indices and positions for all three targets - yield [0, 1, 2], [cself.pts = (unit*[-30/1.36,self.radius,self.radius])+intial[0] + yield [0, 1, 2], [self.pts = (unit*[-30/1.36,self.radius,self.radius])+intial[0] for i in range(1,len(intial)): self.pts = np.vstack([self.pts, (unit*[(i-30)/3,self.radius,self.radius])+intial[i]])enter, pos1, pos2] -BMIControlMultiDirectionConstraint def _test_enter_target(self, ts): ''' @@ -178,4 +177,4 @@ def _test_leave_target(self, ts): return False # No target chosen yet, can't have left it rad = self.target_radius - self.cursor_radius - return d > rad or super()._test_leave_target(ts)''' \ No newline at end of file + return d > rad or super()._test_leave_target(ts) \ No newline at end of file From a2af76221a23f2fb9810fcc1dc1ed6ef0969199a Mon Sep 17 00:00:00 2001 From: katherineperks Date: Thu, 15 Jan 2026 11:55:27 -0800 Subject: [PATCH 38/62] added initial colors, incorrect behavior on selection of second peripheral target --- .../target_capture_multiple_choice.py | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index b40a635a..cf26785f 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -11,6 +11,9 @@ class TwoChoiceTargetCapture(ScreenTargetCapture): #Add a penalty state when subjects looks away. ''' + periph_targ1_color = traits.OptionsList("red", *target_colors, desc="Color of peripheral target 1", bmi3d_input_options=list(target_colors.keys())) + periph_targ2_color = traits.OptionsList("blue", *target_colors, desc="Color of peripheral target 1", bmi3d_input_options=list(target_colors.keys())) + status = dict( wait = dict(start_trial="target"), target = dict(timeout="timeout_penalty", @@ -42,9 +45,9 @@ def __init__(self, *args, **kwargs): target_center = VirtualCircularTarget(target_radius=self.target_radius, target_color=target_colors[self.target_color]) target_periph1 = VirtualCircularTarget(target_radius=self.target_radius, - target_color=target_colors[self.target_color]) + target_color=target_colors["red"]) target_periph2 = VirtualCircularTarget(target_radius=self.target_radius, - target_color=target_colors[self.target_color]) + target_color=target_colors["blue"]) self.targets = [target_center, target_periph1, target_periph2] self.chosen_target = None @@ -91,7 +94,18 @@ def _start_delay(self): self.targets[2].move_to_position(self.targs[2]) self.targets[2].show() - self.sync_event('TARGET_ON', [1, 2]) + self.sync_event('TARGET_ON', 1) + self.sync_event('TARGET_ON', 2) + + def _start_targ_transition(self): + super()._start_targ_transition() + if self.target_index == -1: + + # Came from a penalty state + pass + elif self.target_index == 0: + self.targets[0].hide() + self.sync_event('TARGET_OFF', self.gen_indices[self.target_index]) def _start_reward(self): super()._start_reward() @@ -126,12 +140,11 @@ def dual_peripheral(nblocks=100, distance=10, origin=(0,0,0)): 0, distance*np.sin(angles[1]) ]) + origin - + targs = np.array([center, pos1, pos2]) # Yield indices and positions for all three targets - yield [0, 1, 2], [self.pts = (unit*[-30/1.36,self.radius,self.radius])+intial[0] - for i in range(1,len(intial)): - self.pts = np.vstack([self.pts, (unit*[(i-30)/3,self.radius,self.radius])+intial[i]])enter, pos1, pos2] - + yield [0, 1, 2], targs + + def _test_enter_target(self, ts): ''' #Check if cursor is in the appropriate target(s) @@ -164,7 +177,7 @@ def _test_trial_complete(self, time_in_state): ''' #Trial complete after acquiring either peripheral target (index 1) ''' - return self.target_index == 1 + return self.target_index > 0 def _test_leave_target(self, ts): cursor_pos = self.plant.get_endpoint_pos() From 52359a9f62358854d8d793364be88891e8b29cc9 Mon Sep 17 00:00:00 2001 From: katherineperks Date: Thu, 15 Jan 2026 12:32:17 -0800 Subject: [PATCH 39/62] Can now enter the second target without issue and get rewarded for both targets. Something is weird happening with the red target though during collection --- built_in_tasks/target_capture_multiple_choice.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index cf26785f..e60a9503 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -85,7 +85,6 @@ def _start_hold(self): def _start_delay(self): super()._start_delay() - # After holding center, show BOTH peripheral targets if self.target_index == 0: # Just finished holding center self.targets[1].move_to_position(self.targs[1]) @@ -190,4 +189,4 @@ def _test_leave_target(self, ts): return False # No target chosen yet, can't have left it rad = self.target_radius - self.cursor_radius - return d > rad or super()._test_leave_target(ts) \ No newline at end of file + return d > rad #or super()._test_leave_target(ts) \ No newline at end of file From c4520b06ae9f51527b0ad4705725811aaca0d14c Mon Sep 17 00:00:00 2001 From: katherineperks Date: Thu, 15 Jan 2026 13:11:33 -0800 Subject: [PATCH 40/62] Fixed thetarget behavior, now working on the correct sequence generator --- .../target_capture_multiple_choice.py | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index e60a9503..0c32379f 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -84,7 +84,7 @@ def _start_hold(self): self.sync_event('CURSOR_ENTER_TARGET', self.chosen_target) def _start_delay(self): - super()._start_delay() + #super()._start_delay() # After holding center, show BOTH peripheral targets if self.target_index == 0: # Just finished holding center self.targets[1].move_to_position(self.targs[1]) @@ -143,6 +143,29 @@ def dual_peripheral(nblocks=100, distance=10, origin=(0,0,0)): # Yield indices and positions for all three targets yield [0, 1, 2], targs + @staticmethod + def dual_centerout_2D(nblocks=100, ntargets=8, distance=10, origin=(0,0,0)): + ''' + triplets of central targets at the origin and 2 peripheral targets centered around the origin + + Returns + ------- + [nblocks*ntargets x 1] array of tuples containing trial indices and [2 x 3] target coordinates + ''' + gen = ScreenTargetCapture.out_2D(nblocks, ntargets, distance, origin) + for _ in range(nblocks*ntargets): + + idx, pos = next(gen) + while pos[0][-1]==0.0 + idx, pos = next(gen) + + targs = np.zeros([3, 3]) + origin + targs[1,:] = pos[0] + targs[2,:] = pos[0]*[-1,1,1] #flip the position + indices = np.zeros([3,1]) + indices[1] = 1 + indices[2] = 2 + yield indices, targs def _test_enter_target(self, ts): ''' From 3e25f1a770a7d0282c5144c1332fcf2925cb3843 Mon Sep 17 00:00:00 2001 From: katherineperks Date: Thu, 15 Jan 2026 13:24:19 -0800 Subject: [PATCH 41/62] Fixed thetarget behavior, now working on the correct sequence generator --- built_in_tasks/target_capture_multiple_choice.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index 0c32379f..1d359db7 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -35,7 +35,7 @@ class TwoChoiceTargetCapture(ScreenTargetCapture): end_state=True), ) - sequence_generators = ['dual_peripheral'] + sequence_generators = ['dual_centerout_2D', 'dual_peripheral'] def __init__(self, *args, **kwargs): kwargs['instantiate_targets'] = False @@ -93,7 +93,7 @@ def _start_delay(self): self.targets[2].move_to_position(self.targs[2]) self.targets[2].show() - self.sync_event('TARGET_ON', 1) + self.sync_event('TARGET_ON', 1)#dual_centerout_2D self.sync_event('TARGET_ON', 2) def _start_targ_transition(self): @@ -156,7 +156,7 @@ def dual_centerout_2D(nblocks=100, ntargets=8, distance=10, origin=(0,0,0)): for _ in range(nblocks*ntargets): idx, pos = next(gen) - while pos[0][-1]==0.0 + while pos[0][-1] == 0: idx, pos = next(gen) targs = np.zeros([3, 3]) + origin From fd0023efbff7c0a0af95a665824073c5aa823501 Mon Sep 17 00:00:00 2001 From: katherineperks Date: Thu, 15 Jan 2026 14:54:13 -0800 Subject: [PATCH 42/62] more updaes generator --- .../target_capture_multiple_choice.py | 41 +++++++++++++------ built_in_tasks/target_capture_task.py | 4 +- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index 1d359db7..f08e56c4 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -13,6 +13,10 @@ class TwoChoiceTargetCapture(ScreenTargetCapture): periph_targ1_color = traits.OptionsList("red", *target_colors, desc="Color of peripheral target 1", bmi3d_input_options=list(target_colors.keys())) periph_targ2_color = traits.OptionsList("blue", *target_colors, desc="Color of peripheral target 1", bmi3d_input_options=list(target_colors.keys())) + #hide( + #reward_time = traits.Float(.5, desc="Length of reward dispensation") + reward_multiplier = traits.Float(2.0, desc="Select the reward differential between high and low reward") + #pulses_per_total_reward = traits.Int(5, desc='the nubmer of iterations for reward pulse') status = dict( wait = dict(start_trial="target"), @@ -30,7 +34,7 @@ class TwoChoiceTargetCapture(ScreenTargetCapture): end_state=True), delay_penalty = dict(delay_penalty_end="wait", end_state=True), - reward = dict(reward_end="wait", + reward = dict(reward_end = "wait", stoppable=False, end_state=True), ) @@ -51,6 +55,13 @@ def __init__(self, *args, **kwargs): self.targets = [target_center, target_periph1, target_periph2] self.chosen_target = None + self.base_reward_time = self.reward_time + + if hasattr(self, "pulses_per_total_reward"): + self.base_pulses_per_total_reward = self.pulses_per_total_reward + else: + self.base_pulses_per_total_reward = 0 + def _start_target(self): super()._start_target() @@ -76,11 +87,17 @@ def _start_hold(self): if d1 < d2: self.chosen_target = 1 + self.reward_time = self.base_reward_time * self.reward_multiplier + self.pulses_per_total_reward = np.ceil(self.reward_multiplier)*self.base_pulses_per_total_reward + self.targets[2].hide() # Hide unchosen target else: self.chosen_target = 2 + self.reward_time = self.base_reward_time self.targets[1].hide() # Hide unchosen target - + self.pulses_per_total_reward = self.base_pulses_per_total_reward + + self.sync_event('CURSOR_ENTER_TARGET', self.chosen_target) def _start_delay(self): @@ -93,8 +110,8 @@ def _start_delay(self): self.targets[2].move_to_position(self.targs[2]) self.targets[2].show() - self.sync_event('TARGET_ON', 1)#dual_centerout_2D - self.sync_event('TARGET_ON', 2) + self.sync_event('TARGET_ON', 1)#Convert this index to position index + #self.sync_event('TARGET_ON', 2) def _start_targ_transition(self): super()._start_targ_transition() @@ -108,10 +125,8 @@ def _start_targ_transition(self): def _start_reward(self): super()._start_reward() - # Hide all targets - for target in self.targets: - target.hide() - target.reset() + self.targets[self.chosen_target].cue_trial_end_success() + self.sync_event('REWARD') @staticmethod def dual_peripheral(nblocks=100, distance=10, origin=(0,0,0)): @@ -156,15 +171,15 @@ def dual_centerout_2D(nblocks=100, ntargets=8, distance=10, origin=(0,0,0)): for _ in range(nblocks*ntargets): idx, pos = next(gen) - while pos[0][-1] == 0: + while abs(pos[0][0]) < 0.1: idx, pos = next(gen) targs = np.zeros([3, 3]) + origin targs[1,:] = pos[0] targs[2,:] = pos[0]*[-1,1,1] #flip the position indices = np.zeros([3,1]) - indices[1] = 1 - indices[2] = 2 + indices[1] = idx[0] + indices[2] = 10 - idx[0] yield indices, targs def _test_enter_target(self, ts): @@ -212,4 +227,6 @@ def _test_leave_target(self, ts): return False # No target chosen yet, can't have left it rad = self.target_radius - self.cursor_radius - return d > rad #or super()._test_leave_target(ts) \ No newline at end of file + return d > rad #or super()._test_leave_target(ts) + + #def \ No newline at end of file diff --git a/built_in_tasks/target_capture_task.py b/built_in_tasks/target_capture_task.py index 9e5d59c7..3a4dab2f 100644 --- a/built_in_tasks/target_capture_task.py +++ b/built_in_tasks/target_capture_task.py @@ -55,7 +55,7 @@ class TargetCapture(Sequence): delay_time = traits.Float(0, desc="Length of time after a hold while the next target is on before the go cue") delay_penalty_time = traits.Float(1, desc="Length of penalty time for delay error") timeout_time = traits.Float(10, desc="Time allowed to go between targets") - timeout_penalty_time = traits.Float(1, desc="Length of penalty time for timeout error") + timeout_penalty_ScreenTargetCapturetime = traits.Float(1, desc="Length of penalty time for timeout error") max_attempts = traits.Int(10, desc='The number of attempts of a target chain before skipping to the next one') num_targets_per_attempt = traits.Int(2, desc="Minimum number of target acquisitions to be counted as an attempt") @@ -739,7 +739,7 @@ def rand_target_chain_3D(ntrials=100, chain_length=1, boundaries=(-12,12,-10,10, rng = np.random.default_rng() idx = 0 for t in range(ntrials): - + self.sync_event('TARGET_ON', 1) # Choose a random sequence of points within the boundaries pts = rng.uniform(size=(chain_length, 3))*((boundaries[1]-boundaries[0]), (boundaries[3]-boundaries[2]), (boundaries[5]-boundaries[4])) From c485bdeb3d4f8c444011a598965fc6fbbac6d197 Mon Sep 17 00:00:00 2001 From: katherineperks Date: Thu, 15 Jan 2026 15:00:54 -0800 Subject: [PATCH 43/62] need to fix the sync events --- built_in_tasks/target_capture_multiple_choice.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index f08e56c4..bfa374c5 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -88,14 +88,14 @@ def _start_hold(self): if d1 < d2: self.chosen_target = 1 self.reward_time = self.base_reward_time * self.reward_multiplier - self.pulses_per_total_reward = np.ceil(self.reward_multiplier)*self.base_pulses_per_total_reward + self.pulses_per_total_reward = int(np.ceil(self.reward_multiplier)*self.base_pulses_per_total_reward) self.targets[2].hide() # Hide unchosen target else: self.chosen_target = 2 self.reward_time = self.base_reward_time self.targets[1].hide() # Hide unchosen target - self.pulses_per_total_reward = self.base_pulses_per_total_reward + self.pulses_per_total_reward = int(self.base_pulses_per_total_reward) self.sync_event('CURSOR_ENTER_TARGET', self.chosen_target) @@ -114,7 +114,7 @@ def _start_delay(self): #self.sync_event('TARGET_ON', 2) def _start_targ_transition(self): - super()._start_targ_transition() + #super()._start_targ_transition() if self.target_index == -1: # Came from a penalty state From e5ef4097a8874bfada7d57203fe8d0f7b2d3ae9e Mon Sep 17 00:00:00 2001 From: katherineperks Date: Thu, 22 Jan 2026 10:32:12 -0800 Subject: [PATCH 44/62] added mirror_x parameter --- built_in_tasks/rotation_matrices.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/built_in_tasks/rotation_matrices.py b/built_in_tasks/rotation_matrices.py index 0fb31e05..216cf058 100644 --- a/built_in_tasks/rotation_matrices.py +++ b/built_in_tasks/rotation_matrices.py @@ -34,6 +34,12 @@ exp_rotations = dict( none = np.identity(4), + mirror_x = np.array( + [[-1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1]] + ), about_x_90 = np.array( [[1, 0, 0, 0], [0, 0, 1, 0], From 194090bde25517e7fb916c964facffc71de3e99f Mon Sep 17 00:00:00 2001 From: katherineperks Date: Thu, 22 Jan 2026 13:01:20 -0800 Subject: [PATCH 45/62] added 180 generator for alt task --- .../target_capture_multiple_choice.py | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index bfa374c5..c39fd72f 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -39,7 +39,7 @@ class TwoChoiceTargetCapture(ScreenTargetCapture): end_state=True), ) - sequence_generators = ['dual_centerout_2D', 'dual_peripheral'] + sequence_generators = ['dual_centerout_2D_mirror', 'dual_centerout_2D_180', 'dual_peripheral'] def __init__(self, *args, **kwargs): kwargs['instantiate_targets'] = False @@ -159,7 +159,7 @@ def dual_peripheral(nblocks=100, distance=10, origin=(0,0,0)): yield [0, 1, 2], targs @staticmethod - def dual_centerout_2D(nblocks=100, ntargets=8, distance=10, origin=(0,0,0)): + def dual_centerout_2D_mirror(nblocks=100, ntargets=8, distance=10, origin=(0,0,0)): ''' triplets of central targets at the origin and 2 peripheral targets centered around the origin @@ -181,6 +181,30 @@ def dual_centerout_2D(nblocks=100, ntargets=8, distance=10, origin=(0,0,0)): indices[1] = idx[0] indices[2] = 10 - idx[0] yield indices, targs + + @staticmethod + def dual_centerout_2D_180(nblocks=100, ntargets=8, distance=10, origin=(0,0,0)): + ''' + triplets of central targets at the origin and 2 peripheral targets centered around the origin + + Returns + ------- + [nblocks*ntargets x 1] array of tuples containing trial indices and [2 x 3] target coordinates + ''' + gen = ScreenTargetCapture.out_2D(nblocks, ntargets, distance, origin) + for _ in range(nblocks*ntargets): + + idx, pos = next(gen) + #while abs(pos[0][0]) < 0.1: + # idx, pos = next(gen) + + targs = np.zeros([3, 3]) + origin + targs[1,:] = pos[0] + targs[2,:] = pos[0]*[-1,1,-1] #flip the position + indices = np.zeros([3,1]) + indices[1] = idx[0] + indices[2] = (idx[0] + 4) % 8 + yield indices, targs def _test_enter_target(self, ts): ''' From 2aa4516692e50b0a0a056d85ca0ac643c0d6ae60 Mon Sep 17 00:00:00 2001 From: leo Date: Tue, 4 Nov 2025 15:35:29 -0800 Subject: [PATCH 46/62] system triggers but only works for 1x reward --- features/reward_features.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/features/reward_features.py b/features/reward_features.py index 009d85d9..06dac7e6 100644 --- a/features/reward_features.py +++ b/features/reward_features.py @@ -122,7 +122,12 @@ def _start_reward(self): self.reportstats['Reward #'] += 1 if self.reportstats['Reward #'] % self.trials_per_reward == 0: +<<<<<<< 8005149be4e11667fe4a76d17510adbb17bce7c1 self.reward.trigger(self.ip_address, self.pellet_dispense_time, self.pellets_per_reward) # triggers as many times as pellets_per_reward +======= + for _ in range(self.pellets_per_reward): # call trigger num of pellets_per_reward time + self.reward.trigger(self.ip_address) +>>>>>>> system triggers but only works for 1x reward def _end_reward(self): if hasattr(super(RewardSystem, self), '_end_reward'): From 0d27d89177d10a24b46cd4ebca278d91c0209e0b Mon Sep 17 00:00:00 2001 From: katherineperks Date: Mon, 12 Jan 2026 12:59:34 -0800 Subject: [PATCH 47/62] started new two chocie center out task --- built_in_tasks/target_capture_multiple_choice.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 built_in_tasks/target_capture_multiple_choice.py diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py new file mode 100644 index 00000000..e69de29b From 247353a106d689251d503900900709cbb48d3e2d Mon Sep 17 00:00:00 2001 From: katherineperks Date: Mon, 12 Jan 2026 14:14:46 -0800 Subject: [PATCH 48/62] outline new task --- .../target_capture_multiple_choice.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index e69de29b..87ec0678 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -0,0 +1,40 @@ +import numpy as np +import random +import os + +from .target_graphics import * +from .target_capture_task import ScreenTargetCapture +from riglib.experiment import traits + +class TwoChoiceTargetCapture(ScreenTargetCapture): + ''' + Add a penalty state when subjects looks away. + ''' + + status = dict( + wait = dict(start_trial="target"), + target = dict(timeout="timeout_penalty", + enter_target="hold"), + hold = dict(leave_target="target"), + delay = dict(leave_target="delay_penalty", + delay_complete="targ_transition"), + targ_transition = dict(trial_complete="reward", + trial_abort="wait", + trial_incomplete="target"), + timeout_penalty = dict(timeout_penalty_end="targ_transition", + end_state=True), + hold_penalty = dict(hold_penalty_end="targ_transition", + end_state=True), + delay_penalty = dict(delay_penalty_end="targ_transition", + end_state=True), + reward = dict(reward_end="wait", + stoppable=False, + end_state=True), + ) + + def _start_target(self): + super()._start_target() + + #show two targets at trial start + + \ No newline at end of file From 3c09ccf1a230e35e2018d11d6eb8afd45ee78e86 Mon Sep 17 00:00:00 2001 From: AJ Date: Mon, 12 Jan 2026 14:31:51 -0800 Subject: [PATCH 49/62] added task structure and variables --- .../target_capture_multiple_choice.py | 159 +++++++++++++++++- 1 file changed, 156 insertions(+), 3 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index 87ec0678..a1fbc0ee 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -15,7 +15,7 @@ class TwoChoiceTargetCapture(ScreenTargetCapture): wait = dict(start_trial="target"), target = dict(timeout="timeout_penalty", enter_target="hold"), - hold = dict(leave_target="target"), + hold = dict(leave_target="hold_penalty", hold_complete="delay"), delay = dict(leave_target="delay_penalty", delay_complete="targ_transition"), targ_transition = dict(trial_complete="reward", @@ -32,9 +32,162 @@ class TwoChoiceTargetCapture(ScreenTargetCapture): end_state=True), ) + sequence_generators = ['dual_peripheral'] + + def __init__(self, *args, **kwargs): + kwargs['instantiate_targets'] = False + super().__init__(*args, **kwargs) + + # Create three targets: center + two peripherals + target_center = VirtualCircularTarget(target_radius=self.target_radius, + target_color=target_colors[self.target_color]) + target_periph1 = VirtualCircularTarget(target_radius=self.target_radius, + target_color=target_colors[self.target_color]) + target_periph2 = VirtualCircularTarget(target_radius=self.target_radius, + target_color=target_colors[self.target_color]) + + self.targets = [target_center, target_periph1, target_periph2] + self.chosen_target = None + def _start_target(self): super()._start_target() + + if self.target_index == 0: + # Show only the center target initially + self.targets[0].move_to_position(self.targs[0]) + self.targets[0].show() + self.sync_event('TARGET_ON', 0) + + def _start_delay(self): + super()._start_delay() + + # After holding center, show BOTH peripheral targets + if self.target_index == 0: # Just finished holding center + self.targets[1].move_to_position(self.targs[1]) + self.targets[1].show() + + self.targets[2].move_to_position(self.targs[2]) + self.targets[2].show() + + self.sync_event('TARGET_ON', [1, 2]) + + def _start_hold(self): + super()._start_hold() + + if self.target_index == 0: + # Just entered center target + self.sync_event('CURSOR_ENTER_TARGET', 0) + else: + # Entered one of the peripheral targets - record which one + cursor_pos = self.plant.get_endpoint_pos() + d1 = np.linalg.norm(cursor_pos - self.targs[1]) + d2 = np.linalg.norm(cursor_pos - self.targs[2]) + + if d1 < d2: + self.chosen_target = 1 + self.targets[2].hide() # Hide unchosen target + else: + self.chosen_target = 2 + self.targets[1].hide() # Hide unchosen target + + self.sync_event('CURSOR_ENTER_TARGET', self.chosen_target) + + def _start_reward(self): + super()._start_reward() + # Hide all targets + for target in self.targets: + target.hide() + target.reset() + + @staticmethod + def dual_peripheral(nblocks=100, distance=10, origin=(0,0,0)): + ''' + Generates center target + two peripheral targets + ''' + rng = np.random.default_rng() + for _ in range(nblocks): + # Generate two random angles for peripheral targets + angles = rng.uniform(0, 2*np.pi, size=2) + + # Target 0: center + center = np.array(origin) + + # Target 1: first peripheral + pos1 = np.array([ + distance*np.cos(angles[0]), + 0, + distance*np.sin(angles[0]) + ]) + origin + + # Target 2: second peripheral + pos2 = np.array([ + distance*np.cos(angles[1]), + 0, + distance*np.sin(angles[1]) + ]) + origin + + # Yield indices and positions for all three targets + yield [0, 1, 2], [center, pos1, pos2] + + + def _test_enter_target(self, ts): + ''' + Check if cursor is in the appropriate target(s) + ''' + cursor_pos = self.plant.get_endpoint_pos() + + if self.target_index == 0: + # First target: check only center + d = np.linalg.norm(cursor_pos - self.targs[0]) + return d <= (self.target_radius - self.cursor_radius) + + else: + # After center hold: check if in EITHER peripheral target + d1 = np.linalg.norm(cursor_pos - self.targs[1]) + in_target1 = d1 <= (self.target_radius - self.cursor_radius) + + d2 = np.linalg.norm(cursor_pos - self.targs[2]) + in_target2 = d2 <= (self.target_radius - self.cursor_radius) + + return in_target1 or in_target2 + + def _test_leave_target(self, ts): + ''' + Check if cursor leaves current target + ''' + cursor_pos = self.plant.get_endpoint_pos() + + if self.target_index == 0: + # Leaving center target + d = np.linalg.norm(cursor_pos - self.targs[0]) + else: + # Leaving whichever peripheral was chosen + d = np.linalg.norm(cursor_pos - self.targs[self.chosen_target]) + + rad = self.target_radius - self.cursor_radius + return d > rad or super()._test_leave_target(ts) - #show two targets at trial start + def _test_hold_complete(self, time_in_state): + ''' + Hold complete after holding center OR after holding chosen peripheral + ''' + return time_in_state > self.hold_time + + def _test_trial_complete(self, time_in_state): + ''' + Trial complete after acquiring either peripheral target (index 1) + ''' + return self.target_index == 1 - \ No newline at end of file + def _test_leave_target(self, ts): + cursor_pos = self.plant.get_endpoint_pos() + + if self.target_index == 0: + d = np.linalg.norm(cursor_pos - self.targs[0]) + elif self.chosen_target is not None: # Add safety check + d = np.linalg.norm(cursor_pos - self.targs[self.chosen_target]) + else: + return False # No target chosen yet, can't have left it + + rad = self.target_radius - self.cursor_radius + return d > rad or super()._test_leave_target(ts) \ No newline at end of file From 02aeb9f8067d25da4650c19e56968c345f79dced Mon Sep 17 00:00:00 2001 From: AJ Date: Tue, 13 Jan 2026 12:44:32 -0800 Subject: [PATCH 50/62] some smalle updates --- .../target_capture_multiple_choice.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index a1fbc0ee..e4499370 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -58,19 +58,6 @@ def _start_target(self): self.targets[0].show() self.sync_event('TARGET_ON', 0) - def _start_delay(self): - super()._start_delay() - - # After holding center, show BOTH peripheral targets - if self.target_index == 0: # Just finished holding center - self.targets[1].move_to_position(self.targs[1]) - self.targets[1].show() - - self.targets[2].move_to_position(self.targs[2]) - self.targets[2].show() - - self.sync_event('TARGET_ON', [1, 2]) - def _start_hold(self): super()._start_hold() @@ -91,6 +78,19 @@ def _start_hold(self): self.targets[1].hide() # Hide unchosen target self.sync_event('CURSOR_ENTER_TARGET', self.chosen_target) + + def _start_delay(self): + super()._start_delay() + + # After holding center, show BOTH peripheral targets + if self.target_index == 0: # Just finished holding center + self.targets[1].move_to_position(self.targs[1]) + self.targets[1].show() + + self.targets[2].move_to_position(self.targs[2]) + self.targets[2].show() + + self.sync_event('TARGET_ON', [1, 2]) def _start_reward(self): super()._start_reward() From d219844e247aafef4749e3c91c5fde7d7a2f4302 Mon Sep 17 00:00:00 2001 From: AJ Date: Tue, 13 Jan 2026 13:07:32 -0800 Subject: [PATCH 51/62] updated the two choice task --- .../target_capture_multiple_choice.py | 24 ++++--------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index e4499370..0380adf8 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -21,11 +21,11 @@ class TwoChoiceTargetCapture(ScreenTargetCapture): targ_transition = dict(trial_complete="reward", trial_abort="wait", trial_incomplete="target"), - timeout_penalty = dict(timeout_penalty_end="targ_transition", + timeout_penalty = dict(timeout_penalty_end="wait", end_state=True), - hold_penalty = dict(hold_penalty_end="targ_transition", + hold_penalty = dict(hold_penalty_end="wait", end_state=True), - delay_penalty = dict(delay_penalty_end="targ_transition", + delay_penalty = dict(delay_penalty_end="wait", end_state=True), reward = dict(reward_end="wait", stoppable=False, @@ -58,6 +58,7 @@ def _start_target(self): self.targets[0].show() self.sync_event('TARGET_ON', 0) + def _start_hold(self): super()._start_hold() @@ -78,7 +79,7 @@ def _start_hold(self): self.targets[1].hide() # Hide unchosen target self.sync_event('CURSOR_ENTER_TARGET', self.chosen_target) - + def _start_delay(self): super()._start_delay() @@ -151,21 +152,6 @@ def _test_enter_target(self, ts): return in_target1 or in_target2 - def _test_leave_target(self, ts): - ''' - Check if cursor leaves current target - ''' - cursor_pos = self.plant.get_endpoint_pos() - - if self.target_index == 0: - # Leaving center target - d = np.linalg.norm(cursor_pos - self.targs[0]) - else: - # Leaving whichever peripheral was chosen - d = np.linalg.norm(cursor_pos - self.targs[self.chosen_target]) - - rad = self.target_radius - self.cursor_radius - return d > rad or super()._test_leave_target(ts) def _test_hold_complete(self, time_in_state): ''' From 840bcb1ef784d8bf0b75c926a824f9d00d293948 Mon Sep 17 00:00:00 2001 From: katherineperks Date: Wed, 14 Jan 2026 10:33:43 -0800 Subject: [PATCH 52/62] most recent updates to two choice --- built_in_tasks/manualcontrolmultitasks.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/built_in_tasks/manualcontrolmultitasks.py b/built_in_tasks/manualcontrolmultitasks.py index 73a0edd3..7cdf666a 100644 --- a/built_in_tasks/manualcontrolmultitasks.py +++ b/built_in_tasks/manualcontrolmultitasks.py @@ -14,6 +14,7 @@ EyeHandSequenceCapture, ScreenTargetCapture_Saccade, EyeHandCaptureBlock from .target_tracking_task import ScreenTargetTracking from .rotation_matrices import * +from .target_capture_multiple_choice import TwoChoiceTargetCapture class ManualControlMixin(traits.HasTraits): '''Target capture task where the subject operates a joystick @@ -248,4 +249,9 @@ class ReadySetGoTask(ManualControlMixin, ScreenTargetCapture_ReadySet): Center out task with ready set go auditory cues. Cues separated by 500 ms and participant is expected to move on final go cue. Additionally, participant must move out of center circle (mustmv_time) parameter or there will be an error. ''' - pass \ No newline at end of file + pass + +class TwoChoiceManualControl(ManualControlMixin, TwoChoiceTargetCapture): + ''' + Center out task with two peripheral targets + ''' \ No newline at end of file From 94a443997ee354ff78c84a966eab711b18e3cbbc Mon Sep 17 00:00:00 2001 From: katherineperks Date: Wed, 14 Jan 2026 10:46:29 -0800 Subject: [PATCH 53/62] small updates --- .../target_capture_multiple_choice.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index 0380adf8..2b909a61 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -7,8 +7,8 @@ from riglib.experiment import traits class TwoChoiceTargetCapture(ScreenTargetCapture): - ''' - Add a penalty state when subjects looks away. +''' ''' + #Add a penalty state when subjects looks away. ''' status = dict( @@ -103,7 +103,7 @@ def _start_reward(self): @staticmethod def dual_peripheral(nblocks=100, distance=10, origin=(0,0,0)): ''' - Generates center target + two peripheral targets + #Generates center target + two peripheral targets ''' rng = np.random.default_rng() for _ in range(nblocks): @@ -128,12 +128,14 @@ def dual_peripheral(nblocks=100, distance=10, origin=(0,0,0)): ]) + origin # Yield indices and positions for all three targets - yield [0, 1, 2], [center, pos1, pos2] - + yield [0, 1, 2], [cself.pts = (unit*[-30/1.36,self.radius,self.radius])+intial[0] + for i in range(1,len(intial)): + self.pts = np.vstack([self.pts, (unit*[(i-30)/3,self.radius,self.radius])+intial[i]])enter, pos1, pos2] +BMIControlMultiDirectionConstraint def _test_enter_target(self, ts): ''' - Check if cursor is in the appropriate target(s) + #Check if cursor is in the appropriate target(s) ''' cursor_pos = self.plant.get_endpoint_pos() @@ -155,13 +157,13 @@ def _test_enter_target(self, ts): def _test_hold_complete(self, time_in_state): ''' - Hold complete after holding center OR after holding chosen peripheral + #Hold complete after holding center OR after holding chosen peripheral ''' return time_in_state > self.hold_time def _test_trial_complete(self, time_in_state): ''' - Trial complete after acquiring either peripheral target (index 1) + #Trial complete after acquiring either peripheral target (index 1) ''' return self.target_index == 1 @@ -176,4 +178,4 @@ def _test_leave_target(self, ts): return False # No target chosen yet, can't have left it rad = self.target_radius - self.cursor_radius - return d > rad or super()._test_leave_target(ts) \ No newline at end of file + return d > rad or super()._test_leave_target(ts)''' \ No newline at end of file From a7dd5b9439c15795b1dad206968b0008eba7629c Mon Sep 17 00:00:00 2001 From: katherineperks Date: Wed, 14 Jan 2026 11:02:25 -0800 Subject: [PATCH 54/62] errors in the duaal peripheral generator --- built_in_tasks/target_capture_multiple_choice.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index 2b909a61..b40a635a 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -7,7 +7,7 @@ from riglib.experiment import traits class TwoChoiceTargetCapture(ScreenTargetCapture): -''' ''' + ''' #Add a penalty state when subjects looks away. ''' @@ -128,10 +128,9 @@ def dual_peripheral(nblocks=100, distance=10, origin=(0,0,0)): ]) + origin # Yield indices and positions for all three targets - yield [0, 1, 2], [cself.pts = (unit*[-30/1.36,self.radius,self.radius])+intial[0] + yield [0, 1, 2], [self.pts = (unit*[-30/1.36,self.radius,self.radius])+intial[0] for i in range(1,len(intial)): self.pts = np.vstack([self.pts, (unit*[(i-30)/3,self.radius,self.radius])+intial[i]])enter, pos1, pos2] -BMIControlMultiDirectionConstraint def _test_enter_target(self, ts): ''' @@ -178,4 +177,4 @@ def _test_leave_target(self, ts): return False # No target chosen yet, can't have left it rad = self.target_radius - self.cursor_radius - return d > rad or super()._test_leave_target(ts)''' \ No newline at end of file + return d > rad or super()._test_leave_target(ts) \ No newline at end of file From 1c2f721bda0fbfbb81e89f3a264c9ddb03de31d5 Mon Sep 17 00:00:00 2001 From: katherineperks Date: Thu, 15 Jan 2026 11:55:27 -0800 Subject: [PATCH 55/62] added initial colors, incorrect behavior on selection of second peripheral target --- .../target_capture_multiple_choice.py | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index b40a635a..cf26785f 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -11,6 +11,9 @@ class TwoChoiceTargetCapture(ScreenTargetCapture): #Add a penalty state when subjects looks away. ''' + periph_targ1_color = traits.OptionsList("red", *target_colors, desc="Color of peripheral target 1", bmi3d_input_options=list(target_colors.keys())) + periph_targ2_color = traits.OptionsList("blue", *target_colors, desc="Color of peripheral target 1", bmi3d_input_options=list(target_colors.keys())) + status = dict( wait = dict(start_trial="target"), target = dict(timeout="timeout_penalty", @@ -42,9 +45,9 @@ def __init__(self, *args, **kwargs): target_center = VirtualCircularTarget(target_radius=self.target_radius, target_color=target_colors[self.target_color]) target_periph1 = VirtualCircularTarget(target_radius=self.target_radius, - target_color=target_colors[self.target_color]) + target_color=target_colors["red"]) target_periph2 = VirtualCircularTarget(target_radius=self.target_radius, - target_color=target_colors[self.target_color]) + target_color=target_colors["blue"]) self.targets = [target_center, target_periph1, target_periph2] self.chosen_target = None @@ -91,7 +94,18 @@ def _start_delay(self): self.targets[2].move_to_position(self.targs[2]) self.targets[2].show() - self.sync_event('TARGET_ON', [1, 2]) + self.sync_event('TARGET_ON', 1) + self.sync_event('TARGET_ON', 2) + + def _start_targ_transition(self): + super()._start_targ_transition() + if self.target_index == -1: + + # Came from a penalty state + pass + elif self.target_index == 0: + self.targets[0].hide() + self.sync_event('TARGET_OFF', self.gen_indices[self.target_index]) def _start_reward(self): super()._start_reward() @@ -126,12 +140,11 @@ def dual_peripheral(nblocks=100, distance=10, origin=(0,0,0)): 0, distance*np.sin(angles[1]) ]) + origin - + targs = np.array([center, pos1, pos2]) # Yield indices and positions for all three targets - yield [0, 1, 2], [self.pts = (unit*[-30/1.36,self.radius,self.radius])+intial[0] - for i in range(1,len(intial)): - self.pts = np.vstack([self.pts, (unit*[(i-30)/3,self.radius,self.radius])+intial[i]])enter, pos1, pos2] - + yield [0, 1, 2], targs + + def _test_enter_target(self, ts): ''' #Check if cursor is in the appropriate target(s) @@ -164,7 +177,7 @@ def _test_trial_complete(self, time_in_state): ''' #Trial complete after acquiring either peripheral target (index 1) ''' - return self.target_index == 1 + return self.target_index > 0 def _test_leave_target(self, ts): cursor_pos = self.plant.get_endpoint_pos() From 23f7f3fb05f5098ccd2192c49c74cc0b9243900a Mon Sep 17 00:00:00 2001 From: katherineperks Date: Thu, 15 Jan 2026 12:32:17 -0800 Subject: [PATCH 56/62] Can now enter the second target without issue and get rewarded for both targets. Something is weird happening with the red target though during collection --- built_in_tasks/target_capture_multiple_choice.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index cf26785f..e60a9503 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -85,7 +85,6 @@ def _start_hold(self): def _start_delay(self): super()._start_delay() - # After holding center, show BOTH peripheral targets if self.target_index == 0: # Just finished holding center self.targets[1].move_to_position(self.targs[1]) @@ -190,4 +189,4 @@ def _test_leave_target(self, ts): return False # No target chosen yet, can't have left it rad = self.target_radius - self.cursor_radius - return d > rad or super()._test_leave_target(ts) \ No newline at end of file + return d > rad #or super()._test_leave_target(ts) \ No newline at end of file From 8f3e73f51e0805c758187c2f5e05b7827d641bda Mon Sep 17 00:00:00 2001 From: katherineperks Date: Thu, 15 Jan 2026 13:11:33 -0800 Subject: [PATCH 57/62] Fixed thetarget behavior, now working on the correct sequence generator --- .../target_capture_multiple_choice.py | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index e60a9503..0c32379f 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -84,7 +84,7 @@ def _start_hold(self): self.sync_event('CURSOR_ENTER_TARGET', self.chosen_target) def _start_delay(self): - super()._start_delay() + #super()._start_delay() # After holding center, show BOTH peripheral targets if self.target_index == 0: # Just finished holding center self.targets[1].move_to_position(self.targs[1]) @@ -143,6 +143,29 @@ def dual_peripheral(nblocks=100, distance=10, origin=(0,0,0)): # Yield indices and positions for all three targets yield [0, 1, 2], targs + @staticmethod + def dual_centerout_2D(nblocks=100, ntargets=8, distance=10, origin=(0,0,0)): + ''' + triplets of central targets at the origin and 2 peripheral targets centered around the origin + + Returns + ------- + [nblocks*ntargets x 1] array of tuples containing trial indices and [2 x 3] target coordinates + ''' + gen = ScreenTargetCapture.out_2D(nblocks, ntargets, distance, origin) + for _ in range(nblocks*ntargets): + + idx, pos = next(gen) + while pos[0][-1]==0.0 + idx, pos = next(gen) + + targs = np.zeros([3, 3]) + origin + targs[1,:] = pos[0] + targs[2,:] = pos[0]*[-1,1,1] #flip the position + indices = np.zeros([3,1]) + indices[1] = 1 + indices[2] = 2 + yield indices, targs def _test_enter_target(self, ts): ''' From 38fb7cdb0059ad6049a3bf350beb5c5edd63b797 Mon Sep 17 00:00:00 2001 From: katherineperks Date: Thu, 15 Jan 2026 13:24:19 -0800 Subject: [PATCH 58/62] Fixed thetarget behavior, now working on the correct sequence generator --- built_in_tasks/target_capture_multiple_choice.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index 0c32379f..1d359db7 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -35,7 +35,7 @@ class TwoChoiceTargetCapture(ScreenTargetCapture): end_state=True), ) - sequence_generators = ['dual_peripheral'] + sequence_generators = ['dual_centerout_2D', 'dual_peripheral'] def __init__(self, *args, **kwargs): kwargs['instantiate_targets'] = False @@ -93,7 +93,7 @@ def _start_delay(self): self.targets[2].move_to_position(self.targs[2]) self.targets[2].show() - self.sync_event('TARGET_ON', 1) + self.sync_event('TARGET_ON', 1)#dual_centerout_2D self.sync_event('TARGET_ON', 2) def _start_targ_transition(self): @@ -156,7 +156,7 @@ def dual_centerout_2D(nblocks=100, ntargets=8, distance=10, origin=(0,0,0)): for _ in range(nblocks*ntargets): idx, pos = next(gen) - while pos[0][-1]==0.0 + while pos[0][-1] == 0: idx, pos = next(gen) targs = np.zeros([3, 3]) + origin From dcb98852ed86c36b460a6cee9ca06bcecaa5a7b5 Mon Sep 17 00:00:00 2001 From: katherineperks Date: Thu, 15 Jan 2026 14:54:13 -0800 Subject: [PATCH 59/62] more updaes generator --- .../target_capture_multiple_choice.py | 41 +++++++++++++------ built_in_tasks/target_capture_task.py | 4 +- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index 1d359db7..f08e56c4 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -13,6 +13,10 @@ class TwoChoiceTargetCapture(ScreenTargetCapture): periph_targ1_color = traits.OptionsList("red", *target_colors, desc="Color of peripheral target 1", bmi3d_input_options=list(target_colors.keys())) periph_targ2_color = traits.OptionsList("blue", *target_colors, desc="Color of peripheral target 1", bmi3d_input_options=list(target_colors.keys())) + #hide( + #reward_time = traits.Float(.5, desc="Length of reward dispensation") + reward_multiplier = traits.Float(2.0, desc="Select the reward differential between high and low reward") + #pulses_per_total_reward = traits.Int(5, desc='the nubmer of iterations for reward pulse') status = dict( wait = dict(start_trial="target"), @@ -30,7 +34,7 @@ class TwoChoiceTargetCapture(ScreenTargetCapture): end_state=True), delay_penalty = dict(delay_penalty_end="wait", end_state=True), - reward = dict(reward_end="wait", + reward = dict(reward_end = "wait", stoppable=False, end_state=True), ) @@ -51,6 +55,13 @@ def __init__(self, *args, **kwargs): self.targets = [target_center, target_periph1, target_periph2] self.chosen_target = None + self.base_reward_time = self.reward_time + + if hasattr(self, "pulses_per_total_reward"): + self.base_pulses_per_total_reward = self.pulses_per_total_reward + else: + self.base_pulses_per_total_reward = 0 + def _start_target(self): super()._start_target() @@ -76,11 +87,17 @@ def _start_hold(self): if d1 < d2: self.chosen_target = 1 + self.reward_time = self.base_reward_time * self.reward_multiplier + self.pulses_per_total_reward = np.ceil(self.reward_multiplier)*self.base_pulses_per_total_reward + self.targets[2].hide() # Hide unchosen target else: self.chosen_target = 2 + self.reward_time = self.base_reward_time self.targets[1].hide() # Hide unchosen target - + self.pulses_per_total_reward = self.base_pulses_per_total_reward + + self.sync_event('CURSOR_ENTER_TARGET', self.chosen_target) def _start_delay(self): @@ -93,8 +110,8 @@ def _start_delay(self): self.targets[2].move_to_position(self.targs[2]) self.targets[2].show() - self.sync_event('TARGET_ON', 1)#dual_centerout_2D - self.sync_event('TARGET_ON', 2) + self.sync_event('TARGET_ON', 1)#Convert this index to position index + #self.sync_event('TARGET_ON', 2) def _start_targ_transition(self): super()._start_targ_transition() @@ -108,10 +125,8 @@ def _start_targ_transition(self): def _start_reward(self): super()._start_reward() - # Hide all targets - for target in self.targets: - target.hide() - target.reset() + self.targets[self.chosen_target].cue_trial_end_success() + self.sync_event('REWARD') @staticmethod def dual_peripheral(nblocks=100, distance=10, origin=(0,0,0)): @@ -156,15 +171,15 @@ def dual_centerout_2D(nblocks=100, ntargets=8, distance=10, origin=(0,0,0)): for _ in range(nblocks*ntargets): idx, pos = next(gen) - while pos[0][-1] == 0: + while abs(pos[0][0]) < 0.1: idx, pos = next(gen) targs = np.zeros([3, 3]) + origin targs[1,:] = pos[0] targs[2,:] = pos[0]*[-1,1,1] #flip the position indices = np.zeros([3,1]) - indices[1] = 1 - indices[2] = 2 + indices[1] = idx[0] + indices[2] = 10 - idx[0] yield indices, targs def _test_enter_target(self, ts): @@ -212,4 +227,6 @@ def _test_leave_target(self, ts): return False # No target chosen yet, can't have left it rad = self.target_radius - self.cursor_radius - return d > rad #or super()._test_leave_target(ts) \ No newline at end of file + return d > rad #or super()._test_leave_target(ts) + + #def \ No newline at end of file diff --git a/built_in_tasks/target_capture_task.py b/built_in_tasks/target_capture_task.py index af19a1a5..2625a735 100644 --- a/built_in_tasks/target_capture_task.py +++ b/built_in_tasks/target_capture_task.py @@ -55,7 +55,7 @@ class TargetCapture(Sequence): delay_time = traits.Float(0, desc="Length of time after a hold while the next target is on before the go cue") delay_penalty_time = traits.Float(1, desc="Length of penalty time for delay error") timeout_time = traits.Float(10, desc="Time allowed to go between targets") - timeout_penalty_time = traits.Float(1, desc="Length of penalty time for timeout error") + timeout_penalty_ScreenTargetCapturetime = traits.Float(1, desc="Length of penalty time for timeout error") max_attempts = traits.Int(10, desc='The number of attempts of a target chain before skipping to the next one') num_targets_per_attempt = traits.Int(2, desc="Minimum number of target acquisitions to be counted as an attempt") @@ -797,7 +797,7 @@ def rand_target_chain_3D(ntrials=100, chain_length=1, boundaries=(-12,12,-10,10, rng = np.random.default_rng() idx = 0 for t in range(ntrials): - + self.sync_event('TARGET_ON', 1) # Choose a random sequence of points within the boundaries pts = rng.uniform(size=(chain_length, 3))*((boundaries[1]-boundaries[0]), (boundaries[3]-boundaries[2]), (boundaries[5]-boundaries[4])) From 35e999c356d6c0b9faf91aa75583544a0efd696f Mon Sep 17 00:00:00 2001 From: katherineperks Date: Thu, 15 Jan 2026 15:00:54 -0800 Subject: [PATCH 60/62] need to fix the sync events --- built_in_tasks/target_capture_multiple_choice.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index f08e56c4..bfa374c5 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -88,14 +88,14 @@ def _start_hold(self): if d1 < d2: self.chosen_target = 1 self.reward_time = self.base_reward_time * self.reward_multiplier - self.pulses_per_total_reward = np.ceil(self.reward_multiplier)*self.base_pulses_per_total_reward + self.pulses_per_total_reward = int(np.ceil(self.reward_multiplier)*self.base_pulses_per_total_reward) self.targets[2].hide() # Hide unchosen target else: self.chosen_target = 2 self.reward_time = self.base_reward_time self.targets[1].hide() # Hide unchosen target - self.pulses_per_total_reward = self.base_pulses_per_total_reward + self.pulses_per_total_reward = int(self.base_pulses_per_total_reward) self.sync_event('CURSOR_ENTER_TARGET', self.chosen_target) @@ -114,7 +114,7 @@ def _start_delay(self): #self.sync_event('TARGET_ON', 2) def _start_targ_transition(self): - super()._start_targ_transition() + #super()._start_targ_transition() if self.target_index == -1: # Came from a penalty state From 5a556b2e82c5b51f5998392cd3e007b8151615e8 Mon Sep 17 00:00:00 2001 From: katherineperks Date: Thu, 22 Jan 2026 10:32:12 -0800 Subject: [PATCH 61/62] added mirror_x parameter --- built_in_tasks/rotation_matrices.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/built_in_tasks/rotation_matrices.py b/built_in_tasks/rotation_matrices.py index 0fb31e05..216cf058 100644 --- a/built_in_tasks/rotation_matrices.py +++ b/built_in_tasks/rotation_matrices.py @@ -34,6 +34,12 @@ exp_rotations = dict( none = np.identity(4), + mirror_x = np.array( + [[-1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1]] + ), about_x_90 = np.array( [[1, 0, 0, 0], [0, 0, 1, 0], From ba33feb3452adedc01909288b6cda91fbb70ae5c Mon Sep 17 00:00:00 2001 From: katherineperks Date: Thu, 22 Jan 2026 13:01:20 -0800 Subject: [PATCH 62/62] added 180 generator for alt task --- .../target_capture_multiple_choice.py | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/built_in_tasks/target_capture_multiple_choice.py b/built_in_tasks/target_capture_multiple_choice.py index bfa374c5..c39fd72f 100644 --- a/built_in_tasks/target_capture_multiple_choice.py +++ b/built_in_tasks/target_capture_multiple_choice.py @@ -39,7 +39,7 @@ class TwoChoiceTargetCapture(ScreenTargetCapture): end_state=True), ) - sequence_generators = ['dual_centerout_2D', 'dual_peripheral'] + sequence_generators = ['dual_centerout_2D_mirror', 'dual_centerout_2D_180', 'dual_peripheral'] def __init__(self, *args, **kwargs): kwargs['instantiate_targets'] = False @@ -159,7 +159,7 @@ def dual_peripheral(nblocks=100, distance=10, origin=(0,0,0)): yield [0, 1, 2], targs @staticmethod - def dual_centerout_2D(nblocks=100, ntargets=8, distance=10, origin=(0,0,0)): + def dual_centerout_2D_mirror(nblocks=100, ntargets=8, distance=10, origin=(0,0,0)): ''' triplets of central targets at the origin and 2 peripheral targets centered around the origin @@ -181,6 +181,30 @@ def dual_centerout_2D(nblocks=100, ntargets=8, distance=10, origin=(0,0,0)): indices[1] = idx[0] indices[2] = 10 - idx[0] yield indices, targs + + @staticmethod + def dual_centerout_2D_180(nblocks=100, ntargets=8, distance=10, origin=(0,0,0)): + ''' + triplets of central targets at the origin and 2 peripheral targets centered around the origin + + Returns + ------- + [nblocks*ntargets x 1] array of tuples containing trial indices and [2 x 3] target coordinates + ''' + gen = ScreenTargetCapture.out_2D(nblocks, ntargets, distance, origin) + for _ in range(nblocks*ntargets): + + idx, pos = next(gen) + #while abs(pos[0][0]) < 0.1: + # idx, pos = next(gen) + + targs = np.zeros([3, 3]) + origin + targs[1,:] = pos[0] + targs[2,:] = pos[0]*[-1,1,-1] #flip the position + indices = np.zeros([3,1]) + indices[1] = idx[0] + indices[2] = (idx[0] + 4) % 8 + yield indices, targs def _test_enter_target(self, ts): '''