experiment_process_data
This module defines the ExperimentProcessData class, which serves as the central data management hub for the Photologic-Experiment-Rig application.
It aggregates references to other data-holding classes (StimuliData
, EventData
,
ArduinoData
) and manages core experimental parameters like current trial,
state time durations, and the overall experiment schedule DataFrame. It provides
methods for generating the experimental schedule, updating parameters from the GUI,
calculating runtime, and saving collected data.
1""" 2This module defines the ExperimentProcessData class, which serves as the central 3data management hub for the Photologic-Experiment-Rig application. 4 5It aggregates references to other data-holding classes (`StimuliData`, `EventData`, 6`ArduinoData`) and manages core experimental parameters like current trial, 7state time durations, and the overall experiment schedule DataFrame. It provides 8methods for generating the experimental schedule, updating parameters from the GUI, 9calculating runtime, and saving collected data. 10""" 11 12import logging 13from tkinter import filedialog 14import numpy as np 15import pandas as pd 16from typing import Tuple, List 17import datetime 18from pathlib import Path 19 20from models.stimuli_data import StimuliData 21from models.event_data import EventData 22from models.arduino_data import ArduinoData 23from views.gui_common import GUIUtils 24 25logger = logging.getLogger(__name__) 26 27 28class ExperimentProcessData: 29 """ 30 Central data management class for the behavioral experiment. 31 32 This class acts as the primary container and manager for all data related 33 to an ongoing experiment. It holds references to specialized data classes 34 (`EventData`, `StimuliData`, `ArduinoData`) and maintains experiment-wide 35 state variables, timing intervals, parameter entries (num_stimuli for example), and the generated 36 program schedule (`program_schedule_df`). It performs the generation 37 of the trial schedule and state intervals based on user inputs. 38 39 Attributes 40 ---------- 41 - **`start_time`** (*float*): Timestamp marking the absolute start of the program run. 42 - **`trial_start_time`** (*float*): Timestamp marking the start of the current trial. 43 - **`state_start_time`** (*float*): Timestamp marking the entry into the current FSM state. 44 - **`current_trial_number`** (*int*): The 1-indexed number of the trial currently in progress or about to start. 45 - **`event_data`** (*EventData*): Instance managing the DataFrame of recorded lick/motor events. 46 - **`stimuli_data`** (*StimuliData*): Instance managing stimuli names and related information. 47 - **`arduino_data`** (*ArduinoData*): Instance managing Arduino communication data (durations, schedule indices) and processing incoming Arduino messages. 48 - **`ITI_intervals_final`** (*npt.NDArray[np.int64] | None*): Numpy array holding the calculated Inter-Trial Interval duration (ms) for each trial. 49 None until schedule generated. 50 - **`TTC_intervals_final`** (*npt.NDArray[np.int64] | None*): Numpy array holding the calculated Time-To-Contact duration (ms) for each trial. 51 None until schedule generated. 52 - **`sample_intervals_final`** (*npt.NDArray[np.int64] | None*): Numpy array holding the calculated Sample period duration (ms) for each trial. 53 None until schedule generated. 54 - **`TTC_LICK_THRESHOLD`** (*int*): The number of licks required during the TTC state to trigger an early transition to the SAMPLE state. 55 - **`interval_vars`** (*dict[str, int]*): Dictionary storing base and random variation values (ms) for timing intervals (ITI, TTC, Sample), sourced from GUI entries. 56 - **`exp_var_entries`** (*dict[str, int]*): Dictionary storing core experiment parameters (Num Trial Blocks, Num Stimuli), sourced from GUI entries. 57 `Num Trials` is calculated based on these other values. 58 - **`program_schedule_df`** (*pd.DataFrame*): Pandas DataFrame holding the generated trial-by-trial schedule, including stimuli presentation, calculated intervals, 59 and placeholders for results. Initialized empty. 60 61 Methods 62 ------- 63 - `update_model`(...) 64 Updates internal parameter dictionaries (`interval_vars`, `exp_var_entries`) based on changes from GUI input fields (tkinter vars). 65 - `get_default_value`(...) 66 Retrieves the default or current value for a given parameter name from internal dictionaries, used to populate GUI fields initially. 67 - `generate_schedule`() 68 Performs the creation of the entire experimental schedule, including intervals and stimuli pairings. Returns status. 69 - `create_random_intervals`() 70 Calculates the final ITI, TTC, and Sample intervals for each trial based on base values and randomization ranges. 71 - `calculate_max_runtime`() 72 Estimates the maximum possible runtime based on the sum of all generated intervals. 73 - `create_trial_blocks`() 74 Determines the unique pairs of stimuli presented within a single block based on the number of stimuli. 75 - `generate_pairs`(...) 76 Generates the pseudo-randomized sequence of stimuli pairs across all trials and blocks. 77 - `build_frame`(...) 78 Constructs the `program_schedule_df` DataFrame using the generated stimuli sequences and intervals. 79 - `save_all_data`() 80 Initiates the process of saving the schedule and event log DataFrames to Excel files. 81 - `get_paired_index`(...) 82 Static method to determine the 0-indexed valve number on the opposite side corresponding to a given valve index on side one. 83 - `save_df_to_xlsx`(...) 84 Static method to handle saving a pandas DataFrame to an .xlsx file using a file dialog. 85 - `convert_seconds_to_minutes_seconds`(...) 86 Static method to convert a total number of seconds into minutes and remaining seconds. 87 """ 88 89 def __init__(self): 90 """ 91 Initializes the ExperimentProcessData central data hub. 92 93 Sets initial timestamps to 0.0, starts `current_trial_number` at 1. 94 Instantiates `EventData`, `StimuliData`, and `ArduinoData` (passing self reference). 95 Initializes interval arrays (`ITI_intervals_final`, etc.) to None. 96 Sets the `TTC_LICK_THRESHOLD`. 97 Defines default values for `interval_vars` and `exp_var_entries`. 98 Initializes `program_schedule_df` as an empty DataFrame. 99 """ 100 101 self.start_time: float = 0.0 102 self.trial_start_time: float = 0.0 103 self.state_start_time: float = 0.0 104 105 # this number is centralized here so that all state classes can access and update it easily without 106 # passing it through to each state every time a state change occurs 107 self.current_trial_number: int = 1 108 109 self.event_data = EventData() 110 self.stimuli_data = StimuliData() 111 self.arduino_data = ArduinoData(self) 112 113 self.ITI_intervals_final = None 114 self.TTC_intervals_final = None 115 self.sample_intervals_final = None 116 117 # this constant should be used in arduino_data to say how many licks moves to sample 118 self.TTC_LICK_THRESHOLD: int = 3 119 120 self.interval_vars: dict[str, int] = { 121 "ITI_var": 30000, 122 "TTC_var": 20000, 123 "sample_var": 15000, 124 "ITI_random_entry": 0, 125 "TTC_random_entry": 5000, 126 "sample_random_entry": 5000, 127 } 128 self.exp_var_entries: dict[str, int] = { 129 "Num Trial Blocks": 10, 130 "Num Stimuli": 4, 131 "Num Trials": 0, 132 } 133 134 # include an initial definition of program_schedule_df here as a blank df to avoid type errors and to 135 # make it clear that this is a class attriute 136 self.program_schedule_df = pd.DataFrame() 137 138 def update_model(self, variable_name: str, value: int | None) -> None: 139 """ 140 Updates internal parameter dictionaries from GUI inputs. 141 142 Checks if the `variable_name` corresponds to a key in `interval_vars` or 143 `exp_var_entries` and updates the dictionary value if the provided `value` 144 is not None. 145 146 Parameters 147 ---------- 148 - **variable_name** (*str*): The name identifier of the variable being updated (should match a dictionary key). 149 - **value** (*int | None*): The new integer value for the variable, or None if the input was invalid/empty. 150 """ 151 152 # if the variable name for the tkinter entry item that we are updating is 153 # in the exp_data interval variables dictionary, update that entry 154 # with the value in the tkinter variable 155 if value is not None: 156 if variable_name in self.interval_vars.keys(): 157 self.interval_vars[variable_name] = value 158 elif variable_name in self.exp_var_entries.keys(): 159 # update other var types here 160 self.exp_var_entries[variable_name] = value 161 162 def get_default_value(self, variable_name: str) -> int: 163 """ 164 Retrieves the current value associated with a parameter name. 165 166 Searches `interval_vars` and `exp_var_entries` for the `variable_name`. 167 Used primarily to populate GUI fields with their initial/default values. 168 169 Parameters 170 ---------- 171 - **variable_name** (*str*): The name identifier of the parameter whose value is requested. 172 173 Returns 174 ------- 175 - *int*: The integer value associated with `variable_name` in the dictionaries, or -1 if the name is not found. 176 """ 177 if variable_name in self.interval_vars.keys(): 178 return self.interval_vars[variable_name] 179 elif variable_name in self.exp_var_entries.keys(): 180 # update other var types here 181 return self.exp_var_entries[variable_name] 182 183 # couldn't find the value, return -1 as error code 184 return -1 185 186 def generate_schedule(self) -> bool: 187 """ 188 Generates the complete pseudo-randomized experimental schedule. 189 190 Calculates the total number of trials based on stimuli count and block count. 191 Validates the number of stimuli. Calls helper methods: 192 - `create_random_intervals`() to determine ITI, TTC, Sample times per trial. 193 - `create_trial_blocks`() to define unique stimuli pairs that must occur per block. 194 - `generate_pairs`() to create the trial-by-trial stimuli sequence. 195 - `build_frame`() to assemble the final `program_schedule_df`. 196 197 Returns 198 ------- 199 - *bool*: `True` if schedule generation was successful, `False` if an error occurred (e.g., invalid number of stimuli). 200 """ 201 try: 202 # set number of trials based on number of stimuli, and number of trial blocks set by user 203 num_stimuli = self.exp_var_entries["Num Stimuli"] 204 205 if num_stimuli > 8 or num_stimuli < 2 or num_stimuli % 2 != 0: 206 GUIUtils.display_error( 207 "NUMBER OF STIMULI EXCEEDS CURRENT MAXIMUM", 208 "Program is currently configured for a minumim of 2 and maximum of 8 TOTAL vavles. You must have an even number of valves. If more are desired, program configuration must be modified.", 209 ) 210 return False 211 212 num_trial_blocks = self.exp_var_entries["Num Trial Blocks"] 213 214 self.exp_var_entries["Num Trials"] = (num_stimuli // 2) * num_trial_blocks 215 216 self.create_random_intervals() 217 218 pairs = self.create_trial_blocks() 219 220 # stimuli_1 & stimuli_2 are lists that hold the stimuli to be introduced for each trial on their respective side 221 stimuli_side_one, stimuli_side_two = self.generate_pairs(pairs) 222 223 # args needed -> stimuli_1, stimuli_2 224 # these are lists of stimuli for each trial, for each side respectivelyc:w 225 self.build_frame( 226 stimuli_side_one, 227 stimuli_side_two, 228 ) 229 return True 230 231 except Exception as e: 232 logger.error(f"Error generating program schedule {e}.") 233 return False 234 235 def create_random_intervals(self) -> None: 236 """ 237 Calculates randomized ITI, TTC, and Sample intervals for all trials. 238 239 For each interval type (ITI, TTC, Sample): 240 - Retrieves the base duration and random variation range from `interval_vars`. 241 - Creates a base array repeating the base duration for the total number of trials. 242 - If random variation is specified, generates an array of random integers within the +/- range. 243 - Adds the random variation array to the base array. 244 - Stores the resulting final interval array in the corresponding class attribute (`ITI_intervals_final`, etc.). 245 246 Raises 247 ------ 248 - Propagates NumPy errors (e.g., during random number generation or array addition). 249 - *KeyError*: If expected keys are missing from `interval_vars`. 250 """ 251 try: 252 num_trials = self.exp_var_entries["Num Trials"] 253 254 final_intervals = [None, None, None] 255 256 interval_keys = ["ITI_var", "TTC_var", "sample_var"] 257 258 random_interval_keys = [ 259 "ITI_random_entry", 260 "TTC_random_entry", 261 "sample_random_entry", 262 ] 263 264 # for each type of interval (iti, ttc, sample), generate final interval times 265 for i in range(len(interval_keys)): 266 base_val = self.interval_vars[interval_keys[i]] 267 # this makes an array that is n=num_trials length of base_val 268 base_intervals = np.repeat(base_val, num_trials) 269 270 # get plus minus value for this interval type 271 random_var = self.interval_vars[random_interval_keys[i]] 272 273 if random_var == 0: 274 # if there is no value to add/subtract randomly, continue on 275 final_intervals[i] = base_intervals 276 277 else: 278 # otherwise make num_trials amount of random integers and add them to the final_intervals 279 random_interval_arr = np.random.randint( 280 -random_var, random_var, num_trials 281 ) 282 283 final_intervals[i] = np.add(base_intervals, random_interval_arr) 284 285 # assign results for respective types to the model for storage and later use 286 self.ITI_intervals_final = final_intervals[0] 287 self.TTC_intervals_final = final_intervals[1] 288 self.sample_intervals_final = final_intervals[2] 289 290 logger.info("Random intervals created.") 291 except Exception as e: 292 logger.error(f"Error creating random intervals: {e}") 293 raise 294 295 def calculate_max_runtime(self) -> tuple[int, int]: 296 """ 297 Estimates the maximum possible experiment runtime in minutes and seconds. 298 299 Sums all generated interval durations (ITI, TTC, Sample) across all trials. 300 Converts the total milliseconds to seconds, then uses `convert_seconds_to_minutes_seconds`. 301 302 Returns 303 ------- 304 - *tuple[int, int]*: A tuple containing (estimated_max_minutes, estimated_max_seconds). 305 """ 306 max_time = ( 307 sum(self.ITI_intervals_final) 308 + sum(self.TTC_intervals_final) 309 + sum(self.sample_intervals_final) 310 ) 311 minutes, seconds = self.convert_seconds_to_minutes_seconds(max_time / 1000) 312 return (minutes, seconds) 313 314 def create_trial_blocks(self) -> List[tuple[str, str]]: 315 """ 316 Determines the unique stimuli pairings within a single trial block. 317 318 Calculates the number of pairs per block (`block_sz = num_stimuli / 2`). 319 Iterates from `i = 0` to `block_sz - 1`. For each `i`, finds the 320 corresponding paired valve index using `get_paired_index`. Appends the 321 tuple of (stimulus_name_at_i, stimulus_name_at_paired_index) to a list. 322 323 Returns 324 ------- 325 - *List[tuple]*: A list of tuples, where each tuple represents a unique stimuli pairing (e.g., `[('Odor A', 'Odor B'), ('Odor C', 'Odor D')]`). 326 327 Raises 328 ------ 329 - Propagates errors from `get_paired_index`. 330 - *KeyError*: If expected keys are missing from `exp_var_entries`. 331 - *IndexError*: If calculated indices are out of bounds for `stimuli_data.stimuli_vars`. 332 """ 333 try: 334 # creating pairs list which stores all possible pairs 335 pairs = [] 336 num_stimuli = self.exp_var_entries["Num Stimuli"] 337 block_sz = num_stimuli // 2 338 339 stimuli_names = list(self.stimuli_data.stimuli_vars.values()) 340 341 for i in range(block_sz): 342 pair_index = self.get_paired_index(i, num_stimuli) 343 pairs.append((stimuli_names[i], stimuli_names[pair_index])) 344 345 return pairs 346 except Exception as e: 347 logger.error(f"Error creating trial blocks: {e}") 348 raise 349 350 def generate_pairs(self, pairs: list[tuple[str, str]]) -> Tuple[list, list]: 351 """ 352 Generates the full, pseudo-randomized sequence of stimuli pairs for all trials. 353 354 Repeats the following for the number of trial blocks specified: 355 - Takes the list of unique `pairs` generated by `create_trial_blocks` that must be included in each block. 356 - Shuffles this list randomly (`np.random.shuffle`). 357 - Extends a master list `pseudo_random_lineup` (adds new list to that list) with the shuffled block. 358 After creating the full sequence, separates it into two lists: one for the 359 stimulus presented on side one each trial, and one for side two. 360 361 Parameters 362 ---------- 363 - **pairs** (*List[tuple[str, str]]*): The list of unique stimuli pairings defining one block, generated by `create_trial_blocks`. 364 365 Returns 366 ------- 367 - *Tuple[list, list]*: A tuple containing two lists: 368 - `stimulus_1`: List of stimuli names for Port 1 for each trial. 369 - `stimulus_2`: List of stimuli names for Port 2 for each trial. 370 371 Raises 372 ------ 373 - Propagates errors from `np.random.shuffle`. 374 """ 375 try: 376 pseudo_random_lineup: List[tuple] = [] 377 378 stimulus_1: List[str] = [] 379 stimulus_2: List[str] = [] 380 381 for _ in range(self.exp_var_entries["Num Trial Blocks"]): 382 pairs_copy = [tuple(pair) for pair in pairs] 383 np.random.shuffle(pairs_copy) 384 pseudo_random_lineup.extend(pairs_copy) 385 386 for pair in pseudo_random_lineup: 387 stimulus_1.append(pair[0]) 388 stimulus_2.append(pair[1]) 389 390 return stimulus_1, stimulus_2 391 except Exception as e: 392 logger.error(f"Error generating pairs: {e}") 393 raise 394 395 def build_frame( 396 self, 397 stimuli_1: list[str], 398 stimuli_2: list[str], 399 ) -> None: 400 """ 401 Constructs the main `program_schedule_df` pandas DataFrame. 402 403 Uses the generated stimuli sequences (`stimuli_1`, `stimuli_2`) and the 404 calculated interval arrays (`ITI_intervals_final`, etc.) to build the 405 DataFrame. Includes columns for trial block number, trial number, stimuli 406 per port, calculated intervals, and placeholders (NaN) for results columns 407 like lick counts and actual TTC duration. 408 409 Parameters 410 ---------- 411 - **stimuli_1** (*list*): List of stimuli names for Port 1, one per trial. 412 - **stimuli_2** (*list*): List of stimuli names for Port 2, one per trial. 413 414 Raises 415 ------ 416 - Propagates pandas errors during DataFrame creation. 417 - *ValueError*: If the lengths of input lists/arrays do not match the expected number of trials. 418 """ 419 num_stimuli = self.exp_var_entries["Num Stimuli"] 420 num_trials = self.exp_var_entries["Num Trials"] 421 num_trial_blocks = self.exp_var_entries["Num Trial Blocks"] 422 423 # each block must contain each PAIR -> num pairs = num_stim / 2 424 block_size = int(num_stimuli / 2) 425 try: 426 data = { 427 "Trial Block": np.repeat( 428 range(1, num_trial_blocks + 1), 429 block_size, 430 ), 431 "Trial Number": np.repeat(range(1, num_trials + 1), 1), 432 "Port 1": stimuli_1, 433 "Port 2": stimuli_2, 434 "Port 1 Licks": np.full(num_trials, np.nan), 435 "Port 2 Licks": np.full(num_trials, np.nan), 436 "ITI": self.ITI_intervals_final, 437 "TTC": self.TTC_intervals_final, 438 "SAMPLE": self.sample_intervals_final, 439 "TTC Actual": np.full(num_trials, np.nan), 440 } 441 442 self.program_schedule_df = pd.DataFrame(data) 443 444 logger.info("Initialized stimuli dataframe.") 445 except Exception as e: 446 logger.debug(f"Error Building Stimuli Frame: {e}.") 447 raise 448 449 def save_all_data(self): 450 """ 451 Saves the experiment schedule and detailed event log DataFrames to Excel files. 452 453 Iterates through a dictionary mapping descriptive names to the DataFrame 454 references (`program_schedule_df`, `event_data.event_dataframe`) and calls 455 the static `save_df_to_xlsx` method for each. 456 """ 457 dataframes = { 458 "Experiment Schedule": self.program_schedule_df, 459 "Detailed Event Log Data": self.event_data.event_dataframe, 460 } 461 462 for name, df_reference in dataframes.items(): 463 self.save_df_to_xlsx(name, df_reference) 464 465 @staticmethod 466 def get_paired_index(i: int, num_stimuli: int) -> int | None: 467 """ 468 Calculates the 0-indexed valve number for the stimulus for side two based on give index (i) for side one. 469 470 Based on the total number of stimuli (`num_stimuli`), determines the index 471 of the valve paired with the valve at index `i`. Handles cases for 2, 4, or 8 total stimuli. 472 473 Parameters 474 ---------- 475 - **i** (*int*): The 0-indexed number of the valve on one side. 476 - **num_stimuli** (*int*): The total number of stimuli/valves being used (must be 2, 4, or 8). 477 478 Returns 479 ------- 480 - *int | None*: The 0-indexed number of the paired valve, or None if `num_stimuli` is not 2, 4, or 8. 481 """ 482 match num_stimuli: 483 case 2: 484 # if num stim is 2, we are only running the same stimulus on both sides, 485 # we will only see i=0 here, and its paired with its sibling i=4 486 return i + 4 487 case 4: 488 # if num stim is 4, we are only running 2 stimuli per side, 489 # we will see i=0 and i =1 here, paired with siblings i=5 & i=4 respectively 490 match i: 491 case 0: # valve 1 492 return i + 5 # (5) / valve 6 493 case 1: # valve 2 494 return i + 3 # (4) / valve 5 495 case 8: 496 match i: 497 case 0: 498 return i + 5 499 case 1: 500 return i + 3 501 case 2: 502 return i + 5 503 case 3: 504 return i + 3 505 case _: 506 return None 507 508 @staticmethod 509 def save_df_to_xlsx(name: str, dataframe: pd.DataFrame) -> None: 510 """ 511 Saves a pandas DataFrame to an Excel (.xlsx) file using a file save dialog. 512 513 Prompts the user with a standard 'Save As' dialog window. Suggests a default 514 filename including the provided `name` and the current date. Sets the default 515 directory to '../data_outputs'. If the user confirms a filename, saves the 516 DataFrame; otherwise, logs that the save was cancelled. 517 518 Parameters 519 ---------- 520 - **name** (*str*): A descriptive name used in the default filename (e.g., "Experiment Schedule"). 521 - **dataframe** (*pd.DataFrame*): The pandas DataFrame to be saved. 522 523 Raises 524 ------ 525 - Propagates errors from `filedialog.asksaveasfilename` or `dataframe.to_excel`. 526 """ 527 try: 528 file_name = filedialog.asksaveasfilename( 529 defaultextension=".xlsx", 530 filetypes=[("Excel Files", "*.xlsx")], 531 initialfile=f"{name}, {datetime.date.today()}", 532 initialdir=Path(__file__).parent.parent.parent.resolve() 533 / "data_outputs", 534 title="Save Excel file", 535 ) 536 537 if file_name: 538 dataframe.to_excel(file_name, index=False) 539 else: 540 logger.info("User cancelled saving the stimuli dataframe.") 541 542 logger.info("Data saved to xlsx files.") 543 except Exception as e: 544 logger.error(f"Error saving data to xlsx: {e}") 545 raise 546 547 @staticmethod 548 def convert_seconds_to_minutes_seconds(total_seconds: int) -> tuple[int, int]: 549 """ 550 Converts a total number of seconds into whole minutes and remaining seconds. 551 552 Parameters 553 ---------- 554 - **total_seconds** (*int*): The total duration in seconds. 555 556 Returns 557 ------- 558 - *tuple[int, int]*: A tuple containing (minutes, seconds). 559 560 Raises 561 ------ 562 - Propagates `TypeError` if `total_seconds` is not a number. 563 """ 564 try: 565 # Integer division to get whole minutes 566 minutes = total_seconds // 60 567 # Modulo to get remaining seconds 568 seconds = total_seconds % 60 569 return minutes, seconds 570 except Exception as e: 571 logger.error(f"Error converting seconds to minutes and seconds: {e}") 572 raise
29class ExperimentProcessData: 30 """ 31 Central data management class for the behavioral experiment. 32 33 This class acts as the primary container and manager for all data related 34 to an ongoing experiment. It holds references to specialized data classes 35 (`EventData`, `StimuliData`, `ArduinoData`) and maintains experiment-wide 36 state variables, timing intervals, parameter entries (num_stimuli for example), and the generated 37 program schedule (`program_schedule_df`). It performs the generation 38 of the trial schedule and state intervals based on user inputs. 39 40 Attributes 41 ---------- 42 - **`start_time`** (*float*): Timestamp marking the absolute start of the program run. 43 - **`trial_start_time`** (*float*): Timestamp marking the start of the current trial. 44 - **`state_start_time`** (*float*): Timestamp marking the entry into the current FSM state. 45 - **`current_trial_number`** (*int*): The 1-indexed number of the trial currently in progress or about to start. 46 - **`event_data`** (*EventData*): Instance managing the DataFrame of recorded lick/motor events. 47 - **`stimuli_data`** (*StimuliData*): Instance managing stimuli names and related information. 48 - **`arduino_data`** (*ArduinoData*): Instance managing Arduino communication data (durations, schedule indices) and processing incoming Arduino messages. 49 - **`ITI_intervals_final`** (*npt.NDArray[np.int64] | None*): Numpy array holding the calculated Inter-Trial Interval duration (ms) for each trial. 50 None until schedule generated. 51 - **`TTC_intervals_final`** (*npt.NDArray[np.int64] | None*): Numpy array holding the calculated Time-To-Contact duration (ms) for each trial. 52 None until schedule generated. 53 - **`sample_intervals_final`** (*npt.NDArray[np.int64] | None*): Numpy array holding the calculated Sample period duration (ms) for each trial. 54 None until schedule generated. 55 - **`TTC_LICK_THRESHOLD`** (*int*): The number of licks required during the TTC state to trigger an early transition to the SAMPLE state. 56 - **`interval_vars`** (*dict[str, int]*): Dictionary storing base and random variation values (ms) for timing intervals (ITI, TTC, Sample), sourced from GUI entries. 57 - **`exp_var_entries`** (*dict[str, int]*): Dictionary storing core experiment parameters (Num Trial Blocks, Num Stimuli), sourced from GUI entries. 58 `Num Trials` is calculated based on these other values. 59 - **`program_schedule_df`** (*pd.DataFrame*): Pandas DataFrame holding the generated trial-by-trial schedule, including stimuli presentation, calculated intervals, 60 and placeholders for results. Initialized empty. 61 62 Methods 63 ------- 64 - `update_model`(...) 65 Updates internal parameter dictionaries (`interval_vars`, `exp_var_entries`) based on changes from GUI input fields (tkinter vars). 66 - `get_default_value`(...) 67 Retrieves the default or current value for a given parameter name from internal dictionaries, used to populate GUI fields initially. 68 - `generate_schedule`() 69 Performs the creation of the entire experimental schedule, including intervals and stimuli pairings. Returns status. 70 - `create_random_intervals`() 71 Calculates the final ITI, TTC, and Sample intervals for each trial based on base values and randomization ranges. 72 - `calculate_max_runtime`() 73 Estimates the maximum possible runtime based on the sum of all generated intervals. 74 - `create_trial_blocks`() 75 Determines the unique pairs of stimuli presented within a single block based on the number of stimuli. 76 - `generate_pairs`(...) 77 Generates the pseudo-randomized sequence of stimuli pairs across all trials and blocks. 78 - `build_frame`(...) 79 Constructs the `program_schedule_df` DataFrame using the generated stimuli sequences and intervals. 80 - `save_all_data`() 81 Initiates the process of saving the schedule and event log DataFrames to Excel files. 82 - `get_paired_index`(...) 83 Static method to determine the 0-indexed valve number on the opposite side corresponding to a given valve index on side one. 84 - `save_df_to_xlsx`(...) 85 Static method to handle saving a pandas DataFrame to an .xlsx file using a file dialog. 86 - `convert_seconds_to_minutes_seconds`(...) 87 Static method to convert a total number of seconds into minutes and remaining seconds. 88 """ 89 90 def __init__(self): 91 """ 92 Initializes the ExperimentProcessData central data hub. 93 94 Sets initial timestamps to 0.0, starts `current_trial_number` at 1. 95 Instantiates `EventData`, `StimuliData`, and `ArduinoData` (passing self reference). 96 Initializes interval arrays (`ITI_intervals_final`, etc.) to None. 97 Sets the `TTC_LICK_THRESHOLD`. 98 Defines default values for `interval_vars` and `exp_var_entries`. 99 Initializes `program_schedule_df` as an empty DataFrame. 100 """ 101 102 self.start_time: float = 0.0 103 self.trial_start_time: float = 0.0 104 self.state_start_time: float = 0.0 105 106 # this number is centralized here so that all state classes can access and update it easily without 107 # passing it through to each state every time a state change occurs 108 self.current_trial_number: int = 1 109 110 self.event_data = EventData() 111 self.stimuli_data = StimuliData() 112 self.arduino_data = ArduinoData(self) 113 114 self.ITI_intervals_final = None 115 self.TTC_intervals_final = None 116 self.sample_intervals_final = None 117 118 # this constant should be used in arduino_data to say how many licks moves to sample 119 self.TTC_LICK_THRESHOLD: int = 3 120 121 self.interval_vars: dict[str, int] = { 122 "ITI_var": 30000, 123 "TTC_var": 20000, 124 "sample_var": 15000, 125 "ITI_random_entry": 0, 126 "TTC_random_entry": 5000, 127 "sample_random_entry": 5000, 128 } 129 self.exp_var_entries: dict[str, int] = { 130 "Num Trial Blocks": 10, 131 "Num Stimuli": 4, 132 "Num Trials": 0, 133 } 134 135 # include an initial definition of program_schedule_df here as a blank df to avoid type errors and to 136 # make it clear that this is a class attriute 137 self.program_schedule_df = pd.DataFrame() 138 139 def update_model(self, variable_name: str, value: int | None) -> None: 140 """ 141 Updates internal parameter dictionaries from GUI inputs. 142 143 Checks if the `variable_name` corresponds to a key in `interval_vars` or 144 `exp_var_entries` and updates the dictionary value if the provided `value` 145 is not None. 146 147 Parameters 148 ---------- 149 - **variable_name** (*str*): The name identifier of the variable being updated (should match a dictionary key). 150 - **value** (*int | None*): The new integer value for the variable, or None if the input was invalid/empty. 151 """ 152 153 # if the variable name for the tkinter entry item that we are updating is 154 # in the exp_data interval variables dictionary, update that entry 155 # with the value in the tkinter variable 156 if value is not None: 157 if variable_name in self.interval_vars.keys(): 158 self.interval_vars[variable_name] = value 159 elif variable_name in self.exp_var_entries.keys(): 160 # update other var types here 161 self.exp_var_entries[variable_name] = value 162 163 def get_default_value(self, variable_name: str) -> int: 164 """ 165 Retrieves the current value associated with a parameter name. 166 167 Searches `interval_vars` and `exp_var_entries` for the `variable_name`. 168 Used primarily to populate GUI fields with their initial/default values. 169 170 Parameters 171 ---------- 172 - **variable_name** (*str*): The name identifier of the parameter whose value is requested. 173 174 Returns 175 ------- 176 - *int*: The integer value associated with `variable_name` in the dictionaries, or -1 if the name is not found. 177 """ 178 if variable_name in self.interval_vars.keys(): 179 return self.interval_vars[variable_name] 180 elif variable_name in self.exp_var_entries.keys(): 181 # update other var types here 182 return self.exp_var_entries[variable_name] 183 184 # couldn't find the value, return -1 as error code 185 return -1 186 187 def generate_schedule(self) -> bool: 188 """ 189 Generates the complete pseudo-randomized experimental schedule. 190 191 Calculates the total number of trials based on stimuli count and block count. 192 Validates the number of stimuli. Calls helper methods: 193 - `create_random_intervals`() to determine ITI, TTC, Sample times per trial. 194 - `create_trial_blocks`() to define unique stimuli pairs that must occur per block. 195 - `generate_pairs`() to create the trial-by-trial stimuli sequence. 196 - `build_frame`() to assemble the final `program_schedule_df`. 197 198 Returns 199 ------- 200 - *bool*: `True` if schedule generation was successful, `False` if an error occurred (e.g., invalid number of stimuli). 201 """ 202 try: 203 # set number of trials based on number of stimuli, and number of trial blocks set by user 204 num_stimuli = self.exp_var_entries["Num Stimuli"] 205 206 if num_stimuli > 8 or num_stimuli < 2 or num_stimuli % 2 != 0: 207 GUIUtils.display_error( 208 "NUMBER OF STIMULI EXCEEDS CURRENT MAXIMUM", 209 "Program is currently configured for a minumim of 2 and maximum of 8 TOTAL vavles. You must have an even number of valves. If more are desired, program configuration must be modified.", 210 ) 211 return False 212 213 num_trial_blocks = self.exp_var_entries["Num Trial Blocks"] 214 215 self.exp_var_entries["Num Trials"] = (num_stimuli // 2) * num_trial_blocks 216 217 self.create_random_intervals() 218 219 pairs = self.create_trial_blocks() 220 221 # stimuli_1 & stimuli_2 are lists that hold the stimuli to be introduced for each trial on their respective side 222 stimuli_side_one, stimuli_side_two = self.generate_pairs(pairs) 223 224 # args needed -> stimuli_1, stimuli_2 225 # these are lists of stimuli for each trial, for each side respectivelyc:w 226 self.build_frame( 227 stimuli_side_one, 228 stimuli_side_two, 229 ) 230 return True 231 232 except Exception as e: 233 logger.error(f"Error generating program schedule {e}.") 234 return False 235 236 def create_random_intervals(self) -> None: 237 """ 238 Calculates randomized ITI, TTC, and Sample intervals for all trials. 239 240 For each interval type (ITI, TTC, Sample): 241 - Retrieves the base duration and random variation range from `interval_vars`. 242 - Creates a base array repeating the base duration for the total number of trials. 243 - If random variation is specified, generates an array of random integers within the +/- range. 244 - Adds the random variation array to the base array. 245 - Stores the resulting final interval array in the corresponding class attribute (`ITI_intervals_final`, etc.). 246 247 Raises 248 ------ 249 - Propagates NumPy errors (e.g., during random number generation or array addition). 250 - *KeyError*: If expected keys are missing from `interval_vars`. 251 """ 252 try: 253 num_trials = self.exp_var_entries["Num Trials"] 254 255 final_intervals = [None, None, None] 256 257 interval_keys = ["ITI_var", "TTC_var", "sample_var"] 258 259 random_interval_keys = [ 260 "ITI_random_entry", 261 "TTC_random_entry", 262 "sample_random_entry", 263 ] 264 265 # for each type of interval (iti, ttc, sample), generate final interval times 266 for i in range(len(interval_keys)): 267 base_val = self.interval_vars[interval_keys[i]] 268 # this makes an array that is n=num_trials length of base_val 269 base_intervals = np.repeat(base_val, num_trials) 270 271 # get plus minus value for this interval type 272 random_var = self.interval_vars[random_interval_keys[i]] 273 274 if random_var == 0: 275 # if there is no value to add/subtract randomly, continue on 276 final_intervals[i] = base_intervals 277 278 else: 279 # otherwise make num_trials amount of random integers and add them to the final_intervals 280 random_interval_arr = np.random.randint( 281 -random_var, random_var, num_trials 282 ) 283 284 final_intervals[i] = np.add(base_intervals, random_interval_arr) 285 286 # assign results for respective types to the model for storage and later use 287 self.ITI_intervals_final = final_intervals[0] 288 self.TTC_intervals_final = final_intervals[1] 289 self.sample_intervals_final = final_intervals[2] 290 291 logger.info("Random intervals created.") 292 except Exception as e: 293 logger.error(f"Error creating random intervals: {e}") 294 raise 295 296 def calculate_max_runtime(self) -> tuple[int, int]: 297 """ 298 Estimates the maximum possible experiment runtime in minutes and seconds. 299 300 Sums all generated interval durations (ITI, TTC, Sample) across all trials. 301 Converts the total milliseconds to seconds, then uses `convert_seconds_to_minutes_seconds`. 302 303 Returns 304 ------- 305 - *tuple[int, int]*: A tuple containing (estimated_max_minutes, estimated_max_seconds). 306 """ 307 max_time = ( 308 sum(self.ITI_intervals_final) 309 + sum(self.TTC_intervals_final) 310 + sum(self.sample_intervals_final) 311 ) 312 minutes, seconds = self.convert_seconds_to_minutes_seconds(max_time / 1000) 313 return (minutes, seconds) 314 315 def create_trial_blocks(self) -> List[tuple[str, str]]: 316 """ 317 Determines the unique stimuli pairings within a single trial block. 318 319 Calculates the number of pairs per block (`block_sz = num_stimuli / 2`). 320 Iterates from `i = 0` to `block_sz - 1`. For each `i`, finds the 321 corresponding paired valve index using `get_paired_index`. Appends the 322 tuple of (stimulus_name_at_i, stimulus_name_at_paired_index) to a list. 323 324 Returns 325 ------- 326 - *List[tuple]*: A list of tuples, where each tuple represents a unique stimuli pairing (e.g., `[('Odor A', 'Odor B'), ('Odor C', 'Odor D')]`). 327 328 Raises 329 ------ 330 - Propagates errors from `get_paired_index`. 331 - *KeyError*: If expected keys are missing from `exp_var_entries`. 332 - *IndexError*: If calculated indices are out of bounds for `stimuli_data.stimuli_vars`. 333 """ 334 try: 335 # creating pairs list which stores all possible pairs 336 pairs = [] 337 num_stimuli = self.exp_var_entries["Num Stimuli"] 338 block_sz = num_stimuli // 2 339 340 stimuli_names = list(self.stimuli_data.stimuli_vars.values()) 341 342 for i in range(block_sz): 343 pair_index = self.get_paired_index(i, num_stimuli) 344 pairs.append((stimuli_names[i], stimuli_names[pair_index])) 345 346 return pairs 347 except Exception as e: 348 logger.error(f"Error creating trial blocks: {e}") 349 raise 350 351 def generate_pairs(self, pairs: list[tuple[str, str]]) -> Tuple[list, list]: 352 """ 353 Generates the full, pseudo-randomized sequence of stimuli pairs for all trials. 354 355 Repeats the following for the number of trial blocks specified: 356 - Takes the list of unique `pairs` generated by `create_trial_blocks` that must be included in each block. 357 - Shuffles this list randomly (`np.random.shuffle`). 358 - Extends a master list `pseudo_random_lineup` (adds new list to that list) with the shuffled block. 359 After creating the full sequence, separates it into two lists: one for the 360 stimulus presented on side one each trial, and one for side two. 361 362 Parameters 363 ---------- 364 - **pairs** (*List[tuple[str, str]]*): The list of unique stimuli pairings defining one block, generated by `create_trial_blocks`. 365 366 Returns 367 ------- 368 - *Tuple[list, list]*: A tuple containing two lists: 369 - `stimulus_1`: List of stimuli names for Port 1 for each trial. 370 - `stimulus_2`: List of stimuli names for Port 2 for each trial. 371 372 Raises 373 ------ 374 - Propagates errors from `np.random.shuffle`. 375 """ 376 try: 377 pseudo_random_lineup: List[tuple] = [] 378 379 stimulus_1: List[str] = [] 380 stimulus_2: List[str] = [] 381 382 for _ in range(self.exp_var_entries["Num Trial Blocks"]): 383 pairs_copy = [tuple(pair) for pair in pairs] 384 np.random.shuffle(pairs_copy) 385 pseudo_random_lineup.extend(pairs_copy) 386 387 for pair in pseudo_random_lineup: 388 stimulus_1.append(pair[0]) 389 stimulus_2.append(pair[1]) 390 391 return stimulus_1, stimulus_2 392 except Exception as e: 393 logger.error(f"Error generating pairs: {e}") 394 raise 395 396 def build_frame( 397 self, 398 stimuli_1: list[str], 399 stimuli_2: list[str], 400 ) -> None: 401 """ 402 Constructs the main `program_schedule_df` pandas DataFrame. 403 404 Uses the generated stimuli sequences (`stimuli_1`, `stimuli_2`) and the 405 calculated interval arrays (`ITI_intervals_final`, etc.) to build the 406 DataFrame. Includes columns for trial block number, trial number, stimuli 407 per port, calculated intervals, and placeholders (NaN) for results columns 408 like lick counts and actual TTC duration. 409 410 Parameters 411 ---------- 412 - **stimuli_1** (*list*): List of stimuli names for Port 1, one per trial. 413 - **stimuli_2** (*list*): List of stimuli names for Port 2, one per trial. 414 415 Raises 416 ------ 417 - Propagates pandas errors during DataFrame creation. 418 - *ValueError*: If the lengths of input lists/arrays do not match the expected number of trials. 419 """ 420 num_stimuli = self.exp_var_entries["Num Stimuli"] 421 num_trials = self.exp_var_entries["Num Trials"] 422 num_trial_blocks = self.exp_var_entries["Num Trial Blocks"] 423 424 # each block must contain each PAIR -> num pairs = num_stim / 2 425 block_size = int(num_stimuli / 2) 426 try: 427 data = { 428 "Trial Block": np.repeat( 429 range(1, num_trial_blocks + 1), 430 block_size, 431 ), 432 "Trial Number": np.repeat(range(1, num_trials + 1), 1), 433 "Port 1": stimuli_1, 434 "Port 2": stimuli_2, 435 "Port 1 Licks": np.full(num_trials, np.nan), 436 "Port 2 Licks": np.full(num_trials, np.nan), 437 "ITI": self.ITI_intervals_final, 438 "TTC": self.TTC_intervals_final, 439 "SAMPLE": self.sample_intervals_final, 440 "TTC Actual": np.full(num_trials, np.nan), 441 } 442 443 self.program_schedule_df = pd.DataFrame(data) 444 445 logger.info("Initialized stimuli dataframe.") 446 except Exception as e: 447 logger.debug(f"Error Building Stimuli Frame: {e}.") 448 raise 449 450 def save_all_data(self): 451 """ 452 Saves the experiment schedule and detailed event log DataFrames to Excel files. 453 454 Iterates through a dictionary mapping descriptive names to the DataFrame 455 references (`program_schedule_df`, `event_data.event_dataframe`) and calls 456 the static `save_df_to_xlsx` method for each. 457 """ 458 dataframes = { 459 "Experiment Schedule": self.program_schedule_df, 460 "Detailed Event Log Data": self.event_data.event_dataframe, 461 } 462 463 for name, df_reference in dataframes.items(): 464 self.save_df_to_xlsx(name, df_reference) 465 466 @staticmethod 467 def get_paired_index(i: int, num_stimuli: int) -> int | None: 468 """ 469 Calculates the 0-indexed valve number for the stimulus for side two based on give index (i) for side one. 470 471 Based on the total number of stimuli (`num_stimuli`), determines the index 472 of the valve paired with the valve at index `i`. Handles cases for 2, 4, or 8 total stimuli. 473 474 Parameters 475 ---------- 476 - **i** (*int*): The 0-indexed number of the valve on one side. 477 - **num_stimuli** (*int*): The total number of stimuli/valves being used (must be 2, 4, or 8). 478 479 Returns 480 ------- 481 - *int | None*: The 0-indexed number of the paired valve, or None if `num_stimuli` is not 2, 4, or 8. 482 """ 483 match num_stimuli: 484 case 2: 485 # if num stim is 2, we are only running the same stimulus on both sides, 486 # we will only see i=0 here, and its paired with its sibling i=4 487 return i + 4 488 case 4: 489 # if num stim is 4, we are only running 2 stimuli per side, 490 # we will see i=0 and i =1 here, paired with siblings i=5 & i=4 respectively 491 match i: 492 case 0: # valve 1 493 return i + 5 # (5) / valve 6 494 case 1: # valve 2 495 return i + 3 # (4) / valve 5 496 case 8: 497 match i: 498 case 0: 499 return i + 5 500 case 1: 501 return i + 3 502 case 2: 503 return i + 5 504 case 3: 505 return i + 3 506 case _: 507 return None 508 509 @staticmethod 510 def save_df_to_xlsx(name: str, dataframe: pd.DataFrame) -> None: 511 """ 512 Saves a pandas DataFrame to an Excel (.xlsx) file using a file save dialog. 513 514 Prompts the user with a standard 'Save As' dialog window. Suggests a default 515 filename including the provided `name` and the current date. Sets the default 516 directory to '../data_outputs'. If the user confirms a filename, saves the 517 DataFrame; otherwise, logs that the save was cancelled. 518 519 Parameters 520 ---------- 521 - **name** (*str*): A descriptive name used in the default filename (e.g., "Experiment Schedule"). 522 - **dataframe** (*pd.DataFrame*): The pandas DataFrame to be saved. 523 524 Raises 525 ------ 526 - Propagates errors from `filedialog.asksaveasfilename` or `dataframe.to_excel`. 527 """ 528 try: 529 file_name = filedialog.asksaveasfilename( 530 defaultextension=".xlsx", 531 filetypes=[("Excel Files", "*.xlsx")], 532 initialfile=f"{name}, {datetime.date.today()}", 533 initialdir=Path(__file__).parent.parent.parent.resolve() 534 / "data_outputs", 535 title="Save Excel file", 536 ) 537 538 if file_name: 539 dataframe.to_excel(file_name, index=False) 540 else: 541 logger.info("User cancelled saving the stimuli dataframe.") 542 543 logger.info("Data saved to xlsx files.") 544 except Exception as e: 545 logger.error(f"Error saving data to xlsx: {e}") 546 raise 547 548 @staticmethod 549 def convert_seconds_to_minutes_seconds(total_seconds: int) -> tuple[int, int]: 550 """ 551 Converts a total number of seconds into whole minutes and remaining seconds. 552 553 Parameters 554 ---------- 555 - **total_seconds** (*int*): The total duration in seconds. 556 557 Returns 558 ------- 559 - *tuple[int, int]*: A tuple containing (minutes, seconds). 560 561 Raises 562 ------ 563 - Propagates `TypeError` if `total_seconds` is not a number. 564 """ 565 try: 566 # Integer division to get whole minutes 567 minutes = total_seconds // 60 568 # Modulo to get remaining seconds 569 seconds = total_seconds % 60 570 return minutes, seconds 571 except Exception as e: 572 logger.error(f"Error converting seconds to minutes and seconds: {e}") 573 raise
Central data management class for the behavioral experiment.
This class acts as the primary container and manager for all data related
to an ongoing experiment. It holds references to specialized data classes
(EventData
, StimuliData
, ArduinoData
) and maintains experiment-wide
state variables, timing intervals, parameter entries (num_stimuli for example), and the generated
program schedule (program_schedule_df
). It performs the generation
of the trial schedule and state intervals based on user inputs.
Attributes
start_time
(float): Timestamp marking the absolute start of the program run.trial_start_time
(float): Timestamp marking the start of the current trial.state_start_time
(float): Timestamp marking the entry into the current FSM state.current_trial_number
(int): The 1-indexed number of the trial currently in progress or about to start.event_data
(EventData): Instance managing the DataFrame of recorded lick/motor events.stimuli_data
(StimuliData): Instance managing stimuli names and related information.arduino_data
(ArduinoData): Instance managing Arduino communication data (durations, schedule indices) and processing incoming Arduino messages.ITI_intervals_final
(npt.NDArray[np.int64] | None): Numpy array holding the calculated Inter-Trial Interval duration (ms) for each trial. None until schedule generated.TTC_intervals_final
(npt.NDArray[np.int64] | None): Numpy array holding the calculated Time-To-Contact duration (ms) for each trial. None until schedule generated.sample_intervals_final
(npt.NDArray[np.int64] | None): Numpy array holding the calculated Sample period duration (ms) for each trial. None until schedule generated.TTC_LICK_THRESHOLD
(int): The number of licks required during the TTC state to trigger an early transition to the SAMPLE state.interval_vars
(dict[str, int]): Dictionary storing base and random variation values (ms) for timing intervals (ITI, TTC, Sample), sourced from GUI entries.exp_var_entries
(dict[str, int]): Dictionary storing core experiment parameters (Num Trial Blocks, Num Stimuli), sourced from GUI entries.Num Trials
is calculated based on these other values.program_schedule_df
(pd.DataFrame): Pandas DataFrame holding the generated trial-by-trial schedule, including stimuli presentation, calculated intervals, and placeholders for results. Initialized empty.
Methods
update_model
(...) Updates internal parameter dictionaries (interval_vars
,exp_var_entries
) based on changes from GUI input fields (tkinter vars).get_default_value
(...) Retrieves the default or current value for a given parameter name from internal dictionaries, used to populate GUI fields initially.generate_schedule
() Performs the creation of the entire experimental schedule, including intervals and stimuli pairings. Returns status.create_random_intervals
() Calculates the final ITI, TTC, and Sample intervals for each trial based on base values and randomization ranges.calculate_max_runtime
() Estimates the maximum possible runtime based on the sum of all generated intervals.create_trial_blocks
() Determines the unique pairs of stimuli presented within a single block based on the number of stimuli.generate_pairs
(...) Generates the pseudo-randomized sequence of stimuli pairs across all trials and blocks.build_frame
(...) Constructs theprogram_schedule_df
DataFrame using the generated stimuli sequences and intervals.save_all_data
() Initiates the process of saving the schedule and event log DataFrames to Excel files.get_paired_index
(...) Static method to determine the 0-indexed valve number on the opposite side corresponding to a given valve index on side one.save_df_to_xlsx
(...) Static method to handle saving a pandas DataFrame to an .xlsx file using a file dialog.convert_seconds_to_minutes_seconds
(...) Static method to convert a total number of seconds into minutes and remaining seconds.
90 def __init__(self): 91 """ 92 Initializes the ExperimentProcessData central data hub. 93 94 Sets initial timestamps to 0.0, starts `current_trial_number` at 1. 95 Instantiates `EventData`, `StimuliData`, and `ArduinoData` (passing self reference). 96 Initializes interval arrays (`ITI_intervals_final`, etc.) to None. 97 Sets the `TTC_LICK_THRESHOLD`. 98 Defines default values for `interval_vars` and `exp_var_entries`. 99 Initializes `program_schedule_df` as an empty DataFrame. 100 """ 101 102 self.start_time: float = 0.0 103 self.trial_start_time: float = 0.0 104 self.state_start_time: float = 0.0 105 106 # this number is centralized here so that all state classes can access and update it easily without 107 # passing it through to each state every time a state change occurs 108 self.current_trial_number: int = 1 109 110 self.event_data = EventData() 111 self.stimuli_data = StimuliData() 112 self.arduino_data = ArduinoData(self) 113 114 self.ITI_intervals_final = None 115 self.TTC_intervals_final = None 116 self.sample_intervals_final = None 117 118 # this constant should be used in arduino_data to say how many licks moves to sample 119 self.TTC_LICK_THRESHOLD: int = 3 120 121 self.interval_vars: dict[str, int] = { 122 "ITI_var": 30000, 123 "TTC_var": 20000, 124 "sample_var": 15000, 125 "ITI_random_entry": 0, 126 "TTC_random_entry": 5000, 127 "sample_random_entry": 5000, 128 } 129 self.exp_var_entries: dict[str, int] = { 130 "Num Trial Blocks": 10, 131 "Num Stimuli": 4, 132 "Num Trials": 0, 133 } 134 135 # include an initial definition of program_schedule_df here as a blank df to avoid type errors and to 136 # make it clear that this is a class attriute 137 self.program_schedule_df = pd.DataFrame()
Initializes the ExperimentProcessData central data hub.
Sets initial timestamps to 0.0, starts current_trial_number
at 1.
Instantiates EventData
, StimuliData
, and ArduinoData
(passing self reference).
Initializes interval arrays (ITI_intervals_final
, etc.) to None.
Sets the TTC_LICK_THRESHOLD
.
Defines default values for interval_vars
and exp_var_entries
.
Initializes program_schedule_df
as an empty DataFrame.
139 def update_model(self, variable_name: str, value: int | None) -> None: 140 """ 141 Updates internal parameter dictionaries from GUI inputs. 142 143 Checks if the `variable_name` corresponds to a key in `interval_vars` or 144 `exp_var_entries` and updates the dictionary value if the provided `value` 145 is not None. 146 147 Parameters 148 ---------- 149 - **variable_name** (*str*): The name identifier of the variable being updated (should match a dictionary key). 150 - **value** (*int | None*): The new integer value for the variable, or None if the input was invalid/empty. 151 """ 152 153 # if the variable name for the tkinter entry item that we are updating is 154 # in the exp_data interval variables dictionary, update that entry 155 # with the value in the tkinter variable 156 if value is not None: 157 if variable_name in self.interval_vars.keys(): 158 self.interval_vars[variable_name] = value 159 elif variable_name in self.exp_var_entries.keys(): 160 # update other var types here 161 self.exp_var_entries[variable_name] = value
Updates internal parameter dictionaries from GUI inputs.
Checks if the variable_name
corresponds to a key in interval_vars
or
exp_var_entries
and updates the dictionary value if the provided value
is not None.
Parameters
- variable_name (str): The name identifier of the variable being updated (should match a dictionary key).
- value (int | None): The new integer value for the variable, or None if the input was invalid/empty.
163 def get_default_value(self, variable_name: str) -> int: 164 """ 165 Retrieves the current value associated with a parameter name. 166 167 Searches `interval_vars` and `exp_var_entries` for the `variable_name`. 168 Used primarily to populate GUI fields with their initial/default values. 169 170 Parameters 171 ---------- 172 - **variable_name** (*str*): The name identifier of the parameter whose value is requested. 173 174 Returns 175 ------- 176 - *int*: The integer value associated with `variable_name` in the dictionaries, or -1 if the name is not found. 177 """ 178 if variable_name in self.interval_vars.keys(): 179 return self.interval_vars[variable_name] 180 elif variable_name in self.exp_var_entries.keys(): 181 # update other var types here 182 return self.exp_var_entries[variable_name] 183 184 # couldn't find the value, return -1 as error code 185 return -1
Retrieves the current value associated with a parameter name.
Searches interval_vars
and exp_var_entries
for the variable_name
.
Used primarily to populate GUI fields with their initial/default values.
Parameters
- variable_name (str): The name identifier of the parameter whose value is requested.
Returns
- int: The integer value associated with
variable_name
in the dictionaries, or -1 if the name is not found.
187 def generate_schedule(self) -> bool: 188 """ 189 Generates the complete pseudo-randomized experimental schedule. 190 191 Calculates the total number of trials based on stimuli count and block count. 192 Validates the number of stimuli. Calls helper methods: 193 - `create_random_intervals`() to determine ITI, TTC, Sample times per trial. 194 - `create_trial_blocks`() to define unique stimuli pairs that must occur per block. 195 - `generate_pairs`() to create the trial-by-trial stimuli sequence. 196 - `build_frame`() to assemble the final `program_schedule_df`. 197 198 Returns 199 ------- 200 - *bool*: `True` if schedule generation was successful, `False` if an error occurred (e.g., invalid number of stimuli). 201 """ 202 try: 203 # set number of trials based on number of stimuli, and number of trial blocks set by user 204 num_stimuli = self.exp_var_entries["Num Stimuli"] 205 206 if num_stimuli > 8 or num_stimuli < 2 or num_stimuli % 2 != 0: 207 GUIUtils.display_error( 208 "NUMBER OF STIMULI EXCEEDS CURRENT MAXIMUM", 209 "Program is currently configured for a minumim of 2 and maximum of 8 TOTAL vavles. You must have an even number of valves. If more are desired, program configuration must be modified.", 210 ) 211 return False 212 213 num_trial_blocks = self.exp_var_entries["Num Trial Blocks"] 214 215 self.exp_var_entries["Num Trials"] = (num_stimuli // 2) * num_trial_blocks 216 217 self.create_random_intervals() 218 219 pairs = self.create_trial_blocks() 220 221 # stimuli_1 & stimuli_2 are lists that hold the stimuli to be introduced for each trial on their respective side 222 stimuli_side_one, stimuli_side_two = self.generate_pairs(pairs) 223 224 # args needed -> stimuli_1, stimuli_2 225 # these are lists of stimuli for each trial, for each side respectivelyc:w 226 self.build_frame( 227 stimuli_side_one, 228 stimuli_side_two, 229 ) 230 return True 231 232 except Exception as e: 233 logger.error(f"Error generating program schedule {e}.") 234 return False
Generates the complete pseudo-randomized experimental schedule.
Calculates the total number of trials based on stimuli count and block count. Validates the number of stimuli. Calls helper methods:
create_random_intervals
() to determine ITI, TTC, Sample times per trial.create_trial_blocks
() to define unique stimuli pairs that must occur per block.generate_pairs
() to create the trial-by-trial stimuli sequence.build_frame
() to assemble the finalprogram_schedule_df
.
Returns
- bool:
True
if schedule generation was successful,False
if an error occurred (e.g., invalid number of stimuli).
236 def create_random_intervals(self) -> None: 237 """ 238 Calculates randomized ITI, TTC, and Sample intervals for all trials. 239 240 For each interval type (ITI, TTC, Sample): 241 - Retrieves the base duration and random variation range from `interval_vars`. 242 - Creates a base array repeating the base duration for the total number of trials. 243 - If random variation is specified, generates an array of random integers within the +/- range. 244 - Adds the random variation array to the base array. 245 - Stores the resulting final interval array in the corresponding class attribute (`ITI_intervals_final`, etc.). 246 247 Raises 248 ------ 249 - Propagates NumPy errors (e.g., during random number generation or array addition). 250 - *KeyError*: If expected keys are missing from `interval_vars`. 251 """ 252 try: 253 num_trials = self.exp_var_entries["Num Trials"] 254 255 final_intervals = [None, None, None] 256 257 interval_keys = ["ITI_var", "TTC_var", "sample_var"] 258 259 random_interval_keys = [ 260 "ITI_random_entry", 261 "TTC_random_entry", 262 "sample_random_entry", 263 ] 264 265 # for each type of interval (iti, ttc, sample), generate final interval times 266 for i in range(len(interval_keys)): 267 base_val = self.interval_vars[interval_keys[i]] 268 # this makes an array that is n=num_trials length of base_val 269 base_intervals = np.repeat(base_val, num_trials) 270 271 # get plus minus value for this interval type 272 random_var = self.interval_vars[random_interval_keys[i]] 273 274 if random_var == 0: 275 # if there is no value to add/subtract randomly, continue on 276 final_intervals[i] = base_intervals 277 278 else: 279 # otherwise make num_trials amount of random integers and add them to the final_intervals 280 random_interval_arr = np.random.randint( 281 -random_var, random_var, num_trials 282 ) 283 284 final_intervals[i] = np.add(base_intervals, random_interval_arr) 285 286 # assign results for respective types to the model for storage and later use 287 self.ITI_intervals_final = final_intervals[0] 288 self.TTC_intervals_final = final_intervals[1] 289 self.sample_intervals_final = final_intervals[2] 290 291 logger.info("Random intervals created.") 292 except Exception as e: 293 logger.error(f"Error creating random intervals: {e}") 294 raise
Calculates randomized ITI, TTC, and Sample intervals for all trials.
For each interval type (ITI, TTC, Sample):
- Retrieves the base duration and random variation range from
interval_vars
. - Creates a base array repeating the base duration for the total number of trials.
- If random variation is specified, generates an array of random integers within the +/- range.
- Adds the random variation array to the base array.
- Stores the resulting final interval array in the corresponding class attribute (
ITI_intervals_final
, etc.).
Raises
- Propagates NumPy errors (e.g., during random number generation or array addition).
- KeyError: If expected keys are missing from
interval_vars
.
296 def calculate_max_runtime(self) -> tuple[int, int]: 297 """ 298 Estimates the maximum possible experiment runtime in minutes and seconds. 299 300 Sums all generated interval durations (ITI, TTC, Sample) across all trials. 301 Converts the total milliseconds to seconds, then uses `convert_seconds_to_minutes_seconds`. 302 303 Returns 304 ------- 305 - *tuple[int, int]*: A tuple containing (estimated_max_minutes, estimated_max_seconds). 306 """ 307 max_time = ( 308 sum(self.ITI_intervals_final) 309 + sum(self.TTC_intervals_final) 310 + sum(self.sample_intervals_final) 311 ) 312 minutes, seconds = self.convert_seconds_to_minutes_seconds(max_time / 1000) 313 return (minutes, seconds)
Estimates the maximum possible experiment runtime in minutes and seconds.
Sums all generated interval durations (ITI, TTC, Sample) across all trials.
Converts the total milliseconds to seconds, then uses convert_seconds_to_minutes_seconds
.
Returns
- tuple[int, int]: A tuple containing (estimated_max_minutes, estimated_max_seconds).
315 def create_trial_blocks(self) -> List[tuple[str, str]]: 316 """ 317 Determines the unique stimuli pairings within a single trial block. 318 319 Calculates the number of pairs per block (`block_sz = num_stimuli / 2`). 320 Iterates from `i = 0` to `block_sz - 1`. For each `i`, finds the 321 corresponding paired valve index using `get_paired_index`. Appends the 322 tuple of (stimulus_name_at_i, stimulus_name_at_paired_index) to a list. 323 324 Returns 325 ------- 326 - *List[tuple]*: A list of tuples, where each tuple represents a unique stimuli pairing (e.g., `[('Odor A', 'Odor B'), ('Odor C', 'Odor D')]`). 327 328 Raises 329 ------ 330 - Propagates errors from `get_paired_index`. 331 - *KeyError*: If expected keys are missing from `exp_var_entries`. 332 - *IndexError*: If calculated indices are out of bounds for `stimuli_data.stimuli_vars`. 333 """ 334 try: 335 # creating pairs list which stores all possible pairs 336 pairs = [] 337 num_stimuli = self.exp_var_entries["Num Stimuli"] 338 block_sz = num_stimuli // 2 339 340 stimuli_names = list(self.stimuli_data.stimuli_vars.values()) 341 342 for i in range(block_sz): 343 pair_index = self.get_paired_index(i, num_stimuli) 344 pairs.append((stimuli_names[i], stimuli_names[pair_index])) 345 346 return pairs 347 except Exception as e: 348 logger.error(f"Error creating trial blocks: {e}") 349 raise
Determines the unique stimuli pairings within a single trial block.
Calculates the number of pairs per block (block_sz = num_stimuli / 2
).
Iterates from i = 0
to block_sz - 1
. For each i
, finds the
corresponding paired valve index using get_paired_index
. Appends the
tuple of (stimulus_name_at_i, stimulus_name_at_paired_index) to a list.
Returns
- List[tuple]: A list of tuples, where each tuple represents a unique stimuli pairing (e.g.,
[('Odor A', 'Odor B'), ('Odor C', 'Odor D')]
).
Raises
- Propagates errors from
get_paired_index
. - KeyError: If expected keys are missing from
exp_var_entries
. - IndexError: If calculated indices are out of bounds for
stimuli_data.stimuli_vars
.
351 def generate_pairs(self, pairs: list[tuple[str, str]]) -> Tuple[list, list]: 352 """ 353 Generates the full, pseudo-randomized sequence of stimuli pairs for all trials. 354 355 Repeats the following for the number of trial blocks specified: 356 - Takes the list of unique `pairs` generated by `create_trial_blocks` that must be included in each block. 357 - Shuffles this list randomly (`np.random.shuffle`). 358 - Extends a master list `pseudo_random_lineup` (adds new list to that list) with the shuffled block. 359 After creating the full sequence, separates it into two lists: one for the 360 stimulus presented on side one each trial, and one for side two. 361 362 Parameters 363 ---------- 364 - **pairs** (*List[tuple[str, str]]*): The list of unique stimuli pairings defining one block, generated by `create_trial_blocks`. 365 366 Returns 367 ------- 368 - *Tuple[list, list]*: A tuple containing two lists: 369 - `stimulus_1`: List of stimuli names for Port 1 for each trial. 370 - `stimulus_2`: List of stimuli names for Port 2 for each trial. 371 372 Raises 373 ------ 374 - Propagates errors from `np.random.shuffle`. 375 """ 376 try: 377 pseudo_random_lineup: List[tuple] = [] 378 379 stimulus_1: List[str] = [] 380 stimulus_2: List[str] = [] 381 382 for _ in range(self.exp_var_entries["Num Trial Blocks"]): 383 pairs_copy = [tuple(pair) for pair in pairs] 384 np.random.shuffle(pairs_copy) 385 pseudo_random_lineup.extend(pairs_copy) 386 387 for pair in pseudo_random_lineup: 388 stimulus_1.append(pair[0]) 389 stimulus_2.append(pair[1]) 390 391 return stimulus_1, stimulus_2 392 except Exception as e: 393 logger.error(f"Error generating pairs: {e}") 394 raise
Generates the full, pseudo-randomized sequence of stimuli pairs for all trials.
Repeats the following for the number of trial blocks specified:
- Takes the list of unique
pairs
generated bycreate_trial_blocks
that must be included in each block. - Shuffles this list randomly (
np.random.shuffle
). - Extends a master list
pseudo_random_lineup
(adds new list to that list) with the shuffled block. After creating the full sequence, separates it into two lists: one for the stimulus presented on side one each trial, and one for side two.
Parameters
- pairs (List[tuple[str, str]]): The list of unique stimuli pairings defining one block, generated by
create_trial_blocks
.
Returns
- Tuple[list, list]: A tuple containing two lists:
stimulus_1
: List of stimuli names for Port 1 for each trial.stimulus_2
: List of stimuli names for Port 2 for each trial.
Raises
- Propagates errors from
np.random.shuffle
.
396 def build_frame( 397 self, 398 stimuli_1: list[str], 399 stimuli_2: list[str], 400 ) -> None: 401 """ 402 Constructs the main `program_schedule_df` pandas DataFrame. 403 404 Uses the generated stimuli sequences (`stimuli_1`, `stimuli_2`) and the 405 calculated interval arrays (`ITI_intervals_final`, etc.) to build the 406 DataFrame. Includes columns for trial block number, trial number, stimuli 407 per port, calculated intervals, and placeholders (NaN) for results columns 408 like lick counts and actual TTC duration. 409 410 Parameters 411 ---------- 412 - **stimuli_1** (*list*): List of stimuli names for Port 1, one per trial. 413 - **stimuli_2** (*list*): List of stimuli names for Port 2, one per trial. 414 415 Raises 416 ------ 417 - Propagates pandas errors during DataFrame creation. 418 - *ValueError*: If the lengths of input lists/arrays do not match the expected number of trials. 419 """ 420 num_stimuli = self.exp_var_entries["Num Stimuli"] 421 num_trials = self.exp_var_entries["Num Trials"] 422 num_trial_blocks = self.exp_var_entries["Num Trial Blocks"] 423 424 # each block must contain each PAIR -> num pairs = num_stim / 2 425 block_size = int(num_stimuli / 2) 426 try: 427 data = { 428 "Trial Block": np.repeat( 429 range(1, num_trial_blocks + 1), 430 block_size, 431 ), 432 "Trial Number": np.repeat(range(1, num_trials + 1), 1), 433 "Port 1": stimuli_1, 434 "Port 2": stimuli_2, 435 "Port 1 Licks": np.full(num_trials, np.nan), 436 "Port 2 Licks": np.full(num_trials, np.nan), 437 "ITI": self.ITI_intervals_final, 438 "TTC": self.TTC_intervals_final, 439 "SAMPLE": self.sample_intervals_final, 440 "TTC Actual": np.full(num_trials, np.nan), 441 } 442 443 self.program_schedule_df = pd.DataFrame(data) 444 445 logger.info("Initialized stimuli dataframe.") 446 except Exception as e: 447 logger.debug(f"Error Building Stimuli Frame: {e}.") 448 raise
Constructs the main program_schedule_df
pandas DataFrame.
Uses the generated stimuli sequences (stimuli_1
, stimuli_2
) and the
calculated interval arrays (ITI_intervals_final
, etc.) to build the
DataFrame. Includes columns for trial block number, trial number, stimuli
per port, calculated intervals, and placeholders (NaN) for results columns
like lick counts and actual TTC duration.
Parameters
- stimuli_1 (list): List of stimuli names for Port 1, one per trial.
- stimuli_2 (list): List of stimuli names for Port 2, one per trial.
Raises
- Propagates pandas errors during DataFrame creation.
- ValueError: If the lengths of input lists/arrays do not match the expected number of trials.
450 def save_all_data(self): 451 """ 452 Saves the experiment schedule and detailed event log DataFrames to Excel files. 453 454 Iterates through a dictionary mapping descriptive names to the DataFrame 455 references (`program_schedule_df`, `event_data.event_dataframe`) and calls 456 the static `save_df_to_xlsx` method for each. 457 """ 458 dataframes = { 459 "Experiment Schedule": self.program_schedule_df, 460 "Detailed Event Log Data": self.event_data.event_dataframe, 461 } 462 463 for name, df_reference in dataframes.items(): 464 self.save_df_to_xlsx(name, df_reference)
Saves the experiment schedule and detailed event log DataFrames to Excel files.
Iterates through a dictionary mapping descriptive names to the DataFrame
references (program_schedule_df
, event_data.event_dataframe
) and calls
the static save_df_to_xlsx
method for each.
466 @staticmethod 467 def get_paired_index(i: int, num_stimuli: int) -> int | None: 468 """ 469 Calculates the 0-indexed valve number for the stimulus for side two based on give index (i) for side one. 470 471 Based on the total number of stimuli (`num_stimuli`), determines the index 472 of the valve paired with the valve at index `i`. Handles cases for 2, 4, or 8 total stimuli. 473 474 Parameters 475 ---------- 476 - **i** (*int*): The 0-indexed number of the valve on one side. 477 - **num_stimuli** (*int*): The total number of stimuli/valves being used (must be 2, 4, or 8). 478 479 Returns 480 ------- 481 - *int | None*: The 0-indexed number of the paired valve, or None if `num_stimuli` is not 2, 4, or 8. 482 """ 483 match num_stimuli: 484 case 2: 485 # if num stim is 2, we are only running the same stimulus on both sides, 486 # we will only see i=0 here, and its paired with its sibling i=4 487 return i + 4 488 case 4: 489 # if num stim is 4, we are only running 2 stimuli per side, 490 # we will see i=0 and i =1 here, paired with siblings i=5 & i=4 respectively 491 match i: 492 case 0: # valve 1 493 return i + 5 # (5) / valve 6 494 case 1: # valve 2 495 return i + 3 # (4) / valve 5 496 case 8: 497 match i: 498 case 0: 499 return i + 5 500 case 1: 501 return i + 3 502 case 2: 503 return i + 5 504 case 3: 505 return i + 3 506 case _: 507 return None
Calculates the 0-indexed valve number for the stimulus for side two based on give index (i) for side one.
Based on the total number of stimuli (num_stimuli
), determines the index
of the valve paired with the valve at index i
. Handles cases for 2, 4, or 8 total stimuli.
Parameters
- i (int): The 0-indexed number of the valve on one side.
- num_stimuli (int): The total number of stimuli/valves being used (must be 2, 4, or 8).
Returns
- int | None: The 0-indexed number of the paired valve, or None if
num_stimuli
is not 2, 4, or 8.
509 @staticmethod 510 def save_df_to_xlsx(name: str, dataframe: pd.DataFrame) -> None: 511 """ 512 Saves a pandas DataFrame to an Excel (.xlsx) file using a file save dialog. 513 514 Prompts the user with a standard 'Save As' dialog window. Suggests a default 515 filename including the provided `name` and the current date. Sets the default 516 directory to '../data_outputs'. If the user confirms a filename, saves the 517 DataFrame; otherwise, logs that the save was cancelled. 518 519 Parameters 520 ---------- 521 - **name** (*str*): A descriptive name used in the default filename (e.g., "Experiment Schedule"). 522 - **dataframe** (*pd.DataFrame*): The pandas DataFrame to be saved. 523 524 Raises 525 ------ 526 - Propagates errors from `filedialog.asksaveasfilename` or `dataframe.to_excel`. 527 """ 528 try: 529 file_name = filedialog.asksaveasfilename( 530 defaultextension=".xlsx", 531 filetypes=[("Excel Files", "*.xlsx")], 532 initialfile=f"{name}, {datetime.date.today()}", 533 initialdir=Path(__file__).parent.parent.parent.resolve() 534 / "data_outputs", 535 title="Save Excel file", 536 ) 537 538 if file_name: 539 dataframe.to_excel(file_name, index=False) 540 else: 541 logger.info("User cancelled saving the stimuli dataframe.") 542 543 logger.info("Data saved to xlsx files.") 544 except Exception as e: 545 logger.error(f"Error saving data to xlsx: {e}") 546 raise
Saves a pandas DataFrame to an Excel (.xlsx) file using a file save dialog.
Prompts the user with a standard 'Save As' dialog window. Suggests a default
filename including the provided name
and the current date. Sets the default
directory to '../data_outputs'. If the user confirms a filename, saves the
DataFrame; otherwise, logs that the save was cancelled.
Parameters
- name (str): A descriptive name used in the default filename (e.g., "Experiment Schedule").
- dataframe (pd.DataFrame): The pandas DataFrame to be saved.
Raises
- Propagates errors from
filedialog.asksaveasfilename
ordataframe.to_excel
.
548 @staticmethod 549 def convert_seconds_to_minutes_seconds(total_seconds: int) -> tuple[int, int]: 550 """ 551 Converts a total number of seconds into whole minutes and remaining seconds. 552 553 Parameters 554 ---------- 555 - **total_seconds** (*int*): The total duration in seconds. 556 557 Returns 558 ------- 559 - *tuple[int, int]*: A tuple containing (minutes, seconds). 560 561 Raises 562 ------ 563 - Propagates `TypeError` if `total_seconds` is not a number. 564 """ 565 try: 566 # Integer division to get whole minutes 567 minutes = total_seconds // 60 568 # Modulo to get remaining seconds 569 seconds = total_seconds % 60 570 return minutes, seconds 571 except Exception as e: 572 logger.error(f"Error converting seconds to minutes and seconds: {e}") 573 raise
Converts a total number of seconds into whole minutes and remaining seconds.
Parameters
- total_seconds (int): The total duration in seconds.
Returns
- tuple[int, int]: A tuple containing (minutes, seconds).
Raises
- Propagates
TypeError
iftotal_seconds
is not a number.