app_logic
'app_logic' is the primary module in the Photologic-Experiment-Rig codebase. It contains the StateMachine class which holds the logic for each 'state' an experiment can be in.
Before performing any actions, the StateMachine class initializes the instances of models.experiment_process_data
,
controllers.arduino_control
, and views.main_gui
classes.
Launching main_gui initializes a tkinter root and allows for a GUI to be created for the program.
This module is launched from main to make restarting the program easier, which is done by destroying the instance of state machine and launcing a new one.
1""" 2'app_logic' is the primary module in the Photologic-Experiment-Rig codebase. It contains the 3StateMachine class which holds the logic for each 'state' an experiment can be in. 4 5Before performing any actions, the StateMachine class initializes the instances of `models.experiment_process_data`, 6`controllers.arduino_control`, and `views.main_gui` classes. 7Launching main_gui initializes a tkinter root and allows for a GUI to be created for the program. 8 9This module is launched from main to make restarting the program easier, which is done by destroying the 10instance of state machine and launcing a new one. 11""" 12 13# external imports 14import time 15import threading 16import logging 17import toml 18import queue 19from typing import Callable 20import datetime 21from logging import FileHandler 22 23# imports for locally used modules and classes 24from models.experiment_process_data import ExperimentProcessData 25from views.gui_common import GUIUtils 26import system_config 27from views.main_gui import MainGUI 28 29# these are just use for type hinting here 30from controllers.arduino_control import ArduinoManager 31 32logger = logging.getLogger() 33"""Logger used to log program runtime details for debugging""" 34logger.setLevel(logging.INFO) 35 36console_handler = logging.StreamHandler() 37"""The console handler is used to print errors to the console""" 38console_handler.setLevel(logging.ERROR) 39console_handler.setFormatter( 40 logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") 41) 42logger.addHandler(console_handler) 43 44RIG_CONFIG = system_config.get_rig_config() 45""" 46This utilizes `system_config` module to obtain a constant path for this machine regardless of OS to the Rig Files foler 47at the current users documents folder. 48""" 49 50with open(RIG_CONFIG, "r") as f: 51 DOOR_CONFIG = toml.load(f)["door_motor_config"] 52 53DOOR_MOVE_TIME = DOOR_CONFIG["DOOR_MOVE_TIME"] 54"""Constant value stored in the door_motor_config section of `RIG CONFIG` config""" 55 56 57now = datetime.datetime.now() 58# configure logfile details such as name and the level at which information should be added to the log (INFO here) 59logfile_name = f"{now.hour}_{now.minute}_{now.second} - {now.date()} experiment log" 60logfile_path = system_config.get_log_path(logfile_name) 61 62file_handler = FileHandler(logfile_path) 63file_handler.setLevel(logging.INFO) 64file_handler.setFormatter( 65 logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") 66) 67logger.addHandler(file_handler) 68 69 70class StateMachine: 71 """ 72 This class is the heart of the program. Defines and handles program state transitions. 73 It coordinates co-operation between view (gui) and models (data). We inherit a TkinterApp class to include 74 attributes of a tkinter app such as gui, arduino controller, etc. 75 76 Attributes 77 ---------- 78 - **exp_data** (*ExperimentProcessData*): An instance of `models.experiment_process_data` ExperimentProcessData, this attribute allows for access and 79 modification of experiment variables, and access for to more specific models like `models.event_data` and `models.arduino_data`. 80 - **arduino_controller** (*ArduinoManager*): An instance of `controllers.arduino_control` ArduinoManager, this allows for communication between this program and the 81 Arduino board. 82 - **main_gui** (*MainGUI*): An instance of `views.main_gui` MainGUI, this allows for the creation and modification of all GUI attributes in the program. All GUI windows 83 are created and managed here. 84 - **state** (*str*): Contains the current state the program is in. 85 - **prev_state** (*str*): Contains the state the program was previously in. Useful to restore state in case of erroneous transitions. 86 - **app_result** (*list*): Mutable list with one element. Is a reference to list defined in `main`. 87 - **transitions** (*dict*): Program state transition table. 88 89 Methods 90 ------- 91 - `trigger`(event) 92 Handles state transition events. Decides if a transition is valid and warns user of destructive transitions. If valid, 93 passes state to `execute_state` method. 94 - `execute_state`(new_state: str) 95 Takes the state passed from trigger event and decides appropriate action. 96 - `process_queue`(data_queue) 97 Processes incoming data from the Arduino board. Reads from queue that is added to by `controllers.arduino_control` module 98 `listen_for_serial` method. 99 - `reject_actions`(event) 100 A static method that handles the rejection of actions that cannot be performed given a certain state transition. 101 """ 102 103 def __init__(self, result_container): 104 """init method for `StateMachine`. Takes `main` module `result_container`. 105 the 'super' parent class which is the gui""" 106 107 self.exp_data = ExperimentProcessData() 108 109 self.arduino_controller = ArduinoManager(self.exp_data) 110 111 self.main_gui = MainGUI(self.exp_data, self.trigger, self.arduino_controller) 112 logging.info("GUI started successfully.") 113 114 self.state = "IDLE" 115 """Default state for program is set at IDLE""" 116 117 self.prev_state = None 118 """Default previous state for program is set to None. Utilized in `trigger`.""" 119 120 self.app_result = result_container 121 """ 122 Here we store a reference to `main` module `result_container` to store decision of whether to restart the 123 program or just terminate the current instance. 124 """ 125 126 self.transitions = { 127 ("IDLE", "GENERATE SCHEDULE"): "GENERATE SCHEDULE", 128 ( 129 "GENERATE SCHEDULE", 130 "IDLE", 131 ): "IDLE", 132 ("IDLE", "START"): "START PROGRAM", 133 ("IDLE", "RESET"): "RESET PROGRAM", 134 ("STOP PROGRAM", "RESET"): "RESET PROGRAM", 135 ("START PROGRAM", "ITI"): "ITI", 136 ("ITI", "DOOR OPEN"): "OPENING DOOR", 137 ("OPENING DOOR", "TTC"): "TTC", 138 ("TTC", "SAMPLE"): "SAMPLE", # -> if rat engages in trial 139 ("TTC", "TRIAL END"): "TRIAL END", # -> if rat does NOT engage 140 ("SAMPLE", "TRIAL END"): "TRIAL END", 141 ("TRIAL END", "ITI"): "ITI", # -> save trial data, update gui, door up 142 ("ITI", "STOP"): "STOP PROGRAM", # can move to 'stop' state in all states 143 ("OPENING DOOR", "STOP"): "STOP PROGRAM", 144 ("TTC", "STOP"): "STOP PROGRAM", 145 ("SAMPLE", "STOP"): "STOP PROGRAM", 146 ("TRIAL END", "STOP"): "STOP PROGRAM", 147 } 148 """State transition table defines all transitions that the program can possibly take""" 149 150 # don't start the mainloop until AFTER the gui is setup so that the app_result property 151 # is available if reset is desired 152 self.main_gui.mainloop() 153 154 def trigger(self, event): 155 """ 156 This function takes a requested state and decides if the transition from this state is allowed. 157 158 Parameters 159 ---------- 160 - **event** (*str*): Contains the desired state to move to. 161 """ 162 163 transition = (self.state, event) 164 self.prev_state = self.state 165 new_state = None 166 167 logger.info(f"state transition -> {transition}") 168 169 # checking if the attemped transition is valid according to the table 170 if transition in self.transitions: 171 new_state = self.transitions[transition] 172 else: 173 # if the key is not in the transition table, handle rejection based on desired state 174 self.reject_actions(event) 175 176 # if the key was in the transition table, and the new state wants to reset the program, that means this is a valid action at this time. MAKE SURE the 177 # user REALLY wants to do that 178 if new_state == "RESET PROGRAM": 179 response = GUIUtils.askyesno( 180 "============WARNING============", 181 "THIS ACTION WILL ERASE ALL DATA CURRENTLY STORED FOR THIS EXPERIMENT... ARE YOU SURE YOU WANT TO CONTINUE?", 182 ) 183 # if true, go ahead with the reset, if false return to prev_state 184 if not response: 185 new_state = self.prev_state 186 187 # if attempt to start program with no experiment schedule, tell user to do that 188 if new_state == "START PROGRAM": 189 if self.exp_data.program_schedule_df.empty: 190 GUIUtils.display_error( 191 "Experiement Schedule Not Yet Generated!!!", 192 "Please generate the program schedule before attempting to start the experiment. You can do this by clicking the 'Valve / Stimuli' button in the main screen.", 193 ) 194 new_state = self.prev_state 195 196 # if we have a new state, perform the associated action for that state 197 if new_state: 198 logger.info(f"new state -> {new_state}") 199 self.execute_state(new_state) 200 201 def execute_state(self, new_state: str) -> None: 202 """ 203 Executes the action corresponding to new_state. if the action is defined under state_target, 204 that action will be taken in a new thread to avoid overloading the main tkinter thread. otherwise 205 it is executed immediately in the main thread. 206 207 Parameters 208 ---------- 209 - **new_state** (*str*): Contains the desired state to move to. Used to locate the desired action. 210 """ 211 212 thread_target = None 213 214 match new_state: 215 case "START PROGRAM": 216 217 def thread_target(): 218 StartProgram( 219 self.exp_data, 220 self.main_gui, 221 self.arduino_controller, 222 self.trigger, 223 ) 224 case "RESET PROGRAM": 225 ResetProgram(self.main_gui, self.app_result, self.arduino_controller) 226 case "STOP PROGRAM": 227 StopProgram(self.main_gui, self.arduino_controller, self.trigger) 228 case "GENERATE SCHEDULE": 229 # because this will run in main thread, we need to return early to avoid self.state 230 # assignment confusion 231 self.state = new_state 232 GenerateSchedule( 233 self.main_gui, 234 self.arduino_controller, 235 self.process_queue, 236 self.trigger, 237 ) 238 return 239 case "ITI": 240 241 def thread_target(): 242 InitialTimeInterval( 243 self.exp_data, 244 self.main_gui, 245 new_state, 246 self.trigger, 247 ) 248 case "OPENING DOOR": 249 250 def thread_target(): 251 OpeningDoor( 252 self.exp_data, 253 self.main_gui, 254 self.arduino_controller, 255 new_state, 256 self.trigger, 257 ) 258 case "TTC": 259 260 def thread_target(): 261 TimeToContact( 262 self.exp_data, 263 self.arduino_controller, 264 self.main_gui, 265 new_state, 266 self.trigger, 267 ) 268 case "SAMPLE": 269 270 def thread_target(): 271 SampleTime( 272 self.exp_data, 273 self.main_gui, 274 self.arduino_controller, 275 new_state, 276 self.trigger, 277 ) 278 case "TRIAL END": 279 280 def thread_target(): 281 TrialEnd( 282 self.exp_data, 283 self.main_gui, 284 self.arduino_controller, 285 self.prev_state, 286 self.trigger, 287 ) 288 289 self.prev_state = self.state 290 self.state = new_state 291 292 if thread_target: 293 threading.Thread(target=thread_target).start() 294 295 def process_queue(self, data_queue: queue.Queue[tuple[str, str]]) -> None: 296 """ 297 Process the data queue. Pulls in data from the thread that is reading data from the arduinos constantly. if there is anything 298 in the queue at time of function call, we will stay here until its all been dealt with. 299 300 Parameters 301 ---------- 302 - **data_queue** (*tuple[str, str]*): This is a shared queue between a thread initiated in the `controllers.arduino_control` module's 303 `listen_for_serial` method. That thread constantly reads info from Arduino so it is not missed, and the main thread calls this process method 304 to process accumulated data to avoid overloading the thread. 305 """ 306 307 arduino_data = self.exp_data.arduino_data 308 try: 309 while not data_queue.empty(): 310 source, data = data_queue.get() 311 arduino_data.process_data(source, data, self.state, self.trigger) 312 313 # run this command every 250ms 314 queue_ps_id = self.main_gui.after( 315 250, lambda: self.process_queue(data_queue) 316 ) 317 self.main_gui.scheduled_tasks["PROCESS QUEUE"] = queue_ps_id 318 except Exception as e: 319 logging.error(f"Error processing data queue: {e}") 320 raise 321 322 @staticmethod 323 def reject_actions(event): 324 """ 325 This method handles the rejection of the 'START' and 'RESET' actions. These are rejected when there is no state transition for their current state. 326 This is usually during program runtime for reset or before schedule generation for start. 327 328 Parameters 329 ---------- 330 - **event** (*str*): This is the desired state being rejected here. 331 """ 332 match event: 333 case "RESET": 334 GUIUtils.display_error( 335 "CANNOT PERFORM THIS ACTION", 336 "RESET cannot be performed during runtime. Stop the program to reset.", 337 ) 338 # if key is not in table but we try to start (for example, STOP PROGRAM -> START) we do NOT want to allow this until a full reset has been completed 339 case "START": 340 GUIUtils.display_error( 341 "CANNOT PERFORM THIS ACTION", 342 "Experiement has already been executed. Before running another, you need to RESET the application to ensure all data is reset to defaults", 343 ) 344 case _: 345 return 346 347 348class GenerateSchedule: 349 """ 350 This class handles the schedule generation state. It is called once the user has input the amount of simuli for this experiment, the 351 number of trial blocks, changed the names of the stimuli in each cylinder/valve in the 'Valve / Stimuli' window, and pressed generate schedule. 352 353 The main things handled here are the updating of gui objects that needed the previously discussed info to be created (e.g raster plots need 354 total trials, program schedule window needed the experiment data, etc.) 355 """ 356 357 def __init__( 358 self, 359 main_gui: MainGUI, 360 arduino_controller: ArduinoManager, 361 process_queue: Callable[[queue.Queue[tuple[str, str]]], None], 362 trigger: Callable[[str], None], 363 ): 364 """ 365 Initialize and handle the GenerateSchedule class state. 366 367 Parameters 368 ---------- 369 - **main_gui** (*MainGUI*): A reference to the `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 370 update show the program schedule and create raster plots. 371 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` Arduino controller instance, this is the method by which the Arduino is communicated 372 with in the program. Used to send schedules, variables, and valve durations here. 373 - **process_queue** (*Callback method*): This callback method is passed here so that the Arduino listener process thread can be started once the listener thread 374 is running. It does NOT run prior to this so that sending individual bytes (send schedule, etc.) is performed without having data stolen away by the constantly running 375 listener thread. 376 - **trigger** (*Callback method*): This callback is passed in so that this state can trigger a transition back to `IDLE` when it is finished with its work. 377 """ 378 379 # show the program sched window via the callback passed into the class at initialization 380 main_gui.show_secondary_window("Program Schedule") 381 382 # create plots using generated number of trials as max Y values 383 for window in main_gui.windows["Raster Plot"]: 384 window.create_plot() 385 386 # send exp variables, schedule, and valve open durations stored in arduino_data.toml to the arduino 387 arduino_controller.send_experiment_variables() 388 arduino_controller.send_schedule_data() 389 arduino_controller.send_valve_durations() 390 391 # start Arduino process queue and listener thread so we know when Arduino tries to tell us something 392 process_queue(arduino_controller.data_queue) 393 394 arduino_controller.listener_thread = threading.Thread( 395 target=arduino_controller.listen_for_serial 396 ) 397 398 arduino_controller.listener_thread.start() 399 400 logger.info("Started listening thread for Arduino serial input.") 401 402 # transition back to idle 403 trigger("IDLE") 404 405 406class StartProgram: 407 """ 408 This class handles the remaining set-up steps to prepare for experiment runtime. It then triggers the experiment. 409 """ 410 411 def __init__( 412 self, 413 exp_data: ExperimentProcessData, 414 main_gui: MainGUI, 415 arduino_controller: ArduinoManager, 416 trigger: Callable[[str], None], 417 ): 418 """ 419 Parameters 420 ---------- 421 - **exp_data** (*ExperimentProcessData*): Reference to the `models.experiment_process_data`. Here we use it to mark experiment and state start times. 422 - **main_gui** (*MainGUI*): A reference to `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 423 update clock labels and max program runtime. 424 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` instance, this is the method by which the Arduino is communicated 425 with in the program. Used to tell Arduino that program begins now. 426 - **trigger** (*Callback method*): This callback is passed in so that this state can trigger a transition to `ITI` when it is finished with its work. 427 """ 428 try: 429 # update experiment data model program start time and state start time variables with current time 430 exp_data.start_time = time.time() 431 exp_data.state_start_time = time.time() 432 433 # tell Arduino that experiment starts now so it knows how to calculate timestamps 434 start_command = "T=0\n".encode("utf-8") 435 arduino_controller.send_command(command=start_command) 436 437 main_gui.update_clock_label() 438 main_gui.update_max_time() 439 440 # change the green start button into a red stop button, update the associated command 441 main_gui.start_button.configure( 442 text="Stop", bg="red", command=lambda: trigger("STOP") 443 ) 444 445 logging.info("==========EXPERIMENT BEGINS NOW==========") 446 447 trigger("ITI") 448 except Exception as e: 449 logging.error(f"Error starting program: {e}") 450 raise 451 452 453class StopProgram: 454 """ 455 Stops and updates program to reflect `IDLE` state. 456 457 Methods 458 ------- 459 - `finalize_program`(main_gui, arduino_controller) 460 This method waits until the door closes for the last time, then cancels the last scheduled task, stops the listener thread, closes Arduino connections, and 461 instructs the user to save their data. 462 """ 463 464 def __init__( 465 self, 466 main_gui: MainGUI, 467 arduino_controller: ArduinoManager, 468 trigger: Callable[[str], None], 469 ) -> None: 470 """ 471 Parameters 472 ---------- 473 - **main_gui** (*MainGUI*): A reference to `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 474 call update_on_stop, configure the start/stop button back to start, and cancel tkinter scheduled tasks. 475 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` instance, this is the method by which the Arduino is communicated 476 with in the program. Used to reset Arduino and clear all left-over experiment data. 477 - **trigger** (*Callback method*): This callback is passed in so the start button can be configured to command a `START` state. 478 """ 479 try: 480 main_gui.update_on_stop() 481 # change the command back to start for the start button. (STOP PROGRAM, START) is not a defined transition, so the 482 # trigger function will call reject_actions to let the user know the program has already ran and they need to reset the app. 483 main_gui.start_button.configure( 484 text="Start", bg="green", command=lambda: trigger("START") 485 ) 486 487 # stop all scheduled tasks EXCEPT for the process queue, we are still waiting for the last door close timestamp 488 for desc, sched_task in main_gui.scheduled_tasks.items(): 489 if desc != "PROCESS QUEUE": 490 main_gui.after_cancel(sched_task) 491 492 # schedule finalization after door will be down 493 main_gui.scheduled_tasks["FINALIZE"] = main_gui.after( 494 5000, lambda: self.finalize_program(main_gui, arduino_controller) 495 ) 496 497 logging.info("Program stopped... waiting to finalize...") 498 except Exception as e: 499 logging.error(f"Error stopping program: {e}") 500 raise 501 502 def finalize_program( 503 self, main_gui: MainGUI, arduino_controller: ArduinoManager 504 ) -> None: 505 """ 506 Finalize the program by resetting the Arduino board, offer to save the data frames into xlsx files. 507 508 Parameters 509 ---------- 510 - **main_gui** (*MainGUI*): A reference to `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 511 cancel the last tkinter.after call. 512 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` instance, this is the method by which the Arduino is communicated 513 with in the program. Used to reset Arduino board and close the connection to it. 514 """ 515 try: 516 # stop the arduino listener so that the program can shut down 517 # and not be blocked 518 arduino_controller.stop_listener_thread() 519 520 queue_id = main_gui.scheduled_tasks["PROCESS QUEUE"] 521 main_gui.after_cancel(queue_id) 522 523 arduino_controller.close_connection() 524 525 main_gui.save_button_handler() 526 527 logging.info("Program finalized, arduino boards reset.") 528 except Exception as e: 529 logging.error(f"Error completing program: {e}") 530 raise 531 532 533class ResetProgram: 534 """ 535 Handle resetting the program on click of the reset button. This class has no instance variable or methods. 536 """ 537 538 def __init__( 539 self, main_gui: MainGUI, app_result: list, arduino_controller: ArduinoManager 540 ) -> None: 541 try: 542 # these two calls will stop the gui, halting the programs mainloop. 543 main_gui.quit() 544 main_gui.destroy() 545 546 # cancel all scheduled tasks 547 for sched_task in main_gui.scheduled_tasks.values(): 548 main_gui.after_cancel(sched_task) 549 # if we have an listener thread and arduino connected, stop the thread and close the connection. 550 if arduino_controller.listener_thread is not None: 551 arduino_controller.stop_listener_thread() 552 if arduino_controller.arduino is not None: 553 arduino_controller.close_connection() 554 555 # set first bit in app_result list to 1 to instruct main to restart. 556 app_result[0] = 1 557 558 except Exception as e: 559 logging.error(f"Error resetting the program: {e}") 560 561 562class InitialTimeInterval: 563 """ 564 State class for initial time interval experiment state. 565 """ 566 567 def __init__( 568 self, 569 exp_data: ExperimentProcessData, 570 main_gui: MainGUI, 571 state: str, 572 trigger: Callable[[str], None], 573 ) -> None: 574 """ 575 This function transitions the program into the `ITI` state. It does this by resetting lick counts, setting new state time, 576 updating the models, and setting the .after call to transition to TTC. 577 578 Parameters 579 ---------- 580 - **exp_data** (*ExperimentProcessData*): Reference to the `models.experiment_process_data`. Here we use it to get a reference to event_data to set trial 581 licks to 0, get logical trial number, and get state duration time. 582 - **main_gui** (*MainGUI*): A reference to `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 583 update GUI with new trial information and configure the state timer. 584 - **state** (*str*): State is used to update GUI with current state and grab the state duration time from the program schedule df. 585 - **trigger** (*Callback method*): This callback is passed in so the `OPENING DOOR` state can be triggered after the `ITI` time has passed. 586 """ 587 try: 588 # get a reference to the event_data from exp_data. 589 event_data = exp_data.event_data 590 591 # set trial licks to 0 for both sides 592 event_data.side_one_licks = 0 593 event_data.side_two_licks = 0 594 595 # logical df rows will always be 1 behind the current trial because of zero based indexing in dataframes vs 596 # 1 based for trials 597 logical_trial = exp_data.current_trial_number - 1 598 599 # Call gui updates needed every trial (e.g program schedule window highlighting, progress bar, trial stimuli, etc) 600 # make sure we convert received vals to str to avoid type errors 601 main_gui.update_on_new_trial( 602 str(exp_data.program_schedule_df.loc[logical_trial, "Port 1"]), 603 str(exp_data.program_schedule_df.loc[logical_trial, "Port 2"]), 604 ) 605 606 # clear state timer and reset the state start time 607 main_gui.state_timer_text.configure(text=(state + "Time:")) 608 exp_data.state_start_time = time.time() 609 610 initial_time_interval = exp_data.program_schedule_df.loc[ 611 logical_trial, state 612 ] 613 614 main_gui.update_on_state_change(initial_time_interval, state) 615 616 # tell tkinter main loop that we want to trigger DOOR OPEN state after initial_time_interval milliseconds 617 iti_ttc_transition = main_gui.after( 618 int(initial_time_interval), 619 lambda: trigger("DOOR OPEN"), 620 ) 621 622 # add the transition to scheduled tasks dict so that 623 main_gui.scheduled_tasks["ITI TO DOOR OPEN"] = iti_ttc_transition 624 625 logging.info( 626 f"STATE CHANGE: ITI BEGINS NOW for trial -> {exp_data.current_trial_number}, completes in {initial_time_interval}." 627 ) 628 except Exception as e: 629 logging.error(f"Error in initial time interval: {e}") 630 raise 631 632 633class OpeningDoor: 634 """ 635 Intermediate state between `ITI` and `TTC`. 636 """ 637 638 # we are going to need to pass in the arduino controller as well 639 def __init__( 640 self, 641 exp_data: ExperimentProcessData, 642 main_gui: MainGUI, 643 arduino_controller: ArduinoManager, 644 state: str, 645 trigger: Callable[[str], None], 646 ): 647 """ 648 In the initialization steps for this state we command the door down, then wait `DOOR_MOVE_TIME` to move to the `TTC` state. 649 """ 650 # send comment to arduino to move the door down 651 down_command = "DOWN\n".encode("utf-8") 652 arduino_controller.send_command(command=down_command) 653 654 # after the door is down, then we will begin the ttc state logic, found in run_ttc 655 main_gui.after(DOOR_MOVE_TIME, lambda: trigger("TTC")) 656 657 # state start time begins 658 exp_data.state_start_time = time.time() 659 660 # show current_time / door close time to avoid confusion 661 main_gui.update_on_state_change(DOOR_MOVE_TIME, state) 662 663 664class TimeToContact: 665 """ 666 State class for time to contact experiment state 667 """ 668 669 def __init__( 670 self, 671 exp_data: ExperimentProcessData, 672 arduino_controller: ArduinoManager, 673 main_gui: MainGUI, 674 state: str, 675 trigger: Callable[[str], None], 676 ): 677 """ 678 This function transitions the program into the `TTC` state. We can only reach this state from `OPENING DOOR`. 679 680 We transition into `TTC` by resetting the state timer, instructing the Arduino that the trial has started, and scheduling a transition into 681 `TRIAL END` if the rat does not engage in this trial. This is cancelled in sample time if 3 licks or more from `TTC` are detected in the `models.arduino_data` 682 module's `handle_licks` method. 683 684 Parameters 685 ---------- 686 - **exp_data** (*ExperimentProcessData*): Reference to the `models.experiment_process_data`. Here we use it to get a reference to event_data to reset state time 687 and find `TTC` state time. 688 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` instance, this is the method by which the Arduino is communicated 689 with in the program. Used here to tell Arduino board trial has begun. 690 - **main_gui** (*MainGUI*): A reference to `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 691 update GUI with new state time. 692 - **state** (*str*): State is used to update GUI with current state and grab the state duration time from the program schedule df. 693 - **trigger** (*Callback method*): This callback is passed in so the `TRIAL END` state can be triggered if trial is not engaged. 694 """ 695 try: 696 logging.info( 697 f"STATE CHANGE: DOOR OPEN -> TTC NOW for trial -> {exp_data.current_trial_number}." 698 ) 699 700 logical_trial = exp_data.current_trial_number - 1 701 702 # tell the arduino that the trial begins now, becuase the rats are able to licks beginning now 703 command = "TRIAL START\n".encode("utf-8") 704 arduino_controller.send_command(command) 705 706 main_gui.state_timer_text.configure(text=(state + " Time:")) 707 708 # state time starts now, as does trial because lick availabilty starts now 709 exp_data.state_start_time = time.time() 710 exp_data.trial_start_time = time.time() 711 712 # find the amount of time available for TTC for this trial 713 time_to_contact = exp_data.program_schedule_df.loc[logical_trial, state] 714 715 main_gui.update_on_state_change(time_to_contact, state) 716 717 # set a state change to occur after the time_to_contact time, this will be cancelled if the laser arduino 718 # sends 3 licks befote the TTC_time 719 ttc_iti_transition = main_gui.after( 720 int(time_to_contact), lambda: trigger("TRIAL END") 721 ) 722 723 # store this task id so that it can be cancelled if the sample time transition is taken. 724 main_gui.scheduled_tasks["TTC TO TRIAL END"] = ttc_iti_transition 725 726 logging.info( 727 f"STATE CHANGE: TTC BEGINS NOW for trial -> {exp_data.current_trial_number}, completes in {time_to_contact}." 728 ) 729 except Exception as e: 730 logging.error(f"Error in TTC state start: {e}") 731 raise 732 733 734class SampleTime: 735 """ 736 Sample state class to handle setup of `Sample` state. 737 """ 738 739 def __init__( 740 self, 741 exp_data: ExperimentProcessData, 742 main_gui: MainGUI, 743 arduino_controller: ArduinoManager, 744 state: str, 745 trigger: Callable[[str], None], 746 ): 747 """ 748 This function transitions the program into the `SAMPLE` state. We can only reach this state from `TTC` if 3 licks or more are detected in `TTC`. 749 750 We transition into `SAMPLE` by resetting licks to zero to count only sample time licks for trial, updating the actual `TTC` time taken before engaging in the 751 trial, cancelling the scheduled `TTC` to `TRIAL END` transition, instructing Arduino to begin opening valves when licks occur, and updating state start time. 752 753 Parameters 754 ---------- 755 - **exp_data** (*ExperimentProcessData*): Reference to the `models.experiment_process_data`. Here we use it to get a reference to event_data to reset state time 756 and find `SAMPLE` state time, reset trial lick data. 757 - **main_gui** (*MainGUI*): A reference to `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 758 update GUI with new state info and cancel `TTC` to `TRIAL END` transition. 759 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` instance, this is the method by which the Arduino is communicated 760 with in the program. Used here to tell Arduino to begin opening valves when licks are detected. 761 - **state** (*str*): State is used to update GUI with current state and grab the state duration time from the program schedule df. 762 - **trigger** (*Callback method*): This callback is passed in so the `TRIAL END` state can be triggered after `SAMPLE` time. 763 764 Methods 765 ------- 766 - `update_ttc_time`(exp_data, logical_trial) 767 Updates program schedule dataframe with actual time used in the `TTC` state. 768 """ 769 try: 770 logical_trial = exp_data.current_trial_number - 1 771 772 event_data = exp_data.event_data 773 774 # reset trial licks to count only sample licks 775 event_data.side_one_licks = 0 776 event_data.side_two_licks = 0 777 778 self.update_ttc_time(exp_data, logical_trial) 779 780 # since the trial was engaged, cancel the planned transition from ttc to trial end. program has branched to new state 781 main_gui.after_cancel(main_gui.scheduled_tasks["TTC TO TRIAL END"]) 782 783 # Tell the laser arduino to begin accepting licks and opening valves 784 # resetting the lick counters for both spouts 785 # tell the laser to begin opening valves on licks 786 open_command = "BEGIN OPEN VALVES\n".encode("utf-8") 787 arduino_controller.send_command(command=open_command) 788 789 exp_data.state_start_time = time.time() 790 791 sample_interval_value = exp_data.program_schedule_df.loc[ 792 logical_trial, state 793 ] 794 795 main_gui.update_on_state_change(sample_interval_value, state) 796 797 main_gui.after( 798 int(sample_interval_value), 799 lambda: trigger("TRIAL END"), 800 ) 801 802 logging.info( 803 f"STATE CHANGE: SAMPLE BEGINS NOW for trial-> {exp_data.current_trial_number}, completes in {sample_interval_value}." 804 ) 805 except Exception as e: 806 logging.error( 807 f"Error in sample time trial no. {exp_data.current_trial_number}: {e}" 808 ) 809 raise 810 811 def update_ttc_time( 812 self, exp_data: ExperimentProcessData, logical_trial: int 813 ) -> None: 814 """ 815 If we reach this code that means the rat licked at least 3 times in `TTC` state 816 so we didn't take all the allocated time. update program schedule window with actual time taken 817 """ 818 ttc_time = (time.time() - exp_data.state_start_time) * 1000 819 820 exp_data.program_schedule_df.loc[logical_trial, "TTC Actual"] = round( 821 ttc_time, 3 822 ) 823 824 825class TrialEnd: 826 """ 827 Handle end of trial operations such as data storage and GUI updates with gathered trial data. 828 829 Methods 830 ------- 831 - `arduino_trial_end`(arduino_controller): Handle Arduino end of trial, send door up, stop accepting licks. 832 - `end_trial`(exp_data: ExperimentProcessData): Determine if this trial is the last, if not increment trial number in `models.experiment_process_data`. 833 - `handle_from_ttc`(logical_trial, trigger), exp_data): Call `update_ttc_actual` to update TTC time. Trigger transition to `ITI`. 834 - `update_schedule_licks`(logical_trial, exp_data) -> None: Update program schedule df with licks for this trial on each port. 835 - `update_raster_plots`(exp_data, logical_trial, main_gui) -> None: Update raster plot with licks from this trial. 836 """ 837 838 def __init__( 839 self, 840 exp_data: ExperimentProcessData, 841 main_gui: MainGUI, 842 arduino_controller: ArduinoManager, 843 prev_state: str, 844 trigger: Callable[[str], None], 845 ) -> None: 846 """ 847 This function transitions the program into the `SAMPLE` state. We can only reach this state from `TTC` if 3 licks or more are detected in `TTC`. 848 849 We transition into `SAMPLE` by resetting licks to zero to count only sample time licks for trial, updating the actual `TTC` time taken before engaging in the 850 trial, cancelling the scheduled `TTC` to `TRIAL END` transition, instructing Arduino to begin opening valves when licks occur, and updating state start time. 851 852 Parameters 853 ---------- 854 - **exp_data** (*ExperimentProcessData*): Reference to the `models.experiment_process_data`. Here we use it to update program df with trial licks, increment trial, 855 check if current trial is the final trial, and other data operations. 856 - **main_gui** (*MainGUI*): A reference to `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 857 update program schedule and populate raster plots with trial licks. 858 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` instance, this is the method by which the Arduino is communicated 859 with in the program. Used here to tell Arduino to stop opening valves when licks are detected. 860 - **prev_state** (*str*): prev_state is used to decide which end of trial operations should be executed. 861 - **trigger** (*Callback method*): This callback is passed in so the `ITI` state can be triggered after `TRIAL END` logic is complete. 862 863 Methods 864 ------- 865 - `update_ttc_time`(exp_data, logical_trial) 866 Updates program schedule dataframe with actual time used in the `TTC` state. 867 """ 868 869 # use the same logical trial for all functions current_trial_number - 1 870 # to account for 1 indexing 871 logical_trial = exp_data.current_trial_number - 1 872 873 self.arduino_trial_end(arduino_controller) 874 875 #######TRIAL NUMBER IS INCREMENTED INSIDE OF END_TRIAL####### 876 if self.end_trial(exp_data): 877 program_schedule = main_gui.windows["Program Schedule"] 878 # if the experiment is over update the licks for the final trial 879 self.update_schedule_licks(logical_trial, exp_data) 880 program_schedule.refresh_end_trial(logical_trial) 881 882 trigger("STOP") 883 # return from the call / kill the working thread 884 return 885 886 # update licks for this trial 887 self.update_schedule_licks(logical_trial, exp_data) 888 889 match prev_state: 890 case "TTC": 891 self.update_ttc_actual(logical_trial, exp_data) 892 trigger("ITI") 893 case "SAMPLE": 894 self.update_raster_plots(exp_data, logical_trial, main_gui) 895 trigger("ITI") 896 case _: 897 # cases not explicitly defined go here 898 logger.error("UNDEFINED PREVIOUS TRANSITION IN TRIAL END STATE") 899 900 main_gui.windows["Program Schedule"].refresh_end_trial(logical_trial) 901 902 def arduino_trial_end(self, arduino_controller: ArduinoManager) -> None: 903 """ 904 Send rig door up and tell Arduino to stop accepting licks. 905 """ 906 up_command = "UP\n".encode("utf-8") 907 arduino_controller.send_command(command=up_command) 908 909 stop_command = "STOP OPEN VALVES\n".encode("utf-8") 910 arduino_controller.send_command(command=stop_command) 911 912 def end_trial(self, exp_data: ExperimentProcessData) -> bool: 913 """ 914 This method checks if current trial is the last trial. If it is, we return true to the calling code. 915 If false, we increment current_trial_number, return false, and proceed to next trial. 916 """ 917 current_trial = exp_data.current_trial_number 918 max_trials = exp_data.exp_var_entries["Num Trials"] 919 920 if current_trial >= max_trials: 921 return True 922 923 exp_data.current_trial_number += 1 924 return False 925 926 def update_ttc_actual( 927 self, logical_trial: int, exp_data: ExperimentProcessData 928 ) -> None: 929 """ 930 Update the actual ttc time take in the program schedule with the max time value, 931 since we reached trial end we know we took the max time allowed 932 """ 933 program_df = exp_data.program_schedule_df 934 935 ttc_column_value = program_df.loc[logical_trial, "TTC"] 936 program_df.loc[logical_trial, "TTC Actual"] = ttc_column_value 937 938 def update_schedule_licks( 939 self, logical_trial: int, exp_data: ExperimentProcessData 940 ) -> None: 941 """Update the licks for each side in the program schedule df for this trial""" 942 program_df = exp_data.program_schedule_df 943 event_data = exp_data.event_data 944 945 licks_sd_one = event_data.side_one_licks 946 licks_sd_two = event_data.side_two_licks 947 948 program_df.loc[logical_trial, "Port 1 Licks"] = licks_sd_one 949 950 program_df.loc[logical_trial, "Port 2 Licks"] = licks_sd_two 951 952 def update_raster_plots( 953 self, exp_data: ExperimentProcessData, logical_trial: int, main_gui: MainGUI 954 ) -> None: 955 """Instruct raster windows to update with new lick timestamps""" 956 # gather lick timestamp data from the event data model 957 lick_stamps = exp_data.event_data.get_lick_timestamps(logical_trial) 958 959 # send it to raster windows 960 for i, window in enumerate(main_gui.windows["Raster Plot"]): 961 window.update_plot(lick_stamps[i], logical_trial)
Logger used to log program runtime details for debugging
The console handler is used to print errors to the console
This utilizes system_config
module to obtain a constant path for this machine regardless of OS to the Rig Files foler
at the current users documents folder.
Constant value stored in the door_motor_config section of RIG CONFIG
config
71class StateMachine: 72 """ 73 This class is the heart of the program. Defines and handles program state transitions. 74 It coordinates co-operation between view (gui) and models (data). We inherit a TkinterApp class to include 75 attributes of a tkinter app such as gui, arduino controller, etc. 76 77 Attributes 78 ---------- 79 - **exp_data** (*ExperimentProcessData*): An instance of `models.experiment_process_data` ExperimentProcessData, this attribute allows for access and 80 modification of experiment variables, and access for to more specific models like `models.event_data` and `models.arduino_data`. 81 - **arduino_controller** (*ArduinoManager*): An instance of `controllers.arduino_control` ArduinoManager, this allows for communication between this program and the 82 Arduino board. 83 - **main_gui** (*MainGUI*): An instance of `views.main_gui` MainGUI, this allows for the creation and modification of all GUI attributes in the program. All GUI windows 84 are created and managed here. 85 - **state** (*str*): Contains the current state the program is in. 86 - **prev_state** (*str*): Contains the state the program was previously in. Useful to restore state in case of erroneous transitions. 87 - **app_result** (*list*): Mutable list with one element. Is a reference to list defined in `main`. 88 - **transitions** (*dict*): Program state transition table. 89 90 Methods 91 ------- 92 - `trigger`(event) 93 Handles state transition events. Decides if a transition is valid and warns user of destructive transitions. If valid, 94 passes state to `execute_state` method. 95 - `execute_state`(new_state: str) 96 Takes the state passed from trigger event and decides appropriate action. 97 - `process_queue`(data_queue) 98 Processes incoming data from the Arduino board. Reads from queue that is added to by `controllers.arduino_control` module 99 `listen_for_serial` method. 100 - `reject_actions`(event) 101 A static method that handles the rejection of actions that cannot be performed given a certain state transition. 102 """ 103 104 def __init__(self, result_container): 105 """init method for `StateMachine`. Takes `main` module `result_container`. 106 the 'super' parent class which is the gui""" 107 108 self.exp_data = ExperimentProcessData() 109 110 self.arduino_controller = ArduinoManager(self.exp_data) 111 112 self.main_gui = MainGUI(self.exp_data, self.trigger, self.arduino_controller) 113 logging.info("GUI started successfully.") 114 115 self.state = "IDLE" 116 """Default state for program is set at IDLE""" 117 118 self.prev_state = None 119 """Default previous state for program is set to None. Utilized in `trigger`.""" 120 121 self.app_result = result_container 122 """ 123 Here we store a reference to `main` module `result_container` to store decision of whether to restart the 124 program or just terminate the current instance. 125 """ 126 127 self.transitions = { 128 ("IDLE", "GENERATE SCHEDULE"): "GENERATE SCHEDULE", 129 ( 130 "GENERATE SCHEDULE", 131 "IDLE", 132 ): "IDLE", 133 ("IDLE", "START"): "START PROGRAM", 134 ("IDLE", "RESET"): "RESET PROGRAM", 135 ("STOP PROGRAM", "RESET"): "RESET PROGRAM", 136 ("START PROGRAM", "ITI"): "ITI", 137 ("ITI", "DOOR OPEN"): "OPENING DOOR", 138 ("OPENING DOOR", "TTC"): "TTC", 139 ("TTC", "SAMPLE"): "SAMPLE", # -> if rat engages in trial 140 ("TTC", "TRIAL END"): "TRIAL END", # -> if rat does NOT engage 141 ("SAMPLE", "TRIAL END"): "TRIAL END", 142 ("TRIAL END", "ITI"): "ITI", # -> save trial data, update gui, door up 143 ("ITI", "STOP"): "STOP PROGRAM", # can move to 'stop' state in all states 144 ("OPENING DOOR", "STOP"): "STOP PROGRAM", 145 ("TTC", "STOP"): "STOP PROGRAM", 146 ("SAMPLE", "STOP"): "STOP PROGRAM", 147 ("TRIAL END", "STOP"): "STOP PROGRAM", 148 } 149 """State transition table defines all transitions that the program can possibly take""" 150 151 # don't start the mainloop until AFTER the gui is setup so that the app_result property 152 # is available if reset is desired 153 self.main_gui.mainloop() 154 155 def trigger(self, event): 156 """ 157 This function takes a requested state and decides if the transition from this state is allowed. 158 159 Parameters 160 ---------- 161 - **event** (*str*): Contains the desired state to move to. 162 """ 163 164 transition = (self.state, event) 165 self.prev_state = self.state 166 new_state = None 167 168 logger.info(f"state transition -> {transition}") 169 170 # checking if the attemped transition is valid according to the table 171 if transition in self.transitions: 172 new_state = self.transitions[transition] 173 else: 174 # if the key is not in the transition table, handle rejection based on desired state 175 self.reject_actions(event) 176 177 # if the key was in the transition table, and the new state wants to reset the program, that means this is a valid action at this time. MAKE SURE the 178 # user REALLY wants to do that 179 if new_state == "RESET PROGRAM": 180 response = GUIUtils.askyesno( 181 "============WARNING============", 182 "THIS ACTION WILL ERASE ALL DATA CURRENTLY STORED FOR THIS EXPERIMENT... ARE YOU SURE YOU WANT TO CONTINUE?", 183 ) 184 # if true, go ahead with the reset, if false return to prev_state 185 if not response: 186 new_state = self.prev_state 187 188 # if attempt to start program with no experiment schedule, tell user to do that 189 if new_state == "START PROGRAM": 190 if self.exp_data.program_schedule_df.empty: 191 GUIUtils.display_error( 192 "Experiement Schedule Not Yet Generated!!!", 193 "Please generate the program schedule before attempting to start the experiment. You can do this by clicking the 'Valve / Stimuli' button in the main screen.", 194 ) 195 new_state = self.prev_state 196 197 # if we have a new state, perform the associated action for that state 198 if new_state: 199 logger.info(f"new state -> {new_state}") 200 self.execute_state(new_state) 201 202 def execute_state(self, new_state: str) -> None: 203 """ 204 Executes the action corresponding to new_state. if the action is defined under state_target, 205 that action will be taken in a new thread to avoid overloading the main tkinter thread. otherwise 206 it is executed immediately in the main thread. 207 208 Parameters 209 ---------- 210 - **new_state** (*str*): Contains the desired state to move to. Used to locate the desired action. 211 """ 212 213 thread_target = None 214 215 match new_state: 216 case "START PROGRAM": 217 218 def thread_target(): 219 StartProgram( 220 self.exp_data, 221 self.main_gui, 222 self.arduino_controller, 223 self.trigger, 224 ) 225 case "RESET PROGRAM": 226 ResetProgram(self.main_gui, self.app_result, self.arduino_controller) 227 case "STOP PROGRAM": 228 StopProgram(self.main_gui, self.arduino_controller, self.trigger) 229 case "GENERATE SCHEDULE": 230 # because this will run in main thread, we need to return early to avoid self.state 231 # assignment confusion 232 self.state = new_state 233 GenerateSchedule( 234 self.main_gui, 235 self.arduino_controller, 236 self.process_queue, 237 self.trigger, 238 ) 239 return 240 case "ITI": 241 242 def thread_target(): 243 InitialTimeInterval( 244 self.exp_data, 245 self.main_gui, 246 new_state, 247 self.trigger, 248 ) 249 case "OPENING DOOR": 250 251 def thread_target(): 252 OpeningDoor( 253 self.exp_data, 254 self.main_gui, 255 self.arduino_controller, 256 new_state, 257 self.trigger, 258 ) 259 case "TTC": 260 261 def thread_target(): 262 TimeToContact( 263 self.exp_data, 264 self.arduino_controller, 265 self.main_gui, 266 new_state, 267 self.trigger, 268 ) 269 case "SAMPLE": 270 271 def thread_target(): 272 SampleTime( 273 self.exp_data, 274 self.main_gui, 275 self.arduino_controller, 276 new_state, 277 self.trigger, 278 ) 279 case "TRIAL END": 280 281 def thread_target(): 282 TrialEnd( 283 self.exp_data, 284 self.main_gui, 285 self.arduino_controller, 286 self.prev_state, 287 self.trigger, 288 ) 289 290 self.prev_state = self.state 291 self.state = new_state 292 293 if thread_target: 294 threading.Thread(target=thread_target).start() 295 296 def process_queue(self, data_queue: queue.Queue[tuple[str, str]]) -> None: 297 """ 298 Process the data queue. Pulls in data from the thread that is reading data from the arduinos constantly. if there is anything 299 in the queue at time of function call, we will stay here until its all been dealt with. 300 301 Parameters 302 ---------- 303 - **data_queue** (*tuple[str, str]*): This is a shared queue between a thread initiated in the `controllers.arduino_control` module's 304 `listen_for_serial` method. That thread constantly reads info from Arduino so it is not missed, and the main thread calls this process method 305 to process accumulated data to avoid overloading the thread. 306 """ 307 308 arduino_data = self.exp_data.arduino_data 309 try: 310 while not data_queue.empty(): 311 source, data = data_queue.get() 312 arduino_data.process_data(source, data, self.state, self.trigger) 313 314 # run this command every 250ms 315 queue_ps_id = self.main_gui.after( 316 250, lambda: self.process_queue(data_queue) 317 ) 318 self.main_gui.scheduled_tasks["PROCESS QUEUE"] = queue_ps_id 319 except Exception as e: 320 logging.error(f"Error processing data queue: {e}") 321 raise 322 323 @staticmethod 324 def reject_actions(event): 325 """ 326 This method handles the rejection of the 'START' and 'RESET' actions. These are rejected when there is no state transition for their current state. 327 This is usually during program runtime for reset or before schedule generation for start. 328 329 Parameters 330 ---------- 331 - **event** (*str*): This is the desired state being rejected here. 332 """ 333 match event: 334 case "RESET": 335 GUIUtils.display_error( 336 "CANNOT PERFORM THIS ACTION", 337 "RESET cannot be performed during runtime. Stop the program to reset.", 338 ) 339 # if key is not in table but we try to start (for example, STOP PROGRAM -> START) we do NOT want to allow this until a full reset has been completed 340 case "START": 341 GUIUtils.display_error( 342 "CANNOT PERFORM THIS ACTION", 343 "Experiement has already been executed. Before running another, you need to RESET the application to ensure all data is reset to defaults", 344 ) 345 case _: 346 return
This class is the heart of the program. Defines and handles program state transitions. It coordinates co-operation between view (gui) and models (data). We inherit a TkinterApp class to include attributes of a tkinter app such as gui, arduino controller, etc.
Attributes
- exp_data (ExperimentProcessData): An instance of
models.experiment_process_data
ExperimentProcessData, this attribute allows for access and modification of experiment variables, and access for to more specific models likemodels.event_data
andmodels.arduino_data
. - arduino_controller (ArduinoManager): An instance of
controllers.arduino_control
ArduinoManager, this allows for communication between this program and the Arduino board. - main_gui (MainGUI): An instance of
views.main_gui
MainGUI, this allows for the creation and modification of all GUI attributes in the program. All GUI windows are created and managed here. - state (str): Contains the current state the program is in.
- prev_state (str): Contains the state the program was previously in. Useful to restore state in case of erroneous transitions.
- app_result (list): Mutable list with one element. Is a reference to list defined in
main
. - transitions (dict): Program state transition table.
Methods
trigger
(event) Handles state transition events. Decides if a transition is valid and warns user of destructive transitions. If valid, passes state toexecute_state
method.execute_state
(new_state: str) Takes the state passed from trigger event and decides appropriate action.process_queue
(data_queue) Processes incoming data from the Arduino board. Reads from queue that is added to bycontrollers.arduino_control
modulelisten_for_serial
method.reject_actions
(event) A static method that handles the rejection of actions that cannot be performed given a certain state transition.
104 def __init__(self, result_container): 105 """init method for `StateMachine`. Takes `main` module `result_container`. 106 the 'super' parent class which is the gui""" 107 108 self.exp_data = ExperimentProcessData() 109 110 self.arduino_controller = ArduinoManager(self.exp_data) 111 112 self.main_gui = MainGUI(self.exp_data, self.trigger, self.arduino_controller) 113 logging.info("GUI started successfully.") 114 115 self.state = "IDLE" 116 """Default state for program is set at IDLE""" 117 118 self.prev_state = None 119 """Default previous state for program is set to None. Utilized in `trigger`.""" 120 121 self.app_result = result_container 122 """ 123 Here we store a reference to `main` module `result_container` to store decision of whether to restart the 124 program or just terminate the current instance. 125 """ 126 127 self.transitions = { 128 ("IDLE", "GENERATE SCHEDULE"): "GENERATE SCHEDULE", 129 ( 130 "GENERATE SCHEDULE", 131 "IDLE", 132 ): "IDLE", 133 ("IDLE", "START"): "START PROGRAM", 134 ("IDLE", "RESET"): "RESET PROGRAM", 135 ("STOP PROGRAM", "RESET"): "RESET PROGRAM", 136 ("START PROGRAM", "ITI"): "ITI", 137 ("ITI", "DOOR OPEN"): "OPENING DOOR", 138 ("OPENING DOOR", "TTC"): "TTC", 139 ("TTC", "SAMPLE"): "SAMPLE", # -> if rat engages in trial 140 ("TTC", "TRIAL END"): "TRIAL END", # -> if rat does NOT engage 141 ("SAMPLE", "TRIAL END"): "TRIAL END", 142 ("TRIAL END", "ITI"): "ITI", # -> save trial data, update gui, door up 143 ("ITI", "STOP"): "STOP PROGRAM", # can move to 'stop' state in all states 144 ("OPENING DOOR", "STOP"): "STOP PROGRAM", 145 ("TTC", "STOP"): "STOP PROGRAM", 146 ("SAMPLE", "STOP"): "STOP PROGRAM", 147 ("TRIAL END", "STOP"): "STOP PROGRAM", 148 } 149 """State transition table defines all transitions that the program can possibly take""" 150 151 # don't start the mainloop until AFTER the gui is setup so that the app_result property 152 # is available if reset is desired 153 self.main_gui.mainloop()
init method for StateMachine
. Takes main
module result_container
.
the 'super' parent class which is the gui
Here we store a reference to main
module result_container
to store decision of whether to restart the
program or just terminate the current instance.
155 def trigger(self, event): 156 """ 157 This function takes a requested state and decides if the transition from this state is allowed. 158 159 Parameters 160 ---------- 161 - **event** (*str*): Contains the desired state to move to. 162 """ 163 164 transition = (self.state, event) 165 self.prev_state = self.state 166 new_state = None 167 168 logger.info(f"state transition -> {transition}") 169 170 # checking if the attemped transition is valid according to the table 171 if transition in self.transitions: 172 new_state = self.transitions[transition] 173 else: 174 # if the key is not in the transition table, handle rejection based on desired state 175 self.reject_actions(event) 176 177 # if the key was in the transition table, and the new state wants to reset the program, that means this is a valid action at this time. MAKE SURE the 178 # user REALLY wants to do that 179 if new_state == "RESET PROGRAM": 180 response = GUIUtils.askyesno( 181 "============WARNING============", 182 "THIS ACTION WILL ERASE ALL DATA CURRENTLY STORED FOR THIS EXPERIMENT... ARE YOU SURE YOU WANT TO CONTINUE?", 183 ) 184 # if true, go ahead with the reset, if false return to prev_state 185 if not response: 186 new_state = self.prev_state 187 188 # if attempt to start program with no experiment schedule, tell user to do that 189 if new_state == "START PROGRAM": 190 if self.exp_data.program_schedule_df.empty: 191 GUIUtils.display_error( 192 "Experiement Schedule Not Yet Generated!!!", 193 "Please generate the program schedule before attempting to start the experiment. You can do this by clicking the 'Valve / Stimuli' button in the main screen.", 194 ) 195 new_state = self.prev_state 196 197 # if we have a new state, perform the associated action for that state 198 if new_state: 199 logger.info(f"new state -> {new_state}") 200 self.execute_state(new_state)
This function takes a requested state and decides if the transition from this state is allowed.
Parameters
- event (str): Contains the desired state to move to.
202 def execute_state(self, new_state: str) -> None: 203 """ 204 Executes the action corresponding to new_state. if the action is defined under state_target, 205 that action will be taken in a new thread to avoid overloading the main tkinter thread. otherwise 206 it is executed immediately in the main thread. 207 208 Parameters 209 ---------- 210 - **new_state** (*str*): Contains the desired state to move to. Used to locate the desired action. 211 """ 212 213 thread_target = None 214 215 match new_state: 216 case "START PROGRAM": 217 218 def thread_target(): 219 StartProgram( 220 self.exp_data, 221 self.main_gui, 222 self.arduino_controller, 223 self.trigger, 224 ) 225 case "RESET PROGRAM": 226 ResetProgram(self.main_gui, self.app_result, self.arduino_controller) 227 case "STOP PROGRAM": 228 StopProgram(self.main_gui, self.arduino_controller, self.trigger) 229 case "GENERATE SCHEDULE": 230 # because this will run in main thread, we need to return early to avoid self.state 231 # assignment confusion 232 self.state = new_state 233 GenerateSchedule( 234 self.main_gui, 235 self.arduino_controller, 236 self.process_queue, 237 self.trigger, 238 ) 239 return 240 case "ITI": 241 242 def thread_target(): 243 InitialTimeInterval( 244 self.exp_data, 245 self.main_gui, 246 new_state, 247 self.trigger, 248 ) 249 case "OPENING DOOR": 250 251 def thread_target(): 252 OpeningDoor( 253 self.exp_data, 254 self.main_gui, 255 self.arduino_controller, 256 new_state, 257 self.trigger, 258 ) 259 case "TTC": 260 261 def thread_target(): 262 TimeToContact( 263 self.exp_data, 264 self.arduino_controller, 265 self.main_gui, 266 new_state, 267 self.trigger, 268 ) 269 case "SAMPLE": 270 271 def thread_target(): 272 SampleTime( 273 self.exp_data, 274 self.main_gui, 275 self.arduino_controller, 276 new_state, 277 self.trigger, 278 ) 279 case "TRIAL END": 280 281 def thread_target(): 282 TrialEnd( 283 self.exp_data, 284 self.main_gui, 285 self.arduino_controller, 286 self.prev_state, 287 self.trigger, 288 ) 289 290 self.prev_state = self.state 291 self.state = new_state 292 293 if thread_target: 294 threading.Thread(target=thread_target).start()
Executes the action corresponding to new_state. if the action is defined under state_target, that action will be taken in a new thread to avoid overloading the main tkinter thread. otherwise it is executed immediately in the main thread.
Parameters
- new_state (str): Contains the desired state to move to. Used to locate the desired action.
296 def process_queue(self, data_queue: queue.Queue[tuple[str, str]]) -> None: 297 """ 298 Process the data queue. Pulls in data from the thread that is reading data from the arduinos constantly. if there is anything 299 in the queue at time of function call, we will stay here until its all been dealt with. 300 301 Parameters 302 ---------- 303 - **data_queue** (*tuple[str, str]*): This is a shared queue between a thread initiated in the `controllers.arduino_control` module's 304 `listen_for_serial` method. That thread constantly reads info from Arduino so it is not missed, and the main thread calls this process method 305 to process accumulated data to avoid overloading the thread. 306 """ 307 308 arduino_data = self.exp_data.arduino_data 309 try: 310 while not data_queue.empty(): 311 source, data = data_queue.get() 312 arduino_data.process_data(source, data, self.state, self.trigger) 313 314 # run this command every 250ms 315 queue_ps_id = self.main_gui.after( 316 250, lambda: self.process_queue(data_queue) 317 ) 318 self.main_gui.scheduled_tasks["PROCESS QUEUE"] = queue_ps_id 319 except Exception as e: 320 logging.error(f"Error processing data queue: {e}") 321 raise
Process the data queue. Pulls in data from the thread that is reading data from the arduinos constantly. if there is anything in the queue at time of function call, we will stay here until its all been dealt with.
Parameters
- data_queue (tuple[str, str]): This is a shared queue between a thread initiated in the
controllers.arduino_control
module'slisten_for_serial
method. That thread constantly reads info from Arduino so it is not missed, and the main thread calls this process method to process accumulated data to avoid overloading the thread.
323 @staticmethod 324 def reject_actions(event): 325 """ 326 This method handles the rejection of the 'START' and 'RESET' actions. These are rejected when there is no state transition for their current state. 327 This is usually during program runtime for reset or before schedule generation for start. 328 329 Parameters 330 ---------- 331 - **event** (*str*): This is the desired state being rejected here. 332 """ 333 match event: 334 case "RESET": 335 GUIUtils.display_error( 336 "CANNOT PERFORM THIS ACTION", 337 "RESET cannot be performed during runtime. Stop the program to reset.", 338 ) 339 # if key is not in table but we try to start (for example, STOP PROGRAM -> START) we do NOT want to allow this until a full reset has been completed 340 case "START": 341 GUIUtils.display_error( 342 "CANNOT PERFORM THIS ACTION", 343 "Experiement has already been executed. Before running another, you need to RESET the application to ensure all data is reset to defaults", 344 ) 345 case _: 346 return
This method handles the rejection of the 'START' and 'RESET' actions. These are rejected when there is no state transition for their current state. This is usually during program runtime for reset or before schedule generation for start.
Parameters
- event (str): This is the desired state being rejected here.
349class GenerateSchedule: 350 """ 351 This class handles the schedule generation state. It is called once the user has input the amount of simuli for this experiment, the 352 number of trial blocks, changed the names of the stimuli in each cylinder/valve in the 'Valve / Stimuli' window, and pressed generate schedule. 353 354 The main things handled here are the updating of gui objects that needed the previously discussed info to be created (e.g raster plots need 355 total trials, program schedule window needed the experiment data, etc.) 356 """ 357 358 def __init__( 359 self, 360 main_gui: MainGUI, 361 arduino_controller: ArduinoManager, 362 process_queue: Callable[[queue.Queue[tuple[str, str]]], None], 363 trigger: Callable[[str], None], 364 ): 365 """ 366 Initialize and handle the GenerateSchedule class state. 367 368 Parameters 369 ---------- 370 - **main_gui** (*MainGUI*): A reference to the `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 371 update show the program schedule and create raster plots. 372 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` Arduino controller instance, this is the method by which the Arduino is communicated 373 with in the program. Used to send schedules, variables, and valve durations here. 374 - **process_queue** (*Callback method*): This callback method is passed here so that the Arduino listener process thread can be started once the listener thread 375 is running. It does NOT run prior to this so that sending individual bytes (send schedule, etc.) is performed without having data stolen away by the constantly running 376 listener thread. 377 - **trigger** (*Callback method*): This callback is passed in so that this state can trigger a transition back to `IDLE` when it is finished with its work. 378 """ 379 380 # show the program sched window via the callback passed into the class at initialization 381 main_gui.show_secondary_window("Program Schedule") 382 383 # create plots using generated number of trials as max Y values 384 for window in main_gui.windows["Raster Plot"]: 385 window.create_plot() 386 387 # send exp variables, schedule, and valve open durations stored in arduino_data.toml to the arduino 388 arduino_controller.send_experiment_variables() 389 arduino_controller.send_schedule_data() 390 arduino_controller.send_valve_durations() 391 392 # start Arduino process queue and listener thread so we know when Arduino tries to tell us something 393 process_queue(arduino_controller.data_queue) 394 395 arduino_controller.listener_thread = threading.Thread( 396 target=arduino_controller.listen_for_serial 397 ) 398 399 arduino_controller.listener_thread.start() 400 401 logger.info("Started listening thread for Arduino serial input.") 402 403 # transition back to idle 404 trigger("IDLE")
This class handles the schedule generation state. It is called once the user has input the amount of simuli for this experiment, the number of trial blocks, changed the names of the stimuli in each cylinder/valve in the 'Valve / Stimuli' window, and pressed generate schedule.
The main things handled here are the updating of gui objects that needed the previously discussed info to be created (e.g raster plots need total trials, program schedule window needed the experiment data, etc.)
358 def __init__( 359 self, 360 main_gui: MainGUI, 361 arduino_controller: ArduinoManager, 362 process_queue: Callable[[queue.Queue[tuple[str, str]]], None], 363 trigger: Callable[[str], None], 364 ): 365 """ 366 Initialize and handle the GenerateSchedule class state. 367 368 Parameters 369 ---------- 370 - **main_gui** (*MainGUI*): A reference to the `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 371 update show the program schedule and create raster plots. 372 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` Arduino controller instance, this is the method by which the Arduino is communicated 373 with in the program. Used to send schedules, variables, and valve durations here. 374 - **process_queue** (*Callback method*): This callback method is passed here so that the Arduino listener process thread can be started once the listener thread 375 is running. It does NOT run prior to this so that sending individual bytes (send schedule, etc.) is performed without having data stolen away by the constantly running 376 listener thread. 377 - **trigger** (*Callback method*): This callback is passed in so that this state can trigger a transition back to `IDLE` when it is finished with its work. 378 """ 379 380 # show the program sched window via the callback passed into the class at initialization 381 main_gui.show_secondary_window("Program Schedule") 382 383 # create plots using generated number of trials as max Y values 384 for window in main_gui.windows["Raster Plot"]: 385 window.create_plot() 386 387 # send exp variables, schedule, and valve open durations stored in arduino_data.toml to the arduino 388 arduino_controller.send_experiment_variables() 389 arduino_controller.send_schedule_data() 390 arduino_controller.send_valve_durations() 391 392 # start Arduino process queue and listener thread so we know when Arduino tries to tell us something 393 process_queue(arduino_controller.data_queue) 394 395 arduino_controller.listener_thread = threading.Thread( 396 target=arduino_controller.listen_for_serial 397 ) 398 399 arduino_controller.listener_thread.start() 400 401 logger.info("Started listening thread for Arduino serial input.") 402 403 # transition back to idle 404 trigger("IDLE")
Initialize and handle the GenerateSchedule class state.
Parameters
- main_gui (MainGUI): A reference to the
views.main_gui
MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to update show the program schedule and create raster plots. - arduino_controller (ArduinoManager): A reference to the
controllers.arduino_control
Arduino controller instance, this is the method by which the Arduino is communicated with in the program. Used to send schedules, variables, and valve durations here. - process_queue (Callback method): This callback method is passed here so that the Arduino listener process thread can be started once the listener thread is running. It does NOT run prior to this so that sending individual bytes (send schedule, etc.) is performed without having data stolen away by the constantly running listener thread.
- trigger (Callback method): This callback is passed in so that this state can trigger a transition back to
IDLE
when it is finished with its work.
407class StartProgram: 408 """ 409 This class handles the remaining set-up steps to prepare for experiment runtime. It then triggers the experiment. 410 """ 411 412 def __init__( 413 self, 414 exp_data: ExperimentProcessData, 415 main_gui: MainGUI, 416 arduino_controller: ArduinoManager, 417 trigger: Callable[[str], None], 418 ): 419 """ 420 Parameters 421 ---------- 422 - **exp_data** (*ExperimentProcessData*): Reference to the `models.experiment_process_data`. Here we use it to mark experiment and state start times. 423 - **main_gui** (*MainGUI*): A reference to `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 424 update clock labels and max program runtime. 425 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` instance, this is the method by which the Arduino is communicated 426 with in the program. Used to tell Arduino that program begins now. 427 - **trigger** (*Callback method*): This callback is passed in so that this state can trigger a transition to `ITI` when it is finished with its work. 428 """ 429 try: 430 # update experiment data model program start time and state start time variables with current time 431 exp_data.start_time = time.time() 432 exp_data.state_start_time = time.time() 433 434 # tell Arduino that experiment starts now so it knows how to calculate timestamps 435 start_command = "T=0\n".encode("utf-8") 436 arduino_controller.send_command(command=start_command) 437 438 main_gui.update_clock_label() 439 main_gui.update_max_time() 440 441 # change the green start button into a red stop button, update the associated command 442 main_gui.start_button.configure( 443 text="Stop", bg="red", command=lambda: trigger("STOP") 444 ) 445 446 logging.info("==========EXPERIMENT BEGINS NOW==========") 447 448 trigger("ITI") 449 except Exception as e: 450 logging.error(f"Error starting program: {e}") 451 raise
This class handles the remaining set-up steps to prepare for experiment runtime. It then triggers the experiment.
412 def __init__( 413 self, 414 exp_data: ExperimentProcessData, 415 main_gui: MainGUI, 416 arduino_controller: ArduinoManager, 417 trigger: Callable[[str], None], 418 ): 419 """ 420 Parameters 421 ---------- 422 - **exp_data** (*ExperimentProcessData*): Reference to the `models.experiment_process_data`. Here we use it to mark experiment and state start times. 423 - **main_gui** (*MainGUI*): A reference to `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 424 update clock labels and max program runtime. 425 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` instance, this is the method by which the Arduino is communicated 426 with in the program. Used to tell Arduino that program begins now. 427 - **trigger** (*Callback method*): This callback is passed in so that this state can trigger a transition to `ITI` when it is finished with its work. 428 """ 429 try: 430 # update experiment data model program start time and state start time variables with current time 431 exp_data.start_time = time.time() 432 exp_data.state_start_time = time.time() 433 434 # tell Arduino that experiment starts now so it knows how to calculate timestamps 435 start_command = "T=0\n".encode("utf-8") 436 arduino_controller.send_command(command=start_command) 437 438 main_gui.update_clock_label() 439 main_gui.update_max_time() 440 441 # change the green start button into a red stop button, update the associated command 442 main_gui.start_button.configure( 443 text="Stop", bg="red", command=lambda: trigger("STOP") 444 ) 445 446 logging.info("==========EXPERIMENT BEGINS NOW==========") 447 448 trigger("ITI") 449 except Exception as e: 450 logging.error(f"Error starting program: {e}") 451 raise
Parameters
- exp_data (ExperimentProcessData): Reference to the
models.experiment_process_data
. Here we use it to mark experiment and state start times. - main_gui (MainGUI): A reference to
views.main_gui
MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to update clock labels and max program runtime. - arduino_controller (ArduinoManager): A reference to the
controllers.arduino_control
instance, this is the method by which the Arduino is communicated with in the program. Used to tell Arduino that program begins now. - trigger (Callback method): This callback is passed in so that this state can trigger a transition to
ITI
when it is finished with its work.
454class StopProgram: 455 """ 456 Stops and updates program to reflect `IDLE` state. 457 458 Methods 459 ------- 460 - `finalize_program`(main_gui, arduino_controller) 461 This method waits until the door closes for the last time, then cancels the last scheduled task, stops the listener thread, closes Arduino connections, and 462 instructs the user to save their data. 463 """ 464 465 def __init__( 466 self, 467 main_gui: MainGUI, 468 arduino_controller: ArduinoManager, 469 trigger: Callable[[str], None], 470 ) -> None: 471 """ 472 Parameters 473 ---------- 474 - **main_gui** (*MainGUI*): A reference to `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 475 call update_on_stop, configure the start/stop button back to start, and cancel tkinter scheduled tasks. 476 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` instance, this is the method by which the Arduino is communicated 477 with in the program. Used to reset Arduino and clear all left-over experiment data. 478 - **trigger** (*Callback method*): This callback is passed in so the start button can be configured to command a `START` state. 479 """ 480 try: 481 main_gui.update_on_stop() 482 # change the command back to start for the start button. (STOP PROGRAM, START) is not a defined transition, so the 483 # trigger function will call reject_actions to let the user know the program has already ran and they need to reset the app. 484 main_gui.start_button.configure( 485 text="Start", bg="green", command=lambda: trigger("START") 486 ) 487 488 # stop all scheduled tasks EXCEPT for the process queue, we are still waiting for the last door close timestamp 489 for desc, sched_task in main_gui.scheduled_tasks.items(): 490 if desc != "PROCESS QUEUE": 491 main_gui.after_cancel(sched_task) 492 493 # schedule finalization after door will be down 494 main_gui.scheduled_tasks["FINALIZE"] = main_gui.after( 495 5000, lambda: self.finalize_program(main_gui, arduino_controller) 496 ) 497 498 logging.info("Program stopped... waiting to finalize...") 499 except Exception as e: 500 logging.error(f"Error stopping program: {e}") 501 raise 502 503 def finalize_program( 504 self, main_gui: MainGUI, arduino_controller: ArduinoManager 505 ) -> None: 506 """ 507 Finalize the program by resetting the Arduino board, offer to save the data frames into xlsx files. 508 509 Parameters 510 ---------- 511 - **main_gui** (*MainGUI*): A reference to `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 512 cancel the last tkinter.after call. 513 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` instance, this is the method by which the Arduino is communicated 514 with in the program. Used to reset Arduino board and close the connection to it. 515 """ 516 try: 517 # stop the arduino listener so that the program can shut down 518 # and not be blocked 519 arduino_controller.stop_listener_thread() 520 521 queue_id = main_gui.scheduled_tasks["PROCESS QUEUE"] 522 main_gui.after_cancel(queue_id) 523 524 arduino_controller.close_connection() 525 526 main_gui.save_button_handler() 527 528 logging.info("Program finalized, arduino boards reset.") 529 except Exception as e: 530 logging.error(f"Error completing program: {e}") 531 raise
Stops and updates program to reflect IDLE
state.
Methods
finalize_program
(main_gui, arduino_controller) This method waits until the door closes for the last time, then cancels the last scheduled task, stops the listener thread, closes Arduino connections, and instructs the user to save their data.
465 def __init__( 466 self, 467 main_gui: MainGUI, 468 arduino_controller: ArduinoManager, 469 trigger: Callable[[str], None], 470 ) -> None: 471 """ 472 Parameters 473 ---------- 474 - **main_gui** (*MainGUI*): A reference to `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 475 call update_on_stop, configure the start/stop button back to start, and cancel tkinter scheduled tasks. 476 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` instance, this is the method by which the Arduino is communicated 477 with in the program. Used to reset Arduino and clear all left-over experiment data. 478 - **trigger** (*Callback method*): This callback is passed in so the start button can be configured to command a `START` state. 479 """ 480 try: 481 main_gui.update_on_stop() 482 # change the command back to start for the start button. (STOP PROGRAM, START) is not a defined transition, so the 483 # trigger function will call reject_actions to let the user know the program has already ran and they need to reset the app. 484 main_gui.start_button.configure( 485 text="Start", bg="green", command=lambda: trigger("START") 486 ) 487 488 # stop all scheduled tasks EXCEPT for the process queue, we are still waiting for the last door close timestamp 489 for desc, sched_task in main_gui.scheduled_tasks.items(): 490 if desc != "PROCESS QUEUE": 491 main_gui.after_cancel(sched_task) 492 493 # schedule finalization after door will be down 494 main_gui.scheduled_tasks["FINALIZE"] = main_gui.after( 495 5000, lambda: self.finalize_program(main_gui, arduino_controller) 496 ) 497 498 logging.info("Program stopped... waiting to finalize...") 499 except Exception as e: 500 logging.error(f"Error stopping program: {e}") 501 raise
Parameters
- main_gui (MainGUI): A reference to
views.main_gui
MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to call update_on_stop, configure the start/stop button back to start, and cancel tkinter scheduled tasks. - arduino_controller (ArduinoManager): A reference to the
controllers.arduino_control
instance, this is the method by which the Arduino is communicated with in the program. Used to reset Arduino and clear all left-over experiment data. - trigger (Callback method): This callback is passed in so the start button can be configured to command a
START
state.
503 def finalize_program( 504 self, main_gui: MainGUI, arduino_controller: ArduinoManager 505 ) -> None: 506 """ 507 Finalize the program by resetting the Arduino board, offer to save the data frames into xlsx files. 508 509 Parameters 510 ---------- 511 - **main_gui** (*MainGUI*): A reference to `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 512 cancel the last tkinter.after call. 513 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` instance, this is the method by which the Arduino is communicated 514 with in the program. Used to reset Arduino board and close the connection to it. 515 """ 516 try: 517 # stop the arduino listener so that the program can shut down 518 # and not be blocked 519 arduino_controller.stop_listener_thread() 520 521 queue_id = main_gui.scheduled_tasks["PROCESS QUEUE"] 522 main_gui.after_cancel(queue_id) 523 524 arduino_controller.close_connection() 525 526 main_gui.save_button_handler() 527 528 logging.info("Program finalized, arduino boards reset.") 529 except Exception as e: 530 logging.error(f"Error completing program: {e}") 531 raise
Finalize the program by resetting the Arduino board, offer to save the data frames into xlsx files.
Parameters
- main_gui (MainGUI): A reference to
views.main_gui
MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to cancel the last tkinter.after call. - arduino_controller (ArduinoManager): A reference to the
controllers.arduino_control
instance, this is the method by which the Arduino is communicated with in the program. Used to reset Arduino board and close the connection to it.
534class ResetProgram: 535 """ 536 Handle resetting the program on click of the reset button. This class has no instance variable or methods. 537 """ 538 539 def __init__( 540 self, main_gui: MainGUI, app_result: list, arduino_controller: ArduinoManager 541 ) -> None: 542 try: 543 # these two calls will stop the gui, halting the programs mainloop. 544 main_gui.quit() 545 main_gui.destroy() 546 547 # cancel all scheduled tasks 548 for sched_task in main_gui.scheduled_tasks.values(): 549 main_gui.after_cancel(sched_task) 550 # if we have an listener thread and arduino connected, stop the thread and close the connection. 551 if arduino_controller.listener_thread is not None: 552 arduino_controller.stop_listener_thread() 553 if arduino_controller.arduino is not None: 554 arduino_controller.close_connection() 555 556 # set first bit in app_result list to 1 to instruct main to restart. 557 app_result[0] = 1 558 559 except Exception as e: 560 logging.error(f"Error resetting the program: {e}")
Handle resetting the program on click of the reset button. This class has no instance variable or methods.
539 def __init__( 540 self, main_gui: MainGUI, app_result: list, arduino_controller: ArduinoManager 541 ) -> None: 542 try: 543 # these two calls will stop the gui, halting the programs mainloop. 544 main_gui.quit() 545 main_gui.destroy() 546 547 # cancel all scheduled tasks 548 for sched_task in main_gui.scheduled_tasks.values(): 549 main_gui.after_cancel(sched_task) 550 # if we have an listener thread and arduino connected, stop the thread and close the connection. 551 if arduino_controller.listener_thread is not None: 552 arduino_controller.stop_listener_thread() 553 if arduino_controller.arduino is not None: 554 arduino_controller.close_connection() 555 556 # set first bit in app_result list to 1 to instruct main to restart. 557 app_result[0] = 1 558 559 except Exception as e: 560 logging.error(f"Error resetting the program: {e}")
563class InitialTimeInterval: 564 """ 565 State class for initial time interval experiment state. 566 """ 567 568 def __init__( 569 self, 570 exp_data: ExperimentProcessData, 571 main_gui: MainGUI, 572 state: str, 573 trigger: Callable[[str], None], 574 ) -> None: 575 """ 576 This function transitions the program into the `ITI` state. It does this by resetting lick counts, setting new state time, 577 updating the models, and setting the .after call to transition to TTC. 578 579 Parameters 580 ---------- 581 - **exp_data** (*ExperimentProcessData*): Reference to the `models.experiment_process_data`. Here we use it to get a reference to event_data to set trial 582 licks to 0, get logical trial number, and get state duration time. 583 - **main_gui** (*MainGUI*): A reference to `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 584 update GUI with new trial information and configure the state timer. 585 - **state** (*str*): State is used to update GUI with current state and grab the state duration time from the program schedule df. 586 - **trigger** (*Callback method*): This callback is passed in so the `OPENING DOOR` state can be triggered after the `ITI` time has passed. 587 """ 588 try: 589 # get a reference to the event_data from exp_data. 590 event_data = exp_data.event_data 591 592 # set trial licks to 0 for both sides 593 event_data.side_one_licks = 0 594 event_data.side_two_licks = 0 595 596 # logical df rows will always be 1 behind the current trial because of zero based indexing in dataframes vs 597 # 1 based for trials 598 logical_trial = exp_data.current_trial_number - 1 599 600 # Call gui updates needed every trial (e.g program schedule window highlighting, progress bar, trial stimuli, etc) 601 # make sure we convert received vals to str to avoid type errors 602 main_gui.update_on_new_trial( 603 str(exp_data.program_schedule_df.loc[logical_trial, "Port 1"]), 604 str(exp_data.program_schedule_df.loc[logical_trial, "Port 2"]), 605 ) 606 607 # clear state timer and reset the state start time 608 main_gui.state_timer_text.configure(text=(state + "Time:")) 609 exp_data.state_start_time = time.time() 610 611 initial_time_interval = exp_data.program_schedule_df.loc[ 612 logical_trial, state 613 ] 614 615 main_gui.update_on_state_change(initial_time_interval, state) 616 617 # tell tkinter main loop that we want to trigger DOOR OPEN state after initial_time_interval milliseconds 618 iti_ttc_transition = main_gui.after( 619 int(initial_time_interval), 620 lambda: trigger("DOOR OPEN"), 621 ) 622 623 # add the transition to scheduled tasks dict so that 624 main_gui.scheduled_tasks["ITI TO DOOR OPEN"] = iti_ttc_transition 625 626 logging.info( 627 f"STATE CHANGE: ITI BEGINS NOW for trial -> {exp_data.current_trial_number}, completes in {initial_time_interval}." 628 ) 629 except Exception as e: 630 logging.error(f"Error in initial time interval: {e}") 631 raise
State class for initial time interval experiment state.
568 def __init__( 569 self, 570 exp_data: ExperimentProcessData, 571 main_gui: MainGUI, 572 state: str, 573 trigger: Callable[[str], None], 574 ) -> None: 575 """ 576 This function transitions the program into the `ITI` state. It does this by resetting lick counts, setting new state time, 577 updating the models, and setting the .after call to transition to TTC. 578 579 Parameters 580 ---------- 581 - **exp_data** (*ExperimentProcessData*): Reference to the `models.experiment_process_data`. Here we use it to get a reference to event_data to set trial 582 licks to 0, get logical trial number, and get state duration time. 583 - **main_gui** (*MainGUI*): A reference to `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 584 update GUI with new trial information and configure the state timer. 585 - **state** (*str*): State is used to update GUI with current state and grab the state duration time from the program schedule df. 586 - **trigger** (*Callback method*): This callback is passed in so the `OPENING DOOR` state can be triggered after the `ITI` time has passed. 587 """ 588 try: 589 # get a reference to the event_data from exp_data. 590 event_data = exp_data.event_data 591 592 # set trial licks to 0 for both sides 593 event_data.side_one_licks = 0 594 event_data.side_two_licks = 0 595 596 # logical df rows will always be 1 behind the current trial because of zero based indexing in dataframes vs 597 # 1 based for trials 598 logical_trial = exp_data.current_trial_number - 1 599 600 # Call gui updates needed every trial (e.g program schedule window highlighting, progress bar, trial stimuli, etc) 601 # make sure we convert received vals to str to avoid type errors 602 main_gui.update_on_new_trial( 603 str(exp_data.program_schedule_df.loc[logical_trial, "Port 1"]), 604 str(exp_data.program_schedule_df.loc[logical_trial, "Port 2"]), 605 ) 606 607 # clear state timer and reset the state start time 608 main_gui.state_timer_text.configure(text=(state + "Time:")) 609 exp_data.state_start_time = time.time() 610 611 initial_time_interval = exp_data.program_schedule_df.loc[ 612 logical_trial, state 613 ] 614 615 main_gui.update_on_state_change(initial_time_interval, state) 616 617 # tell tkinter main loop that we want to trigger DOOR OPEN state after initial_time_interval milliseconds 618 iti_ttc_transition = main_gui.after( 619 int(initial_time_interval), 620 lambda: trigger("DOOR OPEN"), 621 ) 622 623 # add the transition to scheduled tasks dict so that 624 main_gui.scheduled_tasks["ITI TO DOOR OPEN"] = iti_ttc_transition 625 626 logging.info( 627 f"STATE CHANGE: ITI BEGINS NOW for trial -> {exp_data.current_trial_number}, completes in {initial_time_interval}." 628 ) 629 except Exception as e: 630 logging.error(f"Error in initial time interval: {e}") 631 raise
This function transitions the program into the ITI
state. It does this by resetting lick counts, setting new state time,
updating the models, and setting the .after call to transition to TTC.
Parameters
- exp_data (ExperimentProcessData): Reference to the
models.experiment_process_data
. Here we use it to get a reference to event_data to set trial licks to 0, get logical trial number, and get state duration time. - main_gui (MainGUI): A reference to
views.main_gui
MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to update GUI with new trial information and configure the state timer. - state (str): State is used to update GUI with current state and grab the state duration time from the program schedule df.
- trigger (Callback method): This callback is passed in so the
OPENING DOOR
state can be triggered after theITI
time has passed.
634class OpeningDoor: 635 """ 636 Intermediate state between `ITI` and `TTC`. 637 """ 638 639 # we are going to need to pass in the arduino controller as well 640 def __init__( 641 self, 642 exp_data: ExperimentProcessData, 643 main_gui: MainGUI, 644 arduino_controller: ArduinoManager, 645 state: str, 646 trigger: Callable[[str], None], 647 ): 648 """ 649 In the initialization steps for this state we command the door down, then wait `DOOR_MOVE_TIME` to move to the `TTC` state. 650 """ 651 # send comment to arduino to move the door down 652 down_command = "DOWN\n".encode("utf-8") 653 arduino_controller.send_command(command=down_command) 654 655 # after the door is down, then we will begin the ttc state logic, found in run_ttc 656 main_gui.after(DOOR_MOVE_TIME, lambda: trigger("TTC")) 657 658 # state start time begins 659 exp_data.state_start_time = time.time() 660 661 # show current_time / door close time to avoid confusion 662 main_gui.update_on_state_change(DOOR_MOVE_TIME, state)
Intermediate state between ITI
and TTC
.
640 def __init__( 641 self, 642 exp_data: ExperimentProcessData, 643 main_gui: MainGUI, 644 arduino_controller: ArduinoManager, 645 state: str, 646 trigger: Callable[[str], None], 647 ): 648 """ 649 In the initialization steps for this state we command the door down, then wait `DOOR_MOVE_TIME` to move to the `TTC` state. 650 """ 651 # send comment to arduino to move the door down 652 down_command = "DOWN\n".encode("utf-8") 653 arduino_controller.send_command(command=down_command) 654 655 # after the door is down, then we will begin the ttc state logic, found in run_ttc 656 main_gui.after(DOOR_MOVE_TIME, lambda: trigger("TTC")) 657 658 # state start time begins 659 exp_data.state_start_time = time.time() 660 661 # show current_time / door close time to avoid confusion 662 main_gui.update_on_state_change(DOOR_MOVE_TIME, state)
In the initialization steps for this state we command the door down, then wait DOOR_MOVE_TIME
to move to the TTC
state.
665class TimeToContact: 666 """ 667 State class for time to contact experiment state 668 """ 669 670 def __init__( 671 self, 672 exp_data: ExperimentProcessData, 673 arduino_controller: ArduinoManager, 674 main_gui: MainGUI, 675 state: str, 676 trigger: Callable[[str], None], 677 ): 678 """ 679 This function transitions the program into the `TTC` state. We can only reach this state from `OPENING DOOR`. 680 681 We transition into `TTC` by resetting the state timer, instructing the Arduino that the trial has started, and scheduling a transition into 682 `TRIAL END` if the rat does not engage in this trial. This is cancelled in sample time if 3 licks or more from `TTC` are detected in the `models.arduino_data` 683 module's `handle_licks` method. 684 685 Parameters 686 ---------- 687 - **exp_data** (*ExperimentProcessData*): Reference to the `models.experiment_process_data`. Here we use it to get a reference to event_data to reset state time 688 and find `TTC` state time. 689 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` instance, this is the method by which the Arduino is communicated 690 with in the program. Used here to tell Arduino board trial has begun. 691 - **main_gui** (*MainGUI*): A reference to `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 692 update GUI with new state time. 693 - **state** (*str*): State is used to update GUI with current state and grab the state duration time from the program schedule df. 694 - **trigger** (*Callback method*): This callback is passed in so the `TRIAL END` state can be triggered if trial is not engaged. 695 """ 696 try: 697 logging.info( 698 f"STATE CHANGE: DOOR OPEN -> TTC NOW for trial -> {exp_data.current_trial_number}." 699 ) 700 701 logical_trial = exp_data.current_trial_number - 1 702 703 # tell the arduino that the trial begins now, becuase the rats are able to licks beginning now 704 command = "TRIAL START\n".encode("utf-8") 705 arduino_controller.send_command(command) 706 707 main_gui.state_timer_text.configure(text=(state + " Time:")) 708 709 # state time starts now, as does trial because lick availabilty starts now 710 exp_data.state_start_time = time.time() 711 exp_data.trial_start_time = time.time() 712 713 # find the amount of time available for TTC for this trial 714 time_to_contact = exp_data.program_schedule_df.loc[logical_trial, state] 715 716 main_gui.update_on_state_change(time_to_contact, state) 717 718 # set a state change to occur after the time_to_contact time, this will be cancelled if the laser arduino 719 # sends 3 licks befote the TTC_time 720 ttc_iti_transition = main_gui.after( 721 int(time_to_contact), lambda: trigger("TRIAL END") 722 ) 723 724 # store this task id so that it can be cancelled if the sample time transition is taken. 725 main_gui.scheduled_tasks["TTC TO TRIAL END"] = ttc_iti_transition 726 727 logging.info( 728 f"STATE CHANGE: TTC BEGINS NOW for trial -> {exp_data.current_trial_number}, completes in {time_to_contact}." 729 ) 730 except Exception as e: 731 logging.error(f"Error in TTC state start: {e}") 732 raise
State class for time to contact experiment state
670 def __init__( 671 self, 672 exp_data: ExperimentProcessData, 673 arduino_controller: ArduinoManager, 674 main_gui: MainGUI, 675 state: str, 676 trigger: Callable[[str], None], 677 ): 678 """ 679 This function transitions the program into the `TTC` state. We can only reach this state from `OPENING DOOR`. 680 681 We transition into `TTC` by resetting the state timer, instructing the Arduino that the trial has started, and scheduling a transition into 682 `TRIAL END` if the rat does not engage in this trial. This is cancelled in sample time if 3 licks or more from `TTC` are detected in the `models.arduino_data` 683 module's `handle_licks` method. 684 685 Parameters 686 ---------- 687 - **exp_data** (*ExperimentProcessData*): Reference to the `models.experiment_process_data`. Here we use it to get a reference to event_data to reset state time 688 and find `TTC` state time. 689 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` instance, this is the method by which the Arduino is communicated 690 with in the program. Used here to tell Arduino board trial has begun. 691 - **main_gui** (*MainGUI*): A reference to `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 692 update GUI with new state time. 693 - **state** (*str*): State is used to update GUI with current state and grab the state duration time from the program schedule df. 694 - **trigger** (*Callback method*): This callback is passed in so the `TRIAL END` state can be triggered if trial is not engaged. 695 """ 696 try: 697 logging.info( 698 f"STATE CHANGE: DOOR OPEN -> TTC NOW for trial -> {exp_data.current_trial_number}." 699 ) 700 701 logical_trial = exp_data.current_trial_number - 1 702 703 # tell the arduino that the trial begins now, becuase the rats are able to licks beginning now 704 command = "TRIAL START\n".encode("utf-8") 705 arduino_controller.send_command(command) 706 707 main_gui.state_timer_text.configure(text=(state + " Time:")) 708 709 # state time starts now, as does trial because lick availabilty starts now 710 exp_data.state_start_time = time.time() 711 exp_data.trial_start_time = time.time() 712 713 # find the amount of time available for TTC for this trial 714 time_to_contact = exp_data.program_schedule_df.loc[logical_trial, state] 715 716 main_gui.update_on_state_change(time_to_contact, state) 717 718 # set a state change to occur after the time_to_contact time, this will be cancelled if the laser arduino 719 # sends 3 licks befote the TTC_time 720 ttc_iti_transition = main_gui.after( 721 int(time_to_contact), lambda: trigger("TRIAL END") 722 ) 723 724 # store this task id so that it can be cancelled if the sample time transition is taken. 725 main_gui.scheduled_tasks["TTC TO TRIAL END"] = ttc_iti_transition 726 727 logging.info( 728 f"STATE CHANGE: TTC BEGINS NOW for trial -> {exp_data.current_trial_number}, completes in {time_to_contact}." 729 ) 730 except Exception as e: 731 logging.error(f"Error in TTC state start: {e}") 732 raise
This function transitions the program into the TTC
state. We can only reach this state from OPENING DOOR
.
We transition into TTC
by resetting the state timer, instructing the Arduino that the trial has started, and scheduling a transition into
TRIAL END
if the rat does not engage in this trial. This is cancelled in sample time if 3 licks or more from TTC
are detected in the models.arduino_data
module's handle_licks
method.
Parameters
- exp_data (ExperimentProcessData): Reference to the
models.experiment_process_data
. Here we use it to get a reference to event_data to reset state time and findTTC
state time. - arduino_controller (ArduinoManager): A reference to the
controllers.arduino_control
instance, this is the method by which the Arduino is communicated with in the program. Used here to tell Arduino board trial has begun. - main_gui (MainGUI): A reference to
views.main_gui
MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to update GUI with new state time. - state (str): State is used to update GUI with current state and grab the state duration time from the program schedule df.
- trigger (Callback method): This callback is passed in so the
TRIAL END
state can be triggered if trial is not engaged.
735class SampleTime: 736 """ 737 Sample state class to handle setup of `Sample` state. 738 """ 739 740 def __init__( 741 self, 742 exp_data: ExperimentProcessData, 743 main_gui: MainGUI, 744 arduino_controller: ArduinoManager, 745 state: str, 746 trigger: Callable[[str], None], 747 ): 748 """ 749 This function transitions the program into the `SAMPLE` state. We can only reach this state from `TTC` if 3 licks or more are detected in `TTC`. 750 751 We transition into `SAMPLE` by resetting licks to zero to count only sample time licks for trial, updating the actual `TTC` time taken before engaging in the 752 trial, cancelling the scheduled `TTC` to `TRIAL END` transition, instructing Arduino to begin opening valves when licks occur, and updating state start time. 753 754 Parameters 755 ---------- 756 - **exp_data** (*ExperimentProcessData*): Reference to the `models.experiment_process_data`. Here we use it to get a reference to event_data to reset state time 757 and find `SAMPLE` state time, reset trial lick data. 758 - **main_gui** (*MainGUI*): A reference to `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 759 update GUI with new state info and cancel `TTC` to `TRIAL END` transition. 760 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` instance, this is the method by which the Arduino is communicated 761 with in the program. Used here to tell Arduino to begin opening valves when licks are detected. 762 - **state** (*str*): State is used to update GUI with current state and grab the state duration time from the program schedule df. 763 - **trigger** (*Callback method*): This callback is passed in so the `TRIAL END` state can be triggered after `SAMPLE` time. 764 765 Methods 766 ------- 767 - `update_ttc_time`(exp_data, logical_trial) 768 Updates program schedule dataframe with actual time used in the `TTC` state. 769 """ 770 try: 771 logical_trial = exp_data.current_trial_number - 1 772 773 event_data = exp_data.event_data 774 775 # reset trial licks to count only sample licks 776 event_data.side_one_licks = 0 777 event_data.side_two_licks = 0 778 779 self.update_ttc_time(exp_data, logical_trial) 780 781 # since the trial was engaged, cancel the planned transition from ttc to trial end. program has branched to new state 782 main_gui.after_cancel(main_gui.scheduled_tasks["TTC TO TRIAL END"]) 783 784 # Tell the laser arduino to begin accepting licks and opening valves 785 # resetting the lick counters for both spouts 786 # tell the laser to begin opening valves on licks 787 open_command = "BEGIN OPEN VALVES\n".encode("utf-8") 788 arduino_controller.send_command(command=open_command) 789 790 exp_data.state_start_time = time.time() 791 792 sample_interval_value = exp_data.program_schedule_df.loc[ 793 logical_trial, state 794 ] 795 796 main_gui.update_on_state_change(sample_interval_value, state) 797 798 main_gui.after( 799 int(sample_interval_value), 800 lambda: trigger("TRIAL END"), 801 ) 802 803 logging.info( 804 f"STATE CHANGE: SAMPLE BEGINS NOW for trial-> {exp_data.current_trial_number}, completes in {sample_interval_value}." 805 ) 806 except Exception as e: 807 logging.error( 808 f"Error in sample time trial no. {exp_data.current_trial_number}: {e}" 809 ) 810 raise 811 812 def update_ttc_time( 813 self, exp_data: ExperimentProcessData, logical_trial: int 814 ) -> None: 815 """ 816 If we reach this code that means the rat licked at least 3 times in `TTC` state 817 so we didn't take all the allocated time. update program schedule window with actual time taken 818 """ 819 ttc_time = (time.time() - exp_data.state_start_time) * 1000 820 821 exp_data.program_schedule_df.loc[logical_trial, "TTC Actual"] = round( 822 ttc_time, 3 823 )
Sample state class to handle setup of Sample
state.
740 def __init__( 741 self, 742 exp_data: ExperimentProcessData, 743 main_gui: MainGUI, 744 arduino_controller: ArduinoManager, 745 state: str, 746 trigger: Callable[[str], None], 747 ): 748 """ 749 This function transitions the program into the `SAMPLE` state. We can only reach this state from `TTC` if 3 licks or more are detected in `TTC`. 750 751 We transition into `SAMPLE` by resetting licks to zero to count only sample time licks for trial, updating the actual `TTC` time taken before engaging in the 752 trial, cancelling the scheduled `TTC` to `TRIAL END` transition, instructing Arduino to begin opening valves when licks occur, and updating state start time. 753 754 Parameters 755 ---------- 756 - **exp_data** (*ExperimentProcessData*): Reference to the `models.experiment_process_data`. Here we use it to get a reference to event_data to reset state time 757 and find `SAMPLE` state time, reset trial lick data. 758 - **main_gui** (*MainGUI*): A reference to `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 759 update GUI with new state info and cancel `TTC` to `TRIAL END` transition. 760 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` instance, this is the method by which the Arduino is communicated 761 with in the program. Used here to tell Arduino to begin opening valves when licks are detected. 762 - **state** (*str*): State is used to update GUI with current state and grab the state duration time from the program schedule df. 763 - **trigger** (*Callback method*): This callback is passed in so the `TRIAL END` state can be triggered after `SAMPLE` time. 764 765 Methods 766 ------- 767 - `update_ttc_time`(exp_data, logical_trial) 768 Updates program schedule dataframe with actual time used in the `TTC` state. 769 """ 770 try: 771 logical_trial = exp_data.current_trial_number - 1 772 773 event_data = exp_data.event_data 774 775 # reset trial licks to count only sample licks 776 event_data.side_one_licks = 0 777 event_data.side_two_licks = 0 778 779 self.update_ttc_time(exp_data, logical_trial) 780 781 # since the trial was engaged, cancel the planned transition from ttc to trial end. program has branched to new state 782 main_gui.after_cancel(main_gui.scheduled_tasks["TTC TO TRIAL END"]) 783 784 # Tell the laser arduino to begin accepting licks and opening valves 785 # resetting the lick counters for both spouts 786 # tell the laser to begin opening valves on licks 787 open_command = "BEGIN OPEN VALVES\n".encode("utf-8") 788 arduino_controller.send_command(command=open_command) 789 790 exp_data.state_start_time = time.time() 791 792 sample_interval_value = exp_data.program_schedule_df.loc[ 793 logical_trial, state 794 ] 795 796 main_gui.update_on_state_change(sample_interval_value, state) 797 798 main_gui.after( 799 int(sample_interval_value), 800 lambda: trigger("TRIAL END"), 801 ) 802 803 logging.info( 804 f"STATE CHANGE: SAMPLE BEGINS NOW for trial-> {exp_data.current_trial_number}, completes in {sample_interval_value}." 805 ) 806 except Exception as e: 807 logging.error( 808 f"Error in sample time trial no. {exp_data.current_trial_number}: {e}" 809 ) 810 raise
This function transitions the program into the SAMPLE
state. We can only reach this state from TTC
if 3 licks or more are detected in TTC
.
We transition into SAMPLE
by resetting licks to zero to count only sample time licks for trial, updating the actual TTC
time taken before engaging in the
trial, cancelling the scheduled TTC
to TRIAL END
transition, instructing Arduino to begin opening valves when licks occur, and updating state start time.
Parameters
- exp_data (ExperimentProcessData): Reference to the
models.experiment_process_data
. Here we use it to get a reference to event_data to reset state time and findSAMPLE
state time, reset trial lick data. - main_gui (MainGUI): A reference to
views.main_gui
MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to update GUI with new state info and cancelTTC
toTRIAL END
transition. - arduino_controller (ArduinoManager): A reference to the
controllers.arduino_control
instance, this is the method by which the Arduino is communicated with in the program. Used here to tell Arduino to begin opening valves when licks are detected. - state (str): State is used to update GUI with current state and grab the state duration time from the program schedule df.
- trigger (Callback method): This callback is passed in so the
TRIAL END
state can be triggered afterSAMPLE
time.
Methods
update_ttc_time
(exp_data, logical_trial) Updates program schedule dataframe with actual time used in theTTC
state.
812 def update_ttc_time( 813 self, exp_data: ExperimentProcessData, logical_trial: int 814 ) -> None: 815 """ 816 If we reach this code that means the rat licked at least 3 times in `TTC` state 817 so we didn't take all the allocated time. update program schedule window with actual time taken 818 """ 819 ttc_time = (time.time() - exp_data.state_start_time) * 1000 820 821 exp_data.program_schedule_df.loc[logical_trial, "TTC Actual"] = round( 822 ttc_time, 3 823 )
If we reach this code that means the rat licked at least 3 times in TTC
state
so we didn't take all the allocated time. update program schedule window with actual time taken
826class TrialEnd: 827 """ 828 Handle end of trial operations such as data storage and GUI updates with gathered trial data. 829 830 Methods 831 ------- 832 - `arduino_trial_end`(arduino_controller): Handle Arduino end of trial, send door up, stop accepting licks. 833 - `end_trial`(exp_data: ExperimentProcessData): Determine if this trial is the last, if not increment trial number in `models.experiment_process_data`. 834 - `handle_from_ttc`(logical_trial, trigger), exp_data): Call `update_ttc_actual` to update TTC time. Trigger transition to `ITI`. 835 - `update_schedule_licks`(logical_trial, exp_data) -> None: Update program schedule df with licks for this trial on each port. 836 - `update_raster_plots`(exp_data, logical_trial, main_gui) -> None: Update raster plot with licks from this trial. 837 """ 838 839 def __init__( 840 self, 841 exp_data: ExperimentProcessData, 842 main_gui: MainGUI, 843 arduino_controller: ArduinoManager, 844 prev_state: str, 845 trigger: Callable[[str], None], 846 ) -> None: 847 """ 848 This function transitions the program into the `SAMPLE` state. We can only reach this state from `TTC` if 3 licks or more are detected in `TTC`. 849 850 We transition into `SAMPLE` by resetting licks to zero to count only sample time licks for trial, updating the actual `TTC` time taken before engaging in the 851 trial, cancelling the scheduled `TTC` to `TRIAL END` transition, instructing Arduino to begin opening valves when licks occur, and updating state start time. 852 853 Parameters 854 ---------- 855 - **exp_data** (*ExperimentProcessData*): Reference to the `models.experiment_process_data`. Here we use it to update program df with trial licks, increment trial, 856 check if current trial is the final trial, and other data operations. 857 - **main_gui** (*MainGUI*): A reference to `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 858 update program schedule and populate raster plots with trial licks. 859 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` instance, this is the method by which the Arduino is communicated 860 with in the program. Used here to tell Arduino to stop opening valves when licks are detected. 861 - **prev_state** (*str*): prev_state is used to decide which end of trial operations should be executed. 862 - **trigger** (*Callback method*): This callback is passed in so the `ITI` state can be triggered after `TRIAL END` logic is complete. 863 864 Methods 865 ------- 866 - `update_ttc_time`(exp_data, logical_trial) 867 Updates program schedule dataframe with actual time used in the `TTC` state. 868 """ 869 870 # use the same logical trial for all functions current_trial_number - 1 871 # to account for 1 indexing 872 logical_trial = exp_data.current_trial_number - 1 873 874 self.arduino_trial_end(arduino_controller) 875 876 #######TRIAL NUMBER IS INCREMENTED INSIDE OF END_TRIAL####### 877 if self.end_trial(exp_data): 878 program_schedule = main_gui.windows["Program Schedule"] 879 # if the experiment is over update the licks for the final trial 880 self.update_schedule_licks(logical_trial, exp_data) 881 program_schedule.refresh_end_trial(logical_trial) 882 883 trigger("STOP") 884 # return from the call / kill the working thread 885 return 886 887 # update licks for this trial 888 self.update_schedule_licks(logical_trial, exp_data) 889 890 match prev_state: 891 case "TTC": 892 self.update_ttc_actual(logical_trial, exp_data) 893 trigger("ITI") 894 case "SAMPLE": 895 self.update_raster_plots(exp_data, logical_trial, main_gui) 896 trigger("ITI") 897 case _: 898 # cases not explicitly defined go here 899 logger.error("UNDEFINED PREVIOUS TRANSITION IN TRIAL END STATE") 900 901 main_gui.windows["Program Schedule"].refresh_end_trial(logical_trial) 902 903 def arduino_trial_end(self, arduino_controller: ArduinoManager) -> None: 904 """ 905 Send rig door up and tell Arduino to stop accepting licks. 906 """ 907 up_command = "UP\n".encode("utf-8") 908 arduino_controller.send_command(command=up_command) 909 910 stop_command = "STOP OPEN VALVES\n".encode("utf-8") 911 arduino_controller.send_command(command=stop_command) 912 913 def end_trial(self, exp_data: ExperimentProcessData) -> bool: 914 """ 915 This method checks if current trial is the last trial. If it is, we return true to the calling code. 916 If false, we increment current_trial_number, return false, and proceed to next trial. 917 """ 918 current_trial = exp_data.current_trial_number 919 max_trials = exp_data.exp_var_entries["Num Trials"] 920 921 if current_trial >= max_trials: 922 return True 923 924 exp_data.current_trial_number += 1 925 return False 926 927 def update_ttc_actual( 928 self, logical_trial: int, exp_data: ExperimentProcessData 929 ) -> None: 930 """ 931 Update the actual ttc time take in the program schedule with the max time value, 932 since we reached trial end we know we took the max time allowed 933 """ 934 program_df = exp_data.program_schedule_df 935 936 ttc_column_value = program_df.loc[logical_trial, "TTC"] 937 program_df.loc[logical_trial, "TTC Actual"] = ttc_column_value 938 939 def update_schedule_licks( 940 self, logical_trial: int, exp_data: ExperimentProcessData 941 ) -> None: 942 """Update the licks for each side in the program schedule df for this trial""" 943 program_df = exp_data.program_schedule_df 944 event_data = exp_data.event_data 945 946 licks_sd_one = event_data.side_one_licks 947 licks_sd_two = event_data.side_two_licks 948 949 program_df.loc[logical_trial, "Port 1 Licks"] = licks_sd_one 950 951 program_df.loc[logical_trial, "Port 2 Licks"] = licks_sd_two 952 953 def update_raster_plots( 954 self, exp_data: ExperimentProcessData, logical_trial: int, main_gui: MainGUI 955 ) -> None: 956 """Instruct raster windows to update with new lick timestamps""" 957 # gather lick timestamp data from the event data model 958 lick_stamps = exp_data.event_data.get_lick_timestamps(logical_trial) 959 960 # send it to raster windows 961 for i, window in enumerate(main_gui.windows["Raster Plot"]): 962 window.update_plot(lick_stamps[i], logical_trial)
Handle end of trial operations such as data storage and GUI updates with gathered trial data.
Methods
arduino_trial_end
(arduino_controller): Handle Arduino end of trial, send door up, stop accepting licks.end_trial
(exp_data: ExperimentProcessData): Determine if this trial is the last, if not increment trial number inmodels.experiment_process_data
.handle_from_ttc
(logical_trial, trigger), exp_data): Callupdate_ttc_actual
to update TTC time. Trigger transition toITI
.update_schedule_licks
(logical_trial, exp_data) -> None: Update program schedule df with licks for this trial on each port.update_raster_plots
(exp_data, logical_trial, main_gui) -> None: Update raster plot with licks from this trial.
839 def __init__( 840 self, 841 exp_data: ExperimentProcessData, 842 main_gui: MainGUI, 843 arduino_controller: ArduinoManager, 844 prev_state: str, 845 trigger: Callable[[str], None], 846 ) -> None: 847 """ 848 This function transitions the program into the `SAMPLE` state. We can only reach this state from `TTC` if 3 licks or more are detected in `TTC`. 849 850 We transition into `SAMPLE` by resetting licks to zero to count only sample time licks for trial, updating the actual `TTC` time taken before engaging in the 851 trial, cancelling the scheduled `TTC` to `TRIAL END` transition, instructing Arduino to begin opening valves when licks occur, and updating state start time. 852 853 Parameters 854 ---------- 855 - **exp_data** (*ExperimentProcessData*): Reference to the `models.experiment_process_data`. Here we use it to update program df with trial licks, increment trial, 856 check if current trial is the final trial, and other data operations. 857 - **main_gui** (*MainGUI*): A reference to `views.main_gui` MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to 858 update program schedule and populate raster plots with trial licks. 859 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` instance, this is the method by which the Arduino is communicated 860 with in the program. Used here to tell Arduino to stop opening valves when licks are detected. 861 - **prev_state** (*str*): prev_state is used to decide which end of trial operations should be executed. 862 - **trigger** (*Callback method*): This callback is passed in so the `ITI` state can be triggered after `TRIAL END` logic is complete. 863 864 Methods 865 ------- 866 - `update_ttc_time`(exp_data, logical_trial) 867 Updates program schedule dataframe with actual time used in the `TTC` state. 868 """ 869 870 # use the same logical trial for all functions current_trial_number - 1 871 # to account for 1 indexing 872 logical_trial = exp_data.current_trial_number - 1 873 874 self.arduino_trial_end(arduino_controller) 875 876 #######TRIAL NUMBER IS INCREMENTED INSIDE OF END_TRIAL####### 877 if self.end_trial(exp_data): 878 program_schedule = main_gui.windows["Program Schedule"] 879 # if the experiment is over update the licks for the final trial 880 self.update_schedule_licks(logical_trial, exp_data) 881 program_schedule.refresh_end_trial(logical_trial) 882 883 trigger("STOP") 884 # return from the call / kill the working thread 885 return 886 887 # update licks for this trial 888 self.update_schedule_licks(logical_trial, exp_data) 889 890 match prev_state: 891 case "TTC": 892 self.update_ttc_actual(logical_trial, exp_data) 893 trigger("ITI") 894 case "SAMPLE": 895 self.update_raster_plots(exp_data, logical_trial, main_gui) 896 trigger("ITI") 897 case _: 898 # cases not explicitly defined go here 899 logger.error("UNDEFINED PREVIOUS TRANSITION IN TRIAL END STATE") 900 901 main_gui.windows["Program Schedule"].refresh_end_trial(logical_trial)
This function transitions the program into the SAMPLE
state. We can only reach this state from TTC
if 3 licks or more are detected in TTC
.
We transition into SAMPLE
by resetting licks to zero to count only sample time licks for trial, updating the actual TTC
time taken before engaging in the
trial, cancelling the scheduled TTC
to TRIAL END
transition, instructing Arduino to begin opening valves when licks occur, and updating state start time.
Parameters
- exp_data (ExperimentProcessData): Reference to the
models.experiment_process_data
. Here we use it to update program df with trial licks, increment trial, check if current trial is the final trial, and other data operations. - main_gui (MainGUI): A reference to
views.main_gui
MainGUI instance, this is used to update elements inside the GUI window(s). Here we use it to update program schedule and populate raster plots with trial licks. - arduino_controller (ArduinoManager): A reference to the
controllers.arduino_control
instance, this is the method by which the Arduino is communicated with in the program. Used here to tell Arduino to stop opening valves when licks are detected. - prev_state (str): prev_state is used to decide which end of trial operations should be executed.
- trigger (Callback method): This callback is passed in so the
ITI
state can be triggered afterTRIAL END
logic is complete.
Methods
update_ttc_time
(exp_data, logical_trial) Updates program schedule dataframe with actual time used in theTTC
state.
903 def arduino_trial_end(self, arduino_controller: ArduinoManager) -> None: 904 """ 905 Send rig door up and tell Arduino to stop accepting licks. 906 """ 907 up_command = "UP\n".encode("utf-8") 908 arduino_controller.send_command(command=up_command) 909 910 stop_command = "STOP OPEN VALVES\n".encode("utf-8") 911 arduino_controller.send_command(command=stop_command)
Send rig door up and tell Arduino to stop accepting licks.
913 def end_trial(self, exp_data: ExperimentProcessData) -> bool: 914 """ 915 This method checks if current trial is the last trial. If it is, we return true to the calling code. 916 If false, we increment current_trial_number, return false, and proceed to next trial. 917 """ 918 current_trial = exp_data.current_trial_number 919 max_trials = exp_data.exp_var_entries["Num Trials"] 920 921 if current_trial >= max_trials: 922 return True 923 924 exp_data.current_trial_number += 1 925 return False
This method checks if current trial is the last trial. If it is, we return true to the calling code. If false, we increment current_trial_number, return false, and proceed to next trial.
927 def update_ttc_actual( 928 self, logical_trial: int, exp_data: ExperimentProcessData 929 ) -> None: 930 """ 931 Update the actual ttc time take in the program schedule with the max time value, 932 since we reached trial end we know we took the max time allowed 933 """ 934 program_df = exp_data.program_schedule_df 935 936 ttc_column_value = program_df.loc[logical_trial, "TTC"] 937 program_df.loc[logical_trial, "TTC Actual"] = ttc_column_value
Update the actual ttc time take in the program schedule with the max time value, since we reached trial end we know we took the max time allowed
939 def update_schedule_licks( 940 self, logical_trial: int, exp_data: ExperimentProcessData 941 ) -> None: 942 """Update the licks for each side in the program schedule df for this trial""" 943 program_df = exp_data.program_schedule_df 944 event_data = exp_data.event_data 945 946 licks_sd_one = event_data.side_one_licks 947 licks_sd_two = event_data.side_two_licks 948 949 program_df.loc[logical_trial, "Port 1 Licks"] = licks_sd_one 950 951 program_df.loc[logical_trial, "Port 2 Licks"] = licks_sd_two
Update the licks for each side in the program schedule df for this trial
953 def update_raster_plots( 954 self, exp_data: ExperimentProcessData, logical_trial: int, main_gui: MainGUI 955 ) -> None: 956 """Instruct raster windows to update with new lick timestamps""" 957 # gather lick timestamp data from the event data model 958 lick_stamps = exp_data.event_data.get_lick_timestamps(logical_trial) 959 960 # send it to raster windows 961 for i, window in enumerate(main_gui.windows["Raster Plot"]): 962 window.update_plot(lick_stamps[i], logical_trial)
Instruct raster windows to update with new lick timestamps