From 3ca3821a43f07d958b6aca51f0003eb25cf2e2e2 Mon Sep 17 00:00:00 2001 From: Emmanouil Froudarakis Date: Mon, 3 Feb 2025 17:50:37 +0200 Subject: [PATCH 01/13] Wheel function update fix --- Interfaces/Wheel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Interfaces/Wheel.py b/Interfaces/Wheel.py index 6918f287..234d75cf 100644 --- a/Interfaces/Wheel.py +++ b/Interfaces/Wheel.py @@ -26,7 +26,7 @@ def __init__(self, **kwargs): sleep(1) self.msg_queue.put(Message(type='offset', value=self.logger.logger_timer.elapsed_time())) - def cleanup(self): + def release(self): self.thread_end.set() self.ser.close() # Close the Serial connection From a23a7e556dfd7afa7a85022b117bc07dbe3baf01 Mon Sep 17 00:00:00 2001 From: Emmanouil Froudarakis Date: Wed, 12 Feb 2025 17:56:00 +0200 Subject: [PATCH 02/13] Wheel + frame sync --- Interfaces/Wheel.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Interfaces/Wheel.py b/Interfaces/Wheel.py index 234d75cf..3697726c 100644 --- a/Interfaces/Wheel.py +++ b/Interfaces/Wheel.py @@ -16,9 +16,12 @@ def __init__(self, **kwargs): self.offset = None self.no_response = False self.timeout_timer = time.time() - self.dataset = self.logger.createDataset(dataset_name='wheel', + self.wheel_dataset = self.logger.createDataset(dataset_name='wheel', dataset_type=np.dtype([("position", np.double), ("tmst", np.double)])) + self.frame_dataset = self.logger.createDataset(dataset_name='frames', + dataset_type=np.dtype([("idx", np.double), + ("tmst", np.double)])) self.ser = Serial(self.port, baudrate=self.baud) sleep(1) self.thread_runner = threading.Thread(target=self._communicator) @@ -38,8 +41,9 @@ def _communicator(self): msg = self._read_msg() # Read the response if msg is not None: if msg['type'] == 'Position' and self.offset is not None: - # print(msg['value'], msg['tmst']) - self.dataset.append('wheel', [msg['value'], msg['tmst']]) + self.wheel_dataset.append('wheel', [msg['value'], msg['tmst']]) + elif msg['type'] == 'Frame' and self.offset is not None: + self.frame_dataset.append('frames', [msg['value'], msg['tmst']]) elif msg['type'] == 'Offset': self.offset = msg['value'] From 2f5796026678f3d6af742b696ee65b06c0631e8f Mon Sep 17 00:00:00 2001 From: Emmanouil Froudarakis Date: Thu, 13 Feb 2025 09:25:46 +0200 Subject: [PATCH 03/13] Logger timestamp into logger_tmst field in session --- core/Logger.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/core/Logger.py b/core/Logger.py index 360741ab..b769c4ca 100755 --- a/core/Logger.py +++ b/core/Logger.py @@ -591,6 +591,7 @@ def log_session(self, params: Dict[str, Any], log_protocol: bool = False) -> Non log_protocol (bool): Whether to log the protocol information. """ # Initializes session parameters and logs the session start. + self.logger_timer.start() # Start session time self._init_session_params(params) # Save the protocol file, name and the git_hash in the database. @@ -603,7 +604,7 @@ def log_session(self, params: Dict[str, Any], log_protocol: bool = False) -> Non # Init the informations(e.g. trial_id=0, session) in control table self._init_control_table(params) - self.logger_timer.start() # Start session time + def _init_session_params(self, params: Dict[str, Any]) -> None: """ @@ -629,7 +630,8 @@ def _init_session_params(self, params: Dict[str, Any]) -> None: # Creates a session key by merging trial key with session parameters. # TODO: Read the user name from the Control Table session_key = {**self.trial_key, **params, "setup": self.setup, - "user_name": params.get("user_name", "bot")} + "user_name": params.get("user_name", "bot"), + "logger_tmst": self.logger_timer.start_time} logging.info("session_key:\n%s", pprint.pformat(session_key)) # Logs the new session id to the database self.put(table="Session", tuple=session_key, priority=1, validate=True, block=True) From 867b4cffdad77ca202a5385d89f9800e655c0051 Mon Sep 17 00:00:00 2001 From: Emmanouil Froudarakis Date: Thu, 13 Feb 2025 12:36:18 +0200 Subject: [PATCH 04/13] Wheel + frame sync --- Experiments/Passive.py | 5 ++++- Interfaces/Wheel.py | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Experiments/Passive.py b/Experiments/Passive.py index abcc654d..e0888fd4 100644 --- a/Experiments/Passive.py +++ b/Experiments/Passive.py @@ -24,7 +24,10 @@ def entry(self): # updates stateMachine from Database entry - override for timi class Entry(Experiment): def next(self): - return 'PreTrial' + if self.logger.setup_status in ['operational']: + return 'PreTrial' + else: + return 'Entry' class PreTrial(Experiment): diff --git a/Interfaces/Wheel.py b/Interfaces/Wheel.py index 3697726c..683dbdef 100644 --- a/Interfaces/Wheel.py +++ b/Interfaces/Wheel.py @@ -57,10 +57,13 @@ def _read_msg(self): elif self.ser.in_waiting == 0: # Nothing received self.no_response = True return None - incoming = self.ser.readline().decode("utf-8") - resp = None self.no_response = False self.timeout_timer = time.time() + try: + incoming = self.ser.readline().decode("utf-8") + except: + print('error reading serial line!') + return None try: resp = json.loads(incoming) except json.JSONDecodeError: From eeddf6bef8a4147003be5d2ba07874b7813b21ad Mon Sep 17 00:00:00 2001 From: Emmanouil Froudarakis Date: Fri, 14 Feb 2025 09:31:46 +0200 Subject: [PATCH 05/13] Wheel decoding fix --- Interfaces/Wheel.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/Interfaces/Wheel.py b/Interfaces/Wheel.py index 683dbdef..976f805a 100644 --- a/Interfaces/Wheel.py +++ b/Interfaces/Wheel.py @@ -50,25 +50,20 @@ def _communicator(self): def _read_msg(self): """Reads a line from the serial buffer, decodes it and returns its contents as a dict.""" - now = time.time() - if (now - self.timeout_timer) > 3: - self.timeout_timer = time.time() + if self.ser.in_waiting == 0: # don't run faster than necessary return None - elif self.ser.in_waiting == 0: # Nothing received - self.no_response = True - return None - self.no_response = False - self.timeout_timer = time.time() - try: + + try: # read message incoming = self.ser.readline().decode("utf-8") - except: - print('error reading serial line!') + except: # partial read of message, retry return None - try: - resp = json.loads(incoming) + + try: # decode message + response = json.loads(incoming) + return response except json.JSONDecodeError: print("Error decoding JSON message!") - return resp + return None def _write_msg(self, message=None): """Sends a JSON-formatted command to the serial interface.""" From cafa9125bfca3c1cf59a53a7b54304f90da93f78 Mon Sep 17 00:00:00 2001 From: Emmanouil Froudarakis Date: Thu, 20 Feb 2025 09:26:03 +0200 Subject: [PATCH 06/13] exit from entry state --- Experiments/Passive.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Experiments/Passive.py b/Experiments/Passive.py index e0888fd4..d67add95 100644 --- a/Experiments/Passive.py +++ b/Experiments/Passive.py @@ -26,6 +26,8 @@ class Entry(Experiment): def next(self): if self.logger.setup_status in ['operational']: return 'PreTrial' + elif self.is_stopped(): # if run out of conditions exit + return 'Exit' else: return 'Entry' From c4f22030655c222f4f9994dfc4656e6325ff9898 Mon Sep 17 00:00:00 2001 From: Emmanouil Froudarakis Date: Sun, 23 Feb 2025 09:25:36 +0200 Subject: [PATCH 07/13] FIX: consecutive recordings are getting the same rec_info because of async database entries. Internal counter of rec_info, in case database has no recordings. --- core/Logger.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/core/Logger.py b/core/Logger.py index b769c4ca..529ec4a8 100755 --- a/core/Logger.py +++ b/core/Logger.py @@ -167,6 +167,7 @@ def __init__(self, protocol=False): self.ping_timer = Timer() self.logger_timer = Timer() self.total_reward = 0 + self.rec_idx = 0 self.curr_state = "" self.thread_exception = None self.update_status = threading.Event() @@ -1014,14 +1015,18 @@ def log_recording(self, rec_key): The method assumes the existence of a `get` method to retrieve existing recordings and a `log` method to log the new recording entry. """ - recs = self.get( - schema="recording", - table="Recording", - key=self.trial_key, - fields=["rec_idx"], - ) - rec_idx = 1 if not recs else max(recs) + 1 - self.log('Recording', data={**rec_key, 'rec_idx': rec_idx}, schema='recording') + if not self.rec_idx: # if rec_idx has not been utilized check database for logs + recs = self.get( + schema="recording", + table="Recording", + key=self.trial_key, + fields=["rec_idx"], + ) + self.rec_idx = max(recs) + 1 + else: + self.rec_idx += 1 + + self.log('Recording', data={**rec_key, 'rec_idx': self.rec_idx}, schema='recording') def closeDatasets(self): """ From 33d46d3d4e953fb500f361ae43ff9adcd82ec615 Mon Sep 17 00:00:00 2001 From: Emmanouil Froudarakis Date: Mon, 24 Feb 2025 13:48:50 +0200 Subject: [PATCH 08/13] Lock recording rec_idx updating to minimize conflicts --- core/Logger.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/core/Logger.py b/core/Logger.py index 529ec4a8..e5b4a127 100755 --- a/core/Logger.py +++ b/core/Logger.py @@ -167,7 +167,6 @@ def __init__(self, protocol=False): self.ping_timer = Timer() self.logger_timer = Timer() self.total_reward = 0 - self.rec_idx = 0 self.curr_state = "" self.thread_exception = None self.update_status = threading.Event() @@ -1015,18 +1014,15 @@ def log_recording(self, rec_key): The method assumes the existence of a `get` method to retrieve existing recordings and a `log` method to log the new recording entry. """ - if not self.rec_idx: # if rec_idx has not been utilized check database for logs - recs = self.get( - schema="recording", - table="Recording", - key=self.trial_key, - fields=["rec_idx"], - ) - self.rec_idx = max(recs) + 1 - else: - self.rec_idx += 1 - - self.log('Recording', data={**rec_key, 'rec_idx': self.rec_idx}, schema='recording') + recs = self.get( + schema="recording", + table="Recording", + key=self.trial_key, + fields=["rec_idx"], + ) + rec_idx = 1 if not recs else max(recs) + 1 + self.log('Recording', data={**rec_key, 'rec_idx': rec_idx}, schema='recording', priority=1, block=True, + validate=True) def closeDatasets(self): """ From 1691bf1cb2e338a85797e81bdcaa795cac7e4f62 Mon Sep 17 00:00:00 2001 From: Emmanouil Froudarakis Date: Tue, 11 Mar 2025 14:48:03 +0200 Subject: [PATCH 09/13] removed Wheel --- Interfaces/Wheel.py | 84 --------------------------------------------- 1 file changed, 84 deletions(-) delete mode 100644 Interfaces/Wheel.py diff --git a/Interfaces/Wheel.py b/Interfaces/Wheel.py deleted file mode 100644 index 976f805a..00000000 --- a/Interfaces/Wheel.py +++ /dev/null @@ -1,84 +0,0 @@ -from core.Interface import * -import json, time -from serial import Serial -import threading -from queue import PriorityQueue - - -class Wheel(Interface): - thread_end, msg_queue = threading.Event(), PriorityQueue(maxsize=1) - - def __init__(self, **kwargs): - super(Wheel, self).__init__(**kwargs) - self.port = self.logger.get(table='SetupConfiguration', key=self.exp.params, fields=['path'])[0] - self.baud = 115200 - self.timeout = .001 - self.offset = None - self.no_response = False - self.timeout_timer = time.time() - self.wheel_dataset = self.logger.createDataset(dataset_name='wheel', - dataset_type=np.dtype([("position", np.double), - ("tmst", np.double)])) - self.frame_dataset = self.logger.createDataset(dataset_name='frames', - dataset_type=np.dtype([("idx", np.double), - ("tmst", np.double)])) - self.ser = Serial(self.port, baudrate=self.baud) - sleep(1) - self.thread_runner = threading.Thread(target=self._communicator) - self.thread_runner.start() - sleep(1) - self.msg_queue.put(Message(type='offset', value=self.logger.logger_timer.elapsed_time())) - - def release(self): - self.thread_end.set() - self.ser.close() # Close the Serial connection - - def _communicator(self): - while not self.thread_end.is_set(): - if not self.msg_queue.empty(): - msg = self.msg_queue.get().dict() - self._write_msg(msg) # Send it - msg = self._read_msg() # Read the response - if msg is not None: - if msg['type'] == 'Position' and self.offset is not None: - self.wheel_dataset.append('wheel', [msg['value'], msg['tmst']]) - elif msg['type'] == 'Frame' and self.offset is not None: - self.frame_dataset.append('frames', [msg['value'], msg['tmst']]) - elif msg['type'] == 'Offset': - self.offset = msg['value'] - - def _read_msg(self): - """Reads a line from the serial buffer, - decodes it and returns its contents as a dict.""" - if self.ser.in_waiting == 0: # don't run faster than necessary - return None - - try: # read message - incoming = self.ser.readline().decode("utf-8") - except: # partial read of message, retry - return None - - try: # decode message - response = json.loads(incoming) - return response - except json.JSONDecodeError: - print("Error decoding JSON message!") - return None - - def _write_msg(self, message=None): - """Sends a JSON-formatted command to the serial interface.""" - try: - json_msg = json.dumps(message) - self.ser.write(json_msg.encode("utf-8")) - except TypeError: - print("Unable to serialize message.") - - -@dataclass -class Message: - type: str = datafield(compare=False, default='') - value: int = datafield(compare=False, default=0) - - def dict(self): - return self.__dict__ - From 4ef1bcab215ea2f2a823757a157489ddddc38b92 Mon Sep 17 00:00:00 2001 From: Emmanouil Froudarakis Date: Tue, 11 Mar 2025 14:49:35 +0200 Subject: [PATCH 10/13] Wheel again --- Interfaces/Wheel.py | 84 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 Interfaces/Wheel.py diff --git a/Interfaces/Wheel.py b/Interfaces/Wheel.py new file mode 100644 index 00000000..976f805a --- /dev/null +++ b/Interfaces/Wheel.py @@ -0,0 +1,84 @@ +from core.Interface import * +import json, time +from serial import Serial +import threading +from queue import PriorityQueue + + +class Wheel(Interface): + thread_end, msg_queue = threading.Event(), PriorityQueue(maxsize=1) + + def __init__(self, **kwargs): + super(Wheel, self).__init__(**kwargs) + self.port = self.logger.get(table='SetupConfiguration', key=self.exp.params, fields=['path'])[0] + self.baud = 115200 + self.timeout = .001 + self.offset = None + self.no_response = False + self.timeout_timer = time.time() + self.wheel_dataset = self.logger.createDataset(dataset_name='wheel', + dataset_type=np.dtype([("position", np.double), + ("tmst", np.double)])) + self.frame_dataset = self.logger.createDataset(dataset_name='frames', + dataset_type=np.dtype([("idx", np.double), + ("tmst", np.double)])) + self.ser = Serial(self.port, baudrate=self.baud) + sleep(1) + self.thread_runner = threading.Thread(target=self._communicator) + self.thread_runner.start() + sleep(1) + self.msg_queue.put(Message(type='offset', value=self.logger.logger_timer.elapsed_time())) + + def release(self): + self.thread_end.set() + self.ser.close() # Close the Serial connection + + def _communicator(self): + while not self.thread_end.is_set(): + if not self.msg_queue.empty(): + msg = self.msg_queue.get().dict() + self._write_msg(msg) # Send it + msg = self._read_msg() # Read the response + if msg is not None: + if msg['type'] == 'Position' and self.offset is not None: + self.wheel_dataset.append('wheel', [msg['value'], msg['tmst']]) + elif msg['type'] == 'Frame' and self.offset is not None: + self.frame_dataset.append('frames', [msg['value'], msg['tmst']]) + elif msg['type'] == 'Offset': + self.offset = msg['value'] + + def _read_msg(self): + """Reads a line from the serial buffer, + decodes it and returns its contents as a dict.""" + if self.ser.in_waiting == 0: # don't run faster than necessary + return None + + try: # read message + incoming = self.ser.readline().decode("utf-8") + except: # partial read of message, retry + return None + + try: # decode message + response = json.loads(incoming) + return response + except json.JSONDecodeError: + print("Error decoding JSON message!") + return None + + def _write_msg(self, message=None): + """Sends a JSON-formatted command to the serial interface.""" + try: + json_msg = json.dumps(message) + self.ser.write(json_msg.encode("utf-8")) + except TypeError: + print("Unable to serialize message.") + + +@dataclass +class Message: + type: str = datafield(compare=False, default='') + value: int = datafield(compare=False, default=0) + + def dict(self): + return self.__dict__ + From 7f838110503e81656e85a538364a7851e06152b1 Mon Sep 17 00:00:00 2001 From: Emmanouil Froudarakis Date: Tue, 11 Mar 2025 15:01:03 +0200 Subject: [PATCH 11/13] Added flatness_correction in Stimulus, Passive Entry prepare stimulsu --- Experiments/Passive.py | 3 --- core/Stimulus.py | 1 + 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Experiments/Passive.py b/Experiments/Passive.py index 2410a700..d67add95 100644 --- a/Experiments/Passive.py +++ b/Experiments/Passive.py @@ -23,9 +23,6 @@ def entry(self): # updates stateMachine from Database entry - override for timi class Entry(Experiment): - def entry(self): - self.stim.prepare - def next(self): if self.logger.setup_status in ['operational']: return 'PreTrial' diff --git a/core/Stimulus.py b/core/Stimulus.py index 4617dd7e..5caddb0e 100755 --- a/core/Stimulus.py +++ b/core/Stimulus.py @@ -32,6 +32,7 @@ class Screen(dj.Part): fps : tinyint UNSIGNED resolution_x : smallint resolution_y : smallint + flatness_correction : binary description : varchar(256) """ From 82f74aa131b2919842efab56ddbb2622d6f1abd0 Mon Sep 17 00:00:00 2001 From: Emmanouil Froudarakis Date: Tue, 11 Mar 2025 15:02:14 +0200 Subject: [PATCH 12/13] PsychoPresenter + PsychoStimuli --- Interfaces/Photodiode.py | 84 ++++++++++++++++++++++++++++++ Stimuli/PsychoBar.py | 107 ++++++++++++++++++++++++++++++++++++++ Stimuli/PsychoDot.py | 59 +++++++++++++++++++++ Stimuli/PsychoGrating.py | 81 +++++++++++++++++++++++++++++ Stimuli/PsychoMovies.py | 79 ++++++++++++++++++++++++++++ utils/PsychoPresenter.py | 108 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 518 insertions(+) create mode 100644 Interfaces/Photodiode.py create mode 100644 Stimuli/PsychoBar.py create mode 100644 Stimuli/PsychoDot.py create mode 100644 Stimuli/PsychoGrating.py create mode 100644 Stimuli/PsychoMovies.py create mode 100644 utils/PsychoPresenter.py diff --git a/Interfaces/Photodiode.py b/Interfaces/Photodiode.py new file mode 100644 index 00000000..4be40298 --- /dev/null +++ b/Interfaces/Photodiode.py @@ -0,0 +1,84 @@ +from core.Interface import * +import json, time +from serial import Serial +import threading +from queue import PriorityQueue + + +class Photodiode(Interface): + thread_end, msg_queue = threading.Event(), PriorityQueue(maxsize=1) + + def __init__(self, **kwargs): + super(Photodiode, self).__init__(**kwargs) + self.port = self.exp.logger.get(table='SetupConfiguration', key=self.exp.params, fields=['path'])[0] + self.baud = 115200 + self.timeout = .001 + self.offset = None + self.no_response = False + self.timeout_timer = time.time() + self.dataset = self.exp.logger.createDataset(dataset_name='fliptimes', + dataset_type=np.dtype([("phd_level", np.double), + ("tmst", np.double)])) + self.ser = Serial(self.port, baudrate=self.baud) + sleep(1) + self.thread_runner = threading.Thread(target=self._communicator) + self.thread_runner.start() + sleep(1) + print('logger timer:', self.exp.logger.logger_timer.elapsed_time()) + self.msg_queue.put(Message(type='offset', value=self.exp.logger.logger_timer.elapsed_time())) + + def cleanup(self): + self.thread_end.set() + self.ser.close() # Close the Serial connection + + def _communicator(self): + while not self.thread_end.is_set(): + if not self.msg_queue.empty(): + msg = self.msg_queue.get().dict() + self._write_msg(msg) # Send it + msg = self._read_msg() # Read the response + if msg is not None: + if msg['type'] == 'Level' and self.offset is not None: + print(msg['value'], msg['tmst']) + self.dataset.append('fliptimes', [msg['value'], msg['tmst']]) + elif msg['type'] == 'Offset': + self.offset = msg['value'] + print('offset:', self.offset) + + def _read_msg(self): + """Reads a line from the serial buffer, + decodes it and returns its contents as a dict.""" + now = time.time() + if (now - self.timeout_timer) > 3: + self.timeout_timer = time.time() + return None + elif self.ser.in_waiting == 0: # Nothing received + self.no_response = True + return None + incoming = self.ser.readline().decode("utf-8") + resp = None + self.no_response = False + self.timeout_timer = time.time() + try: + resp = json.loads(incoming) + except json.JSONDecodeError: + print("Error decoding JSON message!") + return resp + + def _write_msg(self, message=None): + """Sends a JSON-formatted command to the serial interface.""" + try: + json_msg = json.dumps(message) + self.ser.write(json_msg.encode("utf-8")) + except TypeError: + print("Unable to serialize message.") + + +@dataclass +class Message: + type: str = datafield(compare=False, default='') + value: int = datafield(compare=False, default=0) + + def dict(self): + return self.__dict__ + diff --git a/Stimuli/PsychoBar.py b/Stimuli/PsychoBar.py new file mode 100644 index 00000000..49329675 --- /dev/null +++ b/Stimuli/PsychoBar.py @@ -0,0 +1,107 @@ +from core.Stimulus import * +from utils.helper_functions import flat2curve +from utils.PsychoPresenter import * + +@stimulus.schema +class Bar(Stimulus, dj.Manual): + definition = """ + # This class handles the presentation of area mapping Bar stimulus + -> StimCondition + --- + axis : enum('vertical','horizontal') + bar_width : float # degrees + bar_speed : float # degrees/sec + flash_speed : float # cycles/sec + grat_width : float # degrees + grat_freq : float + grid_width : float + grit_freq : float + style : enum('checkerboard', 'grating','none') + direction : float # 1 for UD LR, -1 for DU RL + flatness_correction : tinyint(1) # 1 correct for flatness of monitor, 0 do not + intertrial_duration : int + max_res : smallint + """ + + cond_tables = ['Bar'] + default_key = {'max_res' : 1000, + 'bar_width' : 4, # degrees + 'bar_speed' : 2, # degrees/sec + 'flash_speed' : 2, + 'grat_width' : 10, # degrees + 'grat_freq' : 1, + 'grid_width' : 10, + 'grit_freq' : .1, + 'style' : 'checkerboard', # checkerboard, grating + 'direction' : 1, # 1 for UD LR, -1 for DU RL + 'flatness_correction' : 1, + 'intertrial_duration' : 0} + + def setup(self): + self.Presenter = Presenter(self.logger, self.monitor, background_color=self.fill_colors.background, + photodiode='parity', rec_fliptimes=self.rec_fliptimes) + ymonsize = self.monitor.size * 2.54 / np.sqrt(1 + self.monitor.aspect ** 2) # cm Y monitor size + monSize = [ymonsize * self.monitor.aspect, ymonsize] + y_res = int(self.exp.params['max_res'] / self.monitor.aspect) + self.monRes = [self.exp.params['max_res'], int(y_res + np.ceil(y_res % 2))] + self.FoV = np.arctan(np.array(monSize) / 2 / self.monitor.distance) * 2 * 180 / np.pi # in degrees + self.FoV[1] = self.FoV[0] / self.monitor.aspect + + def prepare(self, curr_cond): + self.curr_cond = curr_cond + self.in_operation = True + self.curr_frame = 1 + + # initialize hor/ver gradients + caxis = 1 if self.curr_cond['axis'] == 'vertical' else 0 + Yspace = np.linspace(-self.FoV[1], self.FoV[1], self.monRes[1]) * self.curr_cond['direction'] + Xspace = np.linspace(-self.FoV[0], self.FoV[0], self.monRes[0]) * self.curr_cond['direction'] + self.cycles = dict() + [self.cycles[abs(caxis - 1)], self.cycles[caxis]] = np.meshgrid(-Yspace/2/self.curr_cond['bar_width'], + -Xspace/2/self.curr_cond['bar_width']) + if self.curr_cond['flatness_correction']: + I_c, self.transform = flat2curve(self.cycles[0], self.monitor.distance, + self.monitor.size, method='index', + center_x=self.monitor.center_x, + center_y=self.monitor.center_y) + self.BarOffset = -np.max(I_c) - 0.5 + deg_range = (np.ptp(I_c)+1)*self.curr_cond['bar_width'] + else: + deg_range = (self.FoV[caxis] + self.curr_cond['bar_width']) # in degrees + self.transform = lambda x: x + self.BarOffset = np.min(self.cycles[0]) - 0.5 + self.nbFrames = np.ceil( deg_range / self.curr_cond['bar_speed'] * self.monitor.fps) + self.BarOffsetCyclesPerFrame = deg_range / self.nbFrames / self.curr_cond['bar_width'] + + # compute fill parameters + if self.curr_cond['style'] == 'checkerboard': # create grid + [X, Y] = np.meshgrid(-Yspace / 2 / self.curr_cond['grid_width'], -Xspace / 2 / self.curr_cond['grid_width']) + VG1 = np.cos(2 * np.pi * X) > 0 # vertical grading + VG2 = np.cos((2 * np.pi * X) - np.pi) > 0 # vertical grading with pi offset + HG = np.cos(2 * np.pi * Y) > 0 # horizontal grading + Grid = VG1 * HG + VG2 * (1 - HG) # combine all + self.StimOffsetCyclesPerFrame = self.curr_cond['flash_speed'] / self.monitor.fps + self.generate = lambda x: abs(Grid - (np.cos(2 * np.pi * x) > 0)) + elif self.curr_cond['style'] == 'grating': + self.StimOffsetCyclesPerFrame = self.curr_cond['grat_freq'] / self.monitor.fps + self.generate = lambda x: np.cos(2 * np.pi * (self.cycles[1]*self.curr_cond['bar_width']/self.curr_cond['grat_width'] + x)) > 0 # vertical grading + elif self.curr_cond['style'] == 'none': + self.StimOffsetCyclesPerFrame = 1 + self.generate = lambda x: 1 + self.StimOffset = 0 # intialize offsets + + def present(self): + if self.curr_frame < self.nbFrames: + offset_cycles = self.cycles[0] + self.BarOffset + offset_cycles[np.logical_or(offset_cycles < -0.5, offset_cycles > .5)] = 0.5 # threshold grading to create a single bar + texture = np.int8((np.cos(offset_cycles * 2 * np.pi) > -1) * self.generate(self.StimOffset)*2) - 1 + new_surface = self.transform(np.tile(texture[:, :, np.newaxis], (1, 3))) + curr_image = psychopy.visual.ImageStim(self.win, new_surface) + curr_image.draw() + self.flip() + self.StimOffset += self.StimOffsetCyclesPerFrame + self.BarOffset += self.BarOffsetCyclesPerFrame + else: + self.in_operation = False + self.fill() + diff --git a/Stimuli/PsychoDot.py b/Stimuli/PsychoDot.py new file mode 100644 index 00000000..9829718f --- /dev/null +++ b/Stimuli/PsychoDot.py @@ -0,0 +1,59 @@ +from core.Stimulus import * +from utils.PsychoPresenter import * + +@stimulus.schema +class Dot(Stimulus, dj.Manual): + definition = """ + # This class handles the presentation of area mapping Bar stimulus + -> StimCondition + --- + bg_level : tinyblob # 0-255 + dot_level : tinyblob # 0-255 + dot_x : float # (fraction of monitor width, 0 for center, from -0.5 to 0.5) position of dot on x axis + dot_y : float # (fraction of monitor width, 0 for center) position of dot on y axis + dot_xsize : float # fraction of monitor width, width of dots + dot_ysize : float # fraction of monitor width, height of dots + dot_shape : enum('rect','oval') # shape of the dot + dot_time : float # (sec) time of each dot persists + """ + + cond_tables = ['Dot'] + required_fields = ['dot_x', 'dot_y', 'dot_xsize', 'dot_ysize', 'dot_time'] + default_key = {'bg_level' : 1, + 'dot_level' : 0, # degrees + 'dot_shape' : 'rect'} + + def setup(self): + self.Presenter = Presenter(self.logger, self.monitor, background_color=self.fill_colors.background, + photodiode='parity', rec_fliptimes=self.rec_fliptimes) + + def prepare(self, curr_cond): + self.curr_cond = curr_cond + self.fill_colors.background = self.curr_cond['bg_level'] + self.Presenter.fill(self.curr_cond['bg_level']) + self.rect = psychopy.visual.Rect(self.Presenter.win, + width=self.curr_cond['dot_xsize'], + height=self.curr_cond['dot_ysize'] * float(self.Presenter.window_ratio), + pos=[self.curr_cond['dot_x'], + self.curr_cond['dot_y'] * float(self.Presenter.window_ratio) ]) + + def start(self): + super().start() + self.rect.color = self.curr_cond['dot_level'] + self.rect.draw() + + def stop(self): + self.log_stop() + self.in_operation = False + + def present(self): + if self.timer.elapsed_time() > self.curr_cond['dot_time']*1000: + self.in_operation = False + + def exit(self): + self.Presenter.fill(self.fill_colors.background) + super().exit() + + + + diff --git a/Stimuli/PsychoGrating.py b/Stimuli/PsychoGrating.py new file mode 100644 index 00000000..95b840e6 --- /dev/null +++ b/Stimuli/PsychoGrating.py @@ -0,0 +1,81 @@ +from core.Stimulus import * +from utils.PsychoPresenter import * +from utils.helper_functions import iterable + + +@stimulus.schema +class PsychoGrating(Stimulus, dj.Manual): + definition = """ + # Definition of grating stimulus conditions + -> StimCondition + pos_x : float # relative to the origin at the center + --- + pos_y : float # relative to the origin at the center + tex="sin" : enum('sin','sqr','saw','tri') # Texture to use for the primary carrier + units="deg" : enum('deg','pix','cm') # Units to use when drawing + size : float # Size of the grating + mask="sqr" : enum('circle','sqr','gauss') # mask to control the shape of the grating + ori : float # Initial orientation of the shape in degrees about its origin + sf : float # cycles/deg + tf : float # cycles/sec + phase : float # initial phase in 0-1 + contrast : tinyint # 0-1 + warper : tinyint(1) # 1 correct for flatness of monitor, 0 do not + duration : smallint # grating duration (seconds) + """ + + cond_tables = ["PsychoGrating"] + required_fields = ['ori', 'sf', 'duration'] + default_key = { + "pos_x": 0, + "pos_y": 0, + "tex": "sin", + "units": " ", + "size": 100, + "mask": "gauss", + "tf": 0.5, + "phase": 0, + "contrast": 1, + "warper": 1} + + def setup(self): + """setup stimulation for presentation before experiment starts""" + self.Presenter = Presenter(self.logger, self.monitor, background_color=self.fill_colors.background, + photodiode='parity', rec_fliptimes=self.rec_fliptimes) + + def prepare(self, curr_cond, stim_period=''): + self.gratings = dict() + self.conds = dict() + self.curr_cond = curr_cond + for idx, pos_x in enumerate(iterable(self.curr_cond['pos_x'])): + # Create GratingStim object + cond = self._get_cond(idx) + self.conds[idx] = cond + self.gratings[idx] = psychopy.visual.GratingStim(self.Presenter.win, + pos=[cond['pos_x'], cond['pos_y']], + ori=cond['ori'], + sf=cond['sf'], + tex=cond['tex'], + units=cond['units'], + size=[cond['size'], cond['size']], + mask=cond['mask'], + phase=cond['phase'], + contrast=cond['contrast']) + + def present(self): + if self.timer.elapsed_time() > self.curr_cond['duration']: + self.in_operation = False + return + # Stimulus presentation ( + for idx, grat in enumerate(iterable(self.curr_cond['pos_x'])): + self.gratings[idx].phase += self.conds[idx]['tf'] / self.monitor.fps # assuming 60 Hz refresh rate + self.gratings[idx].draw() + self.Presenter.flip() + + def _get_cond(self, idx=0): + return {k: v if type(v) is int or type(v) is float or type(v) is str or type(v) is bool else v[idx] + for k, v in self.curr_cond.items()} + + def exit(self): + """exit stimulus stuff""" + self.Presenter.win.close() \ No newline at end of file diff --git a/Stimuli/PsychoMovies.py b/Stimuli/PsychoMovies.py new file mode 100644 index 00000000..4287e52d --- /dev/null +++ b/Stimuli/PsychoMovies.py @@ -0,0 +1,79 @@ +from core.Stimulus import * +from time import sleep +import io, os, imageio +from utils.PsychoPresenter import * + + +@stimulus.schema +class Movies(Stimulus, dj.Manual): + definition = """ + # movie clip conditions + -> StimCondition + --- + movie_name : char(8) # short movie title + clip_number : int # clip index + movie_duration : smallint # movie duration + skip_time : smallint # start time in clip + static_frame : smallint # static frame presentation + """ + + default_key = dict(movie_duration=10, skip_time=0, static_frame=False) + required_fields = ['movie_name', 'clip_number', 'movie_duration', 'skip_time', 'static_frame'] + cond_tables = ['Movies'] + + def init(self, exp): + super().init(exp) + self.path = self.logger.source_path + 'movies/' + + def setup(self): + self.Presenter = Presenter(self.logger, self.monitor, background_color=self.fill_colors.background, + photodiode='parity', rec_fliptimes=self.rec_fliptimes) + + def prepare(self, curr_cond, stim_period=''): + self.curr_cond = curr_cond + file_name = self.get_clip_info(self.curr_cond, 'Movie.Clip', 'file_name') + frame_rate, frame_height, frame_width = \ + self.get_clip_info(self.curr_cond, 'Movie', 'frame_rate', 'frame_height', 'frame_width') + video_aspect = frame_width[0]/frame_height[0] + size = [2, video_aspect*2] if self.monitor.aspect > video_aspect else [2*video_aspect, 2] + print('size: ', size) + self.vid = psychopy.visual.MovieStim(self.Presenter.win, self.path +file_name[0], + loop=False, # replay the video when it reaches the end + autoStart=True, + size=[2, 2], # set as `None` to use the native video size + units='norm') + + self.in_operation = True + self.timer.start() + + def present(self): + if self.timer.elapsed_time() < self.curr_cond['movie_duration']: + self.vid.draw() + self.Presenter.flip() + else: + self.in_operation = False + + def stop(self): + super().stop() + self.vid.stop() + + def get_clip_info(self, key, table, *fields): + return self.exp.logger.get(schema='stimulus', table=table, key=key, fields=fields) + + def make_conditions(self, conditions): + conditions = super().make_conditions(conditions) + + # store local copy of files + if not os.path.isdir(self.path): # create path if necessary + os.makedirs(self.path) + for cond in conditions: + file = self.exp.logger.get(schema='stimulus', table='Movie.Clip', key=cond, fields=('file_name',)) + print(file) + filename = self.path + file[0] + if not os.path.isfile(filename): + print('Saving %s' % filename) + clip = self.exp.logger.get(schema='stimulus', table='Movie.Clip', key=cond, fields=('clip',)) + clip[0].tofile(filename) + return conditions + + diff --git a/utils/PsychoPresenter.py b/utils/PsychoPresenter.py new file mode 100644 index 00000000..6c57f2e8 --- /dev/null +++ b/utils/PsychoPresenter.py @@ -0,0 +1,108 @@ +import psychopy.visual +import psychopy.event +import psychopy.core +from psychopy.visual.windowwarp import Warper +import numpy as np +import pygame + + +class Presenter(): + + def __init__(self, logger, monitor, background_color=(0, 0, 0), photodiode=False, rec_fliptimes=False): + self.logger = logger + self.monitor = monitor + self.clock = pygame.time.Clock() + + # define the window + self.win = psychopy.visual.Window( + size=[monitor.resolution_x, monitor.resolution_y], + monitor='testMonitor', screen=monitor.screen_idx-1, + fullscr=monitor.fullscreen, useFBO=True, waitBlanking=True) + + # compensate for monitor flatness + if monitor.flatness_correction: + self.warper = Warper(self.win, + warp='spherical', + warpGridsize=64, + flipHorizontal=False, + flipVertical=False) + self.warper.dist_cm = 5 #self.monitor.distance + self.warper.warpGridsize = 64 + self.warper.changeProjection('spherical', eyepoint=(monitor.center_x+0.5, monitor.center_y+0.5)) + + # record fliptimes for syncing + self.rec_fliptimes = rec_fliptimes + if self.rec_fliptimes: + self.fliptimes_dataset = self.logger.createDataset(dataset_name='fliptimes', + dataset_type=np.dtype([("flip_idx", np.double), + ("tmst", np.double)])) + # initialize variables + self.flip_count = 0 + self.photodiode = photodiode + self.phd_size = 0.1 # default photodiode signal size in ratio of the X screen size + self.window_ratio = self.monitor.resolution_x / self.monitor.resolution_y + self._setup_photodiode(photodiode) + self.background_color = background_color + self.fill() + + def _setup_photodiode(self, photodiode=True): + if photodiode: + self.photodiode_rect = psychopy.visual.Rect(self.win, + units='norm', + width=self.phd_size, + height=self.phd_size * float(self.window_ratio), + pos=[-1 + self.phd_size / 2, + 1 - self.phd_size * float(self.window_ratio) / 2]) + if photodiode == 'parity': + # Encodes the flip even / dot in the flip amplitude + self.phd_f = lambda x: float(float(x // 2) == x / 2) + elif photodiode == 'flipcount': + # Encodes the flip count (n) in the flip amplitude. + # Every 32 sequential flips encode 32 21-bit flip numbers. + # Thus each n is a 21-bit flip number: FFFFFFFFFFFFFFFFCCCCP + # C = the position within F + # F = the current block of 32 flips + self.phd_f = lambda x: 0.5 * float(((x+1) & 1) * (2 - ((x+1) & (1 << (((np.int64(np.floor((x+1) / 2)) & 15) + 6) - 1)) != 0))) + else: + print(photodiode, ' method not implemented! Available methods: parity, flipcount') + + def render_image(self, image): + curr_image = psychopy.visual.ImageStim(self.win, image) + curr_image.draw() + self.flip() + + def set_background_color(self, color): + self.background_color = color + self.fill(color=color) + + def fill(self, color=False): + """stimulus hiding method""" + if not color: + color = self.background_color + if color: + self.win.color = color + self.flip() + + def flip(self): + self.flip_count += 1 + self._encode_photodiode() + self.win.flip() + if self.rec_fliptimes: + self.fliptimes_dataset.append('fliptimes', [self.flip_count, self.logger.logger_timer.elapsed_time()]) + self.tick() + + def tick(self): + self.clock.tick(self.monitor.fps) + + def _encode_photodiode(self): + """ Encodes the flip parity or flip number in the flip amplitude. + """ + if self.photodiode: + amp = self.phd_f(self.flip_count) + #self.warper.changeProjection(None) + self.photodiode_rect.color = [amp, amp, amp] + self.photodiode_rect.draw() + #self.warper.changeProjection('spherical', eyepoint=(self.monitor.center_x+0.5, self.monitor.center_y+0.5)) + + def quit(self): + self.win.close() From bcb97add98ec7f8b3514eed2f3ae550c96d1accf Mon Sep 17 00:00:00 2001 From: Emmanouil Froudarakis Date: Tue, 11 Mar 2025 15:04:19 +0200 Subject: [PATCH 13/13] testing configurations --- conf/bar_test.py | 5 ++-- conf/dot_test.py | 3 ++- conf/grating_test.py | 22 +++++++++------- conf/movies_test.py | 9 ++++--- conf/obj_test_passive.py | 6 ++--- conf/psychograting_test.py | 54 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 79 insertions(+), 20 deletions(-) create mode 100644 conf/psychograting_test.py diff --git a/conf/bar_test.py b/conf/bar_test.py index d76bceee..c5313a5e 100644 --- a/conf/bar_test.py +++ b/conf/bar_test.py @@ -28,16 +28,17 @@ 'grit_freq' : 1, 'style' : 'checkerboard', # checkerboard, grating 'direction' : 1, # 1 for UD LR, -1 for DU RL - 'flatness_correction' : 1, + 'flatness_correction' : 0, 'intertrial_duration' : 0, } repeat_n = 10 +block = exp.Block(difficulty=0, next_up=0, next_down=0, trial_selection='fixed') conditions = [] for axis in ['horizontal', 'vertical']: for rep in range(0, repeat_n): - conditions += exp.make_conditions(stim_class=Bar(), conditions={**key, 'axis': axis}) + conditions += exp.make_conditions(stim_class=Bar(), conditions={**block.dict(),**key, 'axis': axis}) # run experiments diff --git a/conf/dot_test.py b/conf/dot_test.py index 3dbbfd3b..7a6e9a94 100644 --- a/conf/dot_test.py +++ b/conf/dot_test.py @@ -31,8 +31,9 @@ dot = Dot() dot.photodiode = False dot.rec_fliptimes = False +block = exp.Block(difficulty=0, next_up=0, next_down=0, trial_selection='fixed') for rep in range(0, repeat_n): - conditions += exp.make_conditions(stim_class=dot, conditions=key) + conditions += exp.make_conditions(stim_class=dot, conditions={**block.dict(), **key}) # randomize conditions random.seed(0) diff --git a/conf/grating_test.py b/conf/grating_test.py index 2d666d84..76677e99 100644 --- a/conf/grating_test.py +++ b/conf/grating_test.py @@ -1,7 +1,8 @@ # Orientation discrimination experiment -from Behaviors.MultiPort import * -from Experiments.MatchPort import * +from Experiments.Passive import * from Stimuli.Grating import * +from core.Behavior import * +from Interfaces.Photodiode import * # define session parameters session_params = { @@ -15,23 +16,23 @@ } exp = Experiment() -exp.setup(logger, MultiPort, session_params) +exp.setup(logger, Behavior, session_params) +#phd = Photodiode(exp=exp) +block = exp.Block(difficulty=0, next_up=0, next_down=0, trial_selection='fixed') # define stimulus conditions key = { 'contrast' : 100, 'spatial_freq' : .05, # cycles/deg 'square' : 0, # squarewave or Guassian - 'temporal_freq' : 0, # cycles/sec + 'temporal_freq' : 1, # cycles/sec 'flatness_correction': 1, # adjustment of spatiotemporal frequencies based on animal distance 'duration' : 5000, - 'difficulty' : 1, 'timeout_duration' : 1000, 'trial_duration' : 5000, 'intertrial_duration': 0, 'init_duration' : 0, 'delay_duration' : 0, - 'reward_amount' : 8 } repeat_n = 1 @@ -41,15 +42,16 @@ 2: 90} Grating_Stimuli = Grating() #if session_params['setup_conf_idx'] ==0 else GratingOld() +Grating_Stimuli.photodiode = 'parity' +Grating_Stimuli.rec_fliptimes = True Grating_Stimuli.fill_colors.ready = [] -block = exp.Block(difficulty=1, next_up=1, next_down=1, trial_selection='staircase', metric='dprime', stair_up=1, stair_down=0.5) for port in ports: - conditions += exp.make_conditions(stim_class=Grating_Stimuli, conditions={**block.dict(), - **key, + conditions += exp.make_conditions(stim_class=Grating_Stimuli, conditions={**block.dict(), **key, 'theta' : ports[port], 'reward_port' : port, 'response_port': port}) # run experiments exp.push_conditions(conditions) -exp.start() \ No newline at end of file +exp.start() +#phd.cleanup() \ No newline at end of file diff --git a/conf/movies_test.py b/conf/movies_test.py index f12b8608..9b187620 100644 --- a/conf/movies_test.py +++ b/conf/movies_test.py @@ -15,18 +15,19 @@ conditions = [] # define stimulus conditions -objects = ['MadMax'] +objects = ['madmax'] key = { - 'clip_number' : list(range(10, 40)), + 'clip_number' : [3,2,4], 'skip_time' : [0], - 'movie_duration' : 5000, + 'movie_duration' : 2000, 'static_frame' : False, 'intertrial_duration': 500, } +block = exp.Block(difficulty=0, next_up=0, next_down=0, trial_selection='fixed') for obj in objects: - conditions += exp.make_conditions(stim_class=Movies(), conditions={**key, 'movie_name': obj}) + conditions += exp.make_conditions(stim_class=Movies(), conditions={**block.dict(), **key, 'movie_name': obj}) random.seed(0) random.shuffle(conditions) diff --git a/conf/obj_test_passive.py b/conf/obj_test_passive.py index 1e2f4f8e..07d876c3 100644 --- a/conf/obj_test_passive.py +++ b/conf/obj_test_passive.py @@ -24,11 +24,11 @@ np.random.seed(0) obj_combs = [[3, 2]] times = 10 -reps = 1 +reps = 2 panda_obj = Panda() panda_obj.record() - +block = exp.Block(difficulty=0, next_up=0, next_down=0, trial_selection='fixed') for idx, obj_comb in enumerate(obj_combs): for irep in range(0, reps): pos_x_f = lambda x: interp(np.random.rand(x) - 0.5) @@ -39,7 +39,7 @@ yaw_f = lambda x: interp(np.random.rand(x)*10) dir1_f = lambda: np.array([0, -20, 0]) + np.random.randn(3)*30 dir2_f = lambda: np.array([180, -20, 0]) + np.random.randn(3)*30 - conditions += exp.make_conditions(stim_class=panda_obj, conditions={ + conditions += exp.make_conditions(stim_class=panda_obj, conditions={**block.dict(), 'background_color': (0.5, 0.5, 0.5), 'obj_dur': 3000, 'obj_id': [obj_comb], diff --git a/conf/psychograting_test.py b/conf/psychograting_test.py new file mode 100644 index 00000000..c8287d2a --- /dev/null +++ b/conf/psychograting_test.py @@ -0,0 +1,54 @@ +# Orientation discrimination experiment +from Experiments.Passive import * +from Stimuli.PsychoGrating import * +from core.Behavior import * +from Interfaces.Photodiode import * + +# define session parameters +session_params = { + 'trial_selection' : 'fixed', + 'setup_conf_idx' : 0, +} + +exp = Experiment() +exp.setup(logger, Behavior, session_params) +block = exp.Block(difficulty=0, next_up=0, next_down=0, trial_selection='fixed') + +# define stimulus conditions +key = { + 'contrast' : 1, + 'sf' : .2, # cycles/deg + 'duration' : 1000, + 'trial_duration' : 1000, + 'intertrial_duration': 0, + 'init_duration' : 0, + 'delay_duration' : 0, +} + +repeat_n = 1 +conditions = [] + +ports = {1: 0, + 2: 90} + +Grating_Stimuli = PsychoGrating() +Grating_Stimuli.fill_colors.ready = [] +Grating_Stimuli.fill_colors.background = [] + +Grating_Stimuli.fill_colors.set({'background': (0.5, 0.5, 0.5)}) + + +for port in ports: + conditions += exp.make_conditions(stim_class=Grating_Stimuli, conditions={**block.dict(), **key, + 'ori' : ports[port], + 'pos_x': [[-5, 5]], + 'size': 10}) + +conditions += exp.make_conditions(stim_class=Grating_Stimuli, conditions={**block.dict(), **key, + 'ori' : 0, + 'pos_x': 0, + 'size': 100}) + +# run experiments +exp.push_conditions(conditions) +exp.start() \ No newline at end of file