88obj.run()
99"""
1010
11- from abc import abstractmethod
11+ from abc import abstractmethod , ABC
1212from typing import Callable
13+ from eegnb .devices .eeg import EEG
14+ from psychopy import prefs
1315from psychopy .visual .rift import Rift
1416
1517from time import time
2224from eegnb import generate_save_fn
2325
2426
25- class BaseExperiment :
27+ class BaseExperiment ( ABC ) :
2628
2729 def __init__ (self , exp_name , duration , eeg , save_fn , n_trials : int , iti : float , soa : float , jitter : float ,
28- use_vr = False , use_fullscr = True , screen_num = 0 ):
30+ use_vr = False , use_fullscr = True , screen_num = 0 , stereoscopic = False ):
2931 """ Initializer for the Base Experiment Class
3032
3133 Args:
34+ exp_name (str): Name of the experiment
35+ duration (float): Duration of the experiment in seconds
36+ eeg: EEG device object for recording
37+ save_fn (str): Save filename function for data
3238 n_trials (int): Number of trials/stimulus
3339 iti (float): Inter-trial interval
3440 soa (float): Stimulus on arrival
3541 jitter (float): Random delay between stimulus
3642 use_vr (bool): Use VR for displaying stimulus
43+ use_fullscr (bool): Use fullscreen mode
3744 screen_num (int): Screen number (if multiple monitors present)
45+ stereoscopic (bool): Use stereoscopic rendering for VR
3846 """
3947
4048 self .exp_name = exp_name
4149 self .instruction_text = """\n Welcome to the {} experiment!\n Stay still, focus on the centre of the screen, and try not to blink. \n This block will run for %s seconds.\n
4250 Press spacebar to continue. \n """ .format (self .exp_name )
4351 self .duration = duration
44- self .eeg = eeg
52+ self .eeg : EEG = eeg
4553 self .save_fn = save_fn
4654 self .n_trials = n_trials
4755 self .iti = iti
4856 self .soa = soa
4957 self .jitter = jitter
5058 self .use_vr = use_vr
5159 self .screen_num = screen_num
60+ self .stereoscopic = stereoscopic
5261 if use_vr :
5362 # VR interface accessible by specific experiment classes for customizing and using controllers.
54- self .rift : Rift = visual .Rift (monoscopic = True , headLocked = True )
63+ self .rift : Rift = visual .Rift (monoscopic = not stereoscopic , headLocked = True )
64+ # eye for presentation
65+ if stereoscopic :
66+ self .left_eye_x_pos = 0.2
67+ self .right_eye_x_pos = - 0.2
68+ else :
69+ self .left_eye_x_pos = 0
70+ self .right_eye_x_pos = 0
71+
5572 self .use_fullscr = use_fullscr
5673 self .window_size = [1600 ,800 ]
5774
75+ # Initializing the record duration and the marker names
76+ self .record_duration = np .float32 (self .duration )
77+ self .markernames = [1 , 2 ]
78+
79+ # Setting up the trial and parameter list
80+ self .parameter = np .random .binomial (1 , 0.5 , self .n_trials )
81+ self .trials = DataFrame (dict (parameter = self .parameter , timestamp = np .zeros (self .n_trials )))
82+
5883 @abstractmethod
5984 def load_stimulus (self ):
6085 """
@@ -76,17 +101,20 @@ def present_stimulus(self, idx : int):
76101 """
77102 raise NotImplementedError
78103
79- def setup (self , instructions = True ):
104+ def present_iti (self ):
105+ """
106+ Method that presents the inter-trial interval display for the specific experiment.
80107
81- # Initializing the record duration and the marker names
82- self . record_duration = np . float32 ( self . duration )
83- self . markernames = [ 1 , 2 ]
84-
85- # Setting up the trial and parameter list
86- self . parameter = np . random . binomial ( 1 , 0.5 , self . n_trials )
87- self .trials = DataFrame ( dict ( parameter = self . parameter , timestamp = np . zeros ( self . n_trials )) )
108+ This method defines what is shown on the screen during the period between stimuli.
109+ It could be a blank screen, a fixation cross, or any other appropriate display.
110+
111+ This is an optional method - the default implementation simply flips the window with no additional content.
112+ Subclasses can override this method to provide custom ITI graphics.
113+ """
114+ self .window . flip ( )
88115
89- # Setting up Graphics
116+ def setup (self , instructions = True ):
117+ # Setting up Graphics
90118 self .window = (
91119 self .rift if self .use_vr
92120 else visual .Window (self .window_size , monitor = "testMonitor" , units = "deg" ,
@@ -97,7 +125,8 @@ def setup(self, instructions=True):
97125
98126 # Show Instruction Screen if not skipped by the user
99127 if instructions :
100- self .show_instructions ()
128+ if not self .show_instructions ():
129+ return False
101130
102131 # Checking for EEG to setup the EEG stream
103132 if self .eeg :
@@ -112,7 +141,8 @@ def setup(self, instructions=True):
112141 print (
113142 f"No path for a save file was passed to the experiment. Saving data to { self .save_fn } "
114143 )
115-
144+ return True
145+
116146 def show_instructions (self ):
117147 """
118148 Method that shows the instructions for the specific Experiment
@@ -127,18 +157,22 @@ def show_instructions(self):
127157 self .window .mouseVisible = False
128158
129159 # clear/reset any old key/controller events
130- self .__clear_user_input ()
160+ self ._clear_user_input ()
131161
132162 # Waiting for the user to press the spacebar or controller button or trigger to start the experiment
133- while not self .__user_input ('start' ):
163+ while not self ._user_input ('start' ):
134164 # Displaying the instructions on the screen
135165 text = visual .TextStim (win = self .window , text = self .instruction_text , color = [- 1 , - 1 , - 1 ])
136- self .__draw (lambda : self .__draw_instructions (text ))
166+ self ._draw (lambda : self .__draw_instructions (text ))
137167
138168 # Enabling the cursor again
139169 self .window .mouseVisible = True
140170
141- def __user_input (self , input_type ):
171+ if self ._user_input ('cancel' ):
172+ return False
173+ return True
174+
175+ def _user_input (self , input_type ):
142176 if input_type == 'start' :
143177 key_input = 'spacebar'
144178 vr_inputs = [
@@ -155,6 +189,9 @@ def __user_input(self, input_type):
155189 ('Xbox' , 'B' , None )
156190 ]
157191
192+ else :
193+ raise Exception (f'Invalid input_type: { input_type } ' )
194+
158195 if len (event .getKeys (keyList = key_input )) > 0 :
159196 return True
160197
@@ -192,10 +229,16 @@ def get_vr_input(self, vr_controller, button=None, trigger=False):
192229 return False
193230
194231 def __draw_instructions (self , text ):
195- text .draw ()
232+ if self .use_vr and self .stereoscopic :
233+ for eye , x_pos in [("left" , self .left_eye_x_pos ), ("right" , self .right_eye_x_pos )]:
234+ self .window .setBuffer (eye )
235+ text .pos = (x_pos , 0 )
236+ text .draw ()
237+ else :
238+ text .draw ()
196239 self .window .flip ()
197240
198- def __draw (self , present_stimulus : Callable ):
241+ def _draw (self , present_stimulus : Callable ):
199242 """
200243 Set the current eye position and projection for all given stimulus,
201244 then draw all stimulus and flip the window/buffer
@@ -206,7 +249,7 @@ def __draw(self, present_stimulus: Callable):
206249 self .window .setDefaultView ()
207250 present_stimulus ()
208251
209- def __clear_user_input (self ):
252+ def _clear_user_input (self ):
210253 event .getKeys ()
211254 self .clear_vr_input ()
212255
@@ -216,14 +259,61 @@ def clear_vr_input(self):
216259 """
217260 if self .use_vr :
218261 self .rift .updateInputState ()
262+
263+ def _run_trial_loop (self , start_time , duration ):
264+ """
265+ Run the trial presentation loop
266+
267+ This method handles the common trial presentation logic.
268+
269+ Args:
270+ start_time (float): Time when the trial loop started
271+ duration (float): Maximum duration of the trial loop in seconds
219272
220- def run (self , instructions = True ):
221- """ Do the present operation for a bunch of experiments """
273+ """
222274
223275 def iti_with_jitter ():
224276 return self .iti + np .random .rand () * self .jitter
225277
226- # Setup the experiment, alternatively could get rid of this line, something to think about
278+ # Initialize trial variables
279+ current_trial = trial_end_time = - 1
280+ trial_start_time = None
281+ rendering_trial = - 1
282+
283+ # Clear/reset user input buffer
284+ self ._clear_user_input ()
285+
286+ # Run the trial loop
287+ while (time () - start_time ) < duration :
288+ elapsed_time = time () - start_time
289+
290+ # Do not present stimulus until current trial begins(Adhere to inter-trial interval).
291+ if elapsed_time > trial_end_time :
292+ current_trial += 1
293+
294+ # Calculate timing for this trial
295+ trial_start_time = elapsed_time + iti_with_jitter ()
296+ trial_end_time = trial_start_time + self .soa
297+
298+ # Do not present stimulus after trial has ended(stimulus on arrival interval).
299+ if elapsed_time >= trial_start_time :
300+ # if current trial number changed present new stimulus.
301+ if current_trial > rendering_trial :
302+ # Stimulus presentation overwritten by specific experiment
303+ self ._draw (lambda : self .present_stimulus (current_trial ))
304+ rendering_trial = current_trial
305+ else :
306+ self ._draw (lambda : self .present_iti ())
307+
308+ if self ._user_input ('cancel' ):
309+ return False
310+
311+ return True
312+
313+ def run (self , instructions = True ):
314+ """ Run the experiment """
315+
316+ # Setup the experiment
227317 self .setup (instructions )
228318
229319 print ("Wait for the EEG-stream to start..." )
@@ -234,37 +324,11 @@ def iti_with_jitter():
234324
235325 print ("EEG Stream started" )
236326
237- # Run trial until a key is pressed or experiment duration has expired.
238- start = time ()
239- current_trial = current_trial_end = - 1
240- current_trial_begin = None
241-
242- # Current trial being rendered
243- rendering_trial = - 1
244-
245- # Clear/reset user input buffer
246- self .__clear_user_input ()
247-
248- while not self .__user_input ('cancel' ) and (time () - start ) < self .record_duration :
249-
250- current_experiment_seconds = time () - start
251- # Do not present stimulus until current trial begins(Adhere to inter-trial interval).
252- if current_trial_end < current_experiment_seconds :
253- current_trial += 1
254- current_trial_begin = current_experiment_seconds + iti_with_jitter ()
255- current_trial_end = current_trial_begin + self .soa
256-
257- # Do not present stimulus after trial has ended(stimulus on arrival interval).
258- elif current_trial_begin < current_experiment_seconds :
259-
260- # if current trial number changed get new choice of image.
261- if rendering_trial < current_trial :
262- # Some form of presenting the stimulus - sometimes order changed in lower files like ssvep
263- # Stimulus presentation overwritten by specific experiment
264- self .__draw (lambda : self .present_stimulus (current_trial ))
265- rendering_trial = current_trial
266- else :
267- self .__draw (lambda : self .window .flip ())
327+ # Record experiment until a key is pressed or duration has expired.
328+ record_start_time = time ()
329+
330+ # Run the trial loop
331+ self ._run_trial_loop (record_start_time , self .record_duration )
268332
269333 # Clearing the screen for the next trial
270334 event .clearEvents ()
0 commit comments