main_gui
main_gui
is the module which all program GUI elements originate from. We first initialize the primary app window, then initialize and hide
all secondary windows so that they will not take resources being created later.
1""" 2`main_gui` is the module which all program GUI elements originate from. We first initialize the primary app window, then initialize and hide 3all secondary windows so that they will not take resources being created later. 4 5""" 6 7from tkinter import ttk 8import tkinter as tk 9import time 10import logging 11from typing import Callable 12 13# used for type hinting 14from controllers.arduino_control import ArduinoManager 15from models.experiment_process_data import ExperimentProcessData 16# used for type hinting 17 18from views.gui_common import GUIUtils 19 20# import other GUI classes that can spawn from main GUI 21from views.rasterized_data_window import RasterizedDataWindow 22from views.experiment_control_window import ExperimentCtlWindow 23from views.event_window import EventWindow 24from views.valve_testing.valve_testing_window import ValveTestWindow 25 26from views.program_schedule_window import ProgramScheduleWindow 27from views.valve_control_window import ValveControlWindow 28 29 30logger = logging.getLogger() 31"""Get the logger in use for the app.""" 32 33 34class MainGUI(tk.Tk): 35 """ 36 The creator and controller of all GUI related items and actions for the program. Inherits from tk.Tk to create a tk.root() 37 from which tk.TopLevel windows can be sprouted and tk.after scheduling calls can be made. 38 39 Attributes 40 ---------- 41 - **exp_data** (*ExperimentProcessData*): An instance of `models.experiment_process_data` ExperimentProcessData, 42 this attribute allows for access and modification of experiment variables, and access for to more specific models like 43 `models.event_data` and `models.arduino_data`. 44 - **arduino_controller** (*ArduinoManager*): An instance of `controllers.arduino_control` ArduinoManager, this allows for communication between this program and the 45 Arduino board. 46 - **trigger** (*Callback method*): trigger is the callback method from `app_logic` module `StateMachine` class, it allows the 47 GUI to attempt state transitions. 48 - **scheduled_tasks** (*dict[str,str]*): self.scheduled_tasks is a dict where keys are short task descriptions (e.g ttc_to_iti) and the values are 49 the str ids for tkinter.after scheduling calls. This allows for tracking and cancellations of scheduled tasks. 50 - (**windows**) (*dict*): A dictionary that holds window titles as keys, and instances of GUI sublasses as values. Allows for easy access of windows and their methods 51 and attributes given a title. 52 53 Methods 54 ------- 55 - `setup_basic_window_attr`() 56 This method sets basic window attributes like title, icon, and expansion settings. 57 - `setup_tkinter_variables`() 58 GUI (Tkinter) variables and acutal stored data for the program are decoupled to separate concerns. Here we link them such that when the GUI variables are updated, 59 the data stored in the model is also updated. 60 - `build_gui_widgets`() 61 Setup all widgets to be placed in the MainGUI window. 62 - `preload_secondary_windows`() 63 This method loads all secondary windows and all content available at start time. This was done to reduce strain on system while program is running. Windows always 64 exist and are simply hidden when not shown to user. 65 - `show_secondary_window`() 66 Takes a window description string for the `windows` dictionary. Access is given to the instance of the class for the description, where the windows `show` method is 67 called. 68 - `hide_secondary_window`() 69 The opposing function to `show_secondary_window`. Hides the window specified as the `window` argument. 70 - `create_top_control_buttons`() 71 Create frames and buttons for top bar start an reset buttons. 72 - `create_timers`() 73 Create start and state start timer labels. 74 - `create_status_widgets`() 75 Create state status widgets such as state label, num trials progress bar and label, etc. 76 - `create_entry_widgets`() 77 Build entry widget and frames for ITI, TTC, Sample times, Num Trials, Num Stimuli. 78 - `create_lower_control_buttons`() 79 Create lower control buttons for opening windows and saving data. 80 - `save_button_handler`() 81 Define behavior for saving all data to xlsx files. 82 - `update_clock_label`() 83 Hold logic for updating GUI clocks every 100ms. 84 - `update_max_time`() 85 Calculate max program runtime. 86 - `update_on_new_trial`() 87 Define GUI objects to update each iteration (new ITI state) of program. 88 - `update_on_state_change`() 89 Define GUI objects to update upon each state (state timer, label, etc). 90 - `update_on_stop`() 91 Update GUI objects to reflect "IDLE" program state once stopped. 92 - `on_close`() 93 Defines GUI shutdown behavior when primary window is closed. 94 """ 95 96 def __init__( 97 self, 98 exp_data: ExperimentProcessData, 99 trigger: Callable[[str], None], 100 arduino_controller: ArduinoManager, 101 ) -> None: 102 """ 103 Initialize the MainGUI window. Build all frames and widgets required by the main window *and* loads all secondary windows that can 104 be opened by the lower control buttons. These are hidden after generation and deiconified (shown) when the button is pressed. 105 106 Parameters 107 ---------- 108 - **exp_data** (*ExperimentProcessData*): A reference to the `models.experiment_process_data` ExperimentProcessData instance, 109 this is used to model data based on user GUI interaction. 110 - **trigger** (*Callback method*): This callback is passed in so that this state can trigger state transitions from GUI objects 111 throughout the class. 112 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` Arduino controller instance, 113 this is the method by which the Arduino is communicated with in the program. `views.valve_testing.valve_testing_window` 114 ValveTestWindow, and `views.valve_control_window` require references here to communicate with Arduino to calibrate and open/close 115 valves. This is also used in `on_close` to stop the Arduino listener thread to avoid thread errors on window close. 116 """ 117 # init tk.Tk to use this class as a tk.root. 118 super().__init__() 119 120 self.exp_data = exp_data 121 self.arduino_controller = arduino_controller 122 self.trigger = trigger 123 124 self.scheduled_tasks: dict[str, str] = {} 125 126 self.setup_basic_window_attr() 127 self.setup_tkinter_variables() 128 self.build_gui_widgets() 129 130 # update_idletasks so that window knows what elements are inside and what width and height they require, 131 # then scale and center the window. 132 self.update_idletasks() 133 GUIUtils.center_window(self) 134 135 # create all secondary windows early so that loading when program is running is fast 136 self.preload_secondary_windows() 137 138 logger.info("MainGUI initialized.") 139 140 def setup_basic_window_attr(self): 141 """ 142 Set basic window attributes such as title, size, window icon, protocol for closing the window, etc. 143 We also bind <Control-w> keyboard shortcut to close the window for convenience. Finally, 144 we set the grid rows and columns to expand and contract as window gets larger or smaller (weight=1). 145 """ 146 147 self.title("Samuelsen Lab Photologic Rig") 148 self.bind("<Control-w>", lambda event: self.on_close()) 149 self.protocol("WM_DELETE_WINDOW", self.on_close) 150 151 icon_path = GUIUtils.get_window_icon_path() 152 GUIUtils.set_program_icon(self, icon_path=icon_path) 153 154 # set cols and rows to expand to fill space in main gui 155 for i in range(5): 156 self.grid_rowconfigure(i, weight=1) 157 158 self.grid_columnconfigure(0, weight=1) 159 160 def setup_tkinter_variables(self) -> None: 161 """ 162 Tkinter variables for tkinter entries are created here. These are the variables updated on user input. Default values 163 are set by finding the default value in `models.experiment_process_data`. Traces are added to each variable which update 164 the model upon an update (write) to the Tkinter variable. 165 """ 166 # tkinter variables for time related 167 # modifications in the experiment 168 self.tkinter_entries = { 169 "ITI_var": tk.IntVar(), 170 "TTC_var": tk.IntVar(), 171 "sample_var": tk.IntVar(), 172 "ITI_random_entry": tk.IntVar(), 173 "TTC_random_entry": tk.IntVar(), 174 "sample_random_entry": tk.IntVar(), 175 } 176 177 # fill the tkinter entry boxes with their default values as configured in self.exp_data 178 for key in self.tkinter_entries.keys(): 179 self.tkinter_entries[key].set(self.exp_data.get_default_value(key)) 180 181 # we add traces to all of our tkinter variables so that on value update 182 # we sent those updates to the corresponding model (data storage location) 183 for key, value in self.tkinter_entries.items(): 184 value.trace_add( 185 "write", 186 lambda *args, key=key, value=value: self.exp_data.update_model( 187 key, GUIUtils.safe_tkinter_get(value) 188 ), 189 ) 190 191 # tkinter variables for other variables in the experiment such as num stimuli 192 # or number of trial blocks 193 self.exp_var_entries = { 194 "Num Trial Blocks": tk.IntVar(), 195 "Num Stimuli": tk.IntVar(), 196 } 197 198 # fill the exp_var entry boxes with their default values as configured in self.exp_data 199 for key in self.exp_var_entries.keys(): 200 self.exp_var_entries[key].set(self.exp_data.get_default_value(key)) 201 202 for key, value in self.exp_var_entries.items(): 203 value.trace_add( 204 "write", 205 lambda *args, key=key, value=value: self.exp_data.update_model( 206 key, GUIUtils.safe_tkinter_get(value) 207 ), 208 ) 209 210 def build_gui_widgets(self) -> None: 211 """ 212 Build all GUI widgets by calling respective functions for each type of widget listed here. 213 """ 214 try: 215 self.create_top_control_buttons() 216 self.create_timers() 217 self.create_status_widgets() 218 self.create_entry_widgets() 219 self.create_lower_control_buttons() 220 logger.info("GUI setup completed.") 221 except Exception as e: 222 logger.error(f"Error setting up GUI: {e}") 223 raise 224 225 def preload_secondary_windows(self) -> None: 226 """ 227 Build all secondary windows (windows that require a button push to view) and get them ready 228 to be opened (deiconified in tkinter terms) when the user clicks the corresponding button. 229 """ 230 # define the data that ExperimentCtlWindow() needs to function 231 stimuli_data = self.exp_data.stimuli_data 232 event_data = self.exp_data.event_data 233 234 self.windows = { 235 "Experiment Control": ExperimentCtlWindow( 236 self.exp_data, stimuli_data, self.trigger 237 ), 238 "Program Schedule": ProgramScheduleWindow( 239 self.exp_data, 240 stimuli_data, 241 ), 242 "Raster Plot": ( 243 RasterizedDataWindow(1, self.exp_data), 244 RasterizedDataWindow(2, self.exp_data), 245 ), 246 "Event Data": EventWindow(event_data), 247 "Valve Testing": ValveTestWindow(self.arduino_controller), 248 "Valve Control": ValveControlWindow(self.arduino_controller), 249 } 250 251 def show_secondary_window(self, window: str) -> None: 252 """ 253 Show the window corresponding to a button press or other event calling this method. Accesses class attribute `windows` to gain 254 accesss to the desired instance and call the class `show` method. 255 256 Parameters 257 ---------- 258 - **window** (*str*): A key to the `windows` dictionary. This will return an instance of the corresponding class to the provided 259 'key' (window). 260 """ 261 if isinstance(self.windows[window], tuple): 262 for instance in self.windows[window]: 263 instance.deiconify() 264 else: 265 df = self.exp_data.program_schedule_df 266 if window == "Valve Testing" and not df.empty: 267 GUIUtils.display_error( 268 "CANNOT DISPLAY WINDOW", 269 "Since the schedule has been generated, valve testing has been disabled for this experiment. Reset the application to test again.", 270 ) 271 return 272 273 self.windows[window].show() 274 275 def hide_secondary_window(self, window: str) -> None: 276 """ 277 Performs the opposite action of `show_secondary_window`. Hides the window without destroyoing it by calling tkinter method 278 'withdraw' on the tk.TopLevel (class/`windows` dict value) instance. 279 280 Parameters 281 ---------- 282 - **window** (*str*): A key to the `windows` dictionary. This will return an instance of the corresponding class to the provided 283 'key' (window). 284 """ 285 self.windows[window].withdraw() 286 287 def create_top_control_buttons(self) -> None: 288 """ 289 Creates frame to contain top control buttons, then utilizes `views.gui_common` `GUIUtils` methods to create buttons for 290 'Start/Stop' and 'Reset' button functions. 291 """ 292 try: 293 self.main_control_button_frame = GUIUtils.create_basic_frame( 294 self, row=0, column=0, rows=1, cols=2 295 ) 296 297 self.start_button_frame, self.start_button = GUIUtils.create_button( 298 self.main_control_button_frame, 299 button_text="Start", 300 command=lambda: self.trigger("START"), 301 bg="green", 302 row=0, 303 column=0, 304 ) 305 306 self.reset_button_frame, _ = GUIUtils.create_button( 307 self.main_control_button_frame, 308 button_text="Reset", 309 command=lambda: self.trigger("RESET"), 310 bg="grey", 311 row=0, 312 column=1, 313 ) 314 logger.info("Main control buttons displayed.") 315 except Exception as e: 316 logger.error(f"Error displaying main control buttons: {e}") 317 raise 318 319 def create_timers(self) -> None: 320 """ 321 Creates frame to contain program timer, max runtime timer, and state timer. Then builds both timers and places them into the frame. 322 """ 323 try: 324 self.timers_frame = GUIUtils.create_basic_frame( 325 self, row=1, column=0, rows=1, cols=2 326 ) 327 328 self.main_timer_frame, _, self.main_timer_text = GUIUtils.create_timer( 329 self.timers_frame, "Time Elapsed:", "0.0s", 0, 0 330 ) 331 self.main_timer_min_sec_text = tk.Label( 332 self.main_timer_frame, text="", bg="light blue", font=("Helvetica", 24) 333 ) 334 self.main_timer_min_sec_text.grid(row=0, column=2) 335 336 ###### BEGIN max total time frame ##### 337 self.maximum_total_time_frame, _, self.maximum_total_time = ( 338 GUIUtils.create_timer( 339 self.timers_frame, "Maximum Total Time:", "0 Minutes, 0 S", 0, 1 340 ) 341 ) 342 self.maximum_total_time_frame.grid(sticky="e") 343 344 ###### BEGIN state time frame ##### 345 self.state_timer_frame, _, self.state_timer_text = GUIUtils.create_timer( 346 self.timers_frame, "State Time:", "0.0s", 1, 0 347 ) 348 self.full_state_time_text = tk.Label( 349 self.state_timer_frame, 350 text="/ 0.0s", 351 bg="light blue", 352 font=("Helvetica", 24), 353 ) 354 self.full_state_time_text.grid(row=0, column=2) 355 356 logger.info("Timers created.") 357 except Exception as e: 358 logger.error(f"Error displaying timers: {e}") 359 raise 360 361 def create_status_widgets(self) -> None: 362 """ 363 Creates frames for status widgets (state label, trial number/progress bar, current stimuli, etc), builds these widgets and places them 364 into their respective frames. 365 """ 366 try: 367 self.status_frame = GUIUtils.create_basic_frame( 368 self, row=2, column=0, rows=1, cols=2 369 ) 370 371 self.program_status_statistic_frame = GUIUtils.create_basic_frame( 372 self.status_frame, row=0, column=0, rows=1, cols=1 373 ) 374 375 self.stimuli_information_frame = GUIUtils.create_basic_frame( 376 self.status_frame, row=0, column=1, rows=1, cols=1 377 ) 378 379 self.status_label = tk.Label( 380 self.program_status_statistic_frame, 381 text="Status: Idle", 382 font=("Helvetica", 24), 383 bg="light blue", 384 highlightthickness=1, 385 highlightbackground="dark blue", 386 ) 387 self.status_label.grid(row=0, column=0) 388 389 self.trials_completed_label = tk.Label( 390 self.program_status_statistic_frame, 391 text="0 / 0 Trials Completed", 392 font=("Helvetica", 20), 393 bg="light blue", 394 highlightthickness=1, 395 highlightbackground="dark blue", 396 ) 397 self.trials_completed_label.grid(row=1, column=0) 398 399 self.progress = ttk.Progressbar( 400 self.program_status_statistic_frame, 401 orient="horizontal", 402 length=300, 403 mode="determinate", 404 value=0, 405 ) 406 self.progress.grid(row=2, column=0, pady=5) 407 408 self.stimuli_label = tk.Label( 409 self.stimuli_information_frame, 410 text="Side One | VS | Side Two", 411 font=("Helvetica", 24), 412 bg="light blue", 413 highlightthickness=1, 414 highlightbackground="dark blue", 415 ) 416 self.stimuli_label.grid(row=0, column=0, pady=5) 417 418 self.trial_number_label = tk.Label( 419 self.stimuli_information_frame, 420 text="Trial Number: ", 421 font=("Helvetica", 24), 422 bg="light blue", 423 highlightthickness=1, 424 highlightbackground="dark blue", 425 ) 426 self.trial_number_label.grid(row=1, column=0, pady=5) 427 logger.info("Status widgets displayed.") 428 except Exception as e: 429 logger.error(f"Error displaying status widget: {e}") 430 raise 431 432 def create_entry_widgets(self) -> None: 433 """ 434 Creates frame to store labels and entries for ITI, TTC, Sample times using `views.gui_common` GUIUtils static class methods. 435 """ 436 try: 437 self.entry_widgets_frame = GUIUtils.create_basic_frame( 438 self, row=3, column=0, rows=1, cols=4 439 ) 440 441 # Simplify the creation of labeled entries 442 self.ITI_Interval_Frame, _, _ = GUIUtils.create_labeled_entry( 443 parent=self.entry_widgets_frame, 444 label_text="ITI Time", 445 text_var=self.tkinter_entries["ITI_var"], 446 row=0, 447 column=0, 448 ) 449 450 self.TTC_Interval_Frame, _, _ = GUIUtils.create_labeled_entry( 451 parent=self.entry_widgets_frame, 452 label_text="TTC Time", 453 text_var=self.tkinter_entries["TTC_var"], 454 row=0, 455 column=1, 456 ) 457 self.Sample_Interval_Frame, _, _ = GUIUtils.create_labeled_entry( 458 parent=self.entry_widgets_frame, 459 label_text="Sample Time", 460 text_var=self.tkinter_entries["sample_var"], 461 row=0, 462 column=2, 463 ) 464 self.num_trial_blocks_frame, _, _ = GUIUtils.create_labeled_entry( 465 parent=self.entry_widgets_frame, 466 label_text="# Trial Blocks", 467 text_var=self.exp_var_entries["Num Trial Blocks"], 468 row=0, 469 column=3, 470 ) 471 472 # Similarly for random plus/minus intervals and the number of stimuli 473 self.ITI_Random_Interval_frame, _, _ = GUIUtils.create_labeled_entry( 474 parent=self.entry_widgets_frame, 475 label_text="+/- ITI", 476 text_var=self.tkinter_entries["ITI_random_entry"], 477 row=1, 478 column=0, 479 ) 480 self.TTC_Random_Interval_frame, _, _ = GUIUtils.create_labeled_entry( 481 parent=self.entry_widgets_frame, 482 label_text="+/- TTC", 483 text_var=self.tkinter_entries["TTC_random_entry"], 484 row=1, 485 column=1, 486 ) 487 self.Sample_Interval_Random_Frame, _, _ = GUIUtils.create_labeled_entry( 488 parent=self.entry_widgets_frame, 489 label_text="+/- Sample", 490 text_var=self.tkinter_entries["sample_random_entry"], 491 row=1, 492 column=2, 493 ) 494 self.num_stimuli_frame, _, _ = GUIUtils.create_labeled_entry( 495 parent=self.entry_widgets_frame, 496 label_text="# Stimuli", 497 text_var=self.exp_var_entries["Num Stimuli"], 498 row=1, 499 column=3, 500 ) 501 logger.info("Entry widgets displayed.") 502 except Exception as e: 503 logger.error(f"Error displaying entry widgets: {e}") 504 raise 505 506 def create_lower_control_buttons(self) -> None: 507 """ 508 Creates frame for lower control buttons (those that open windows and save data) and sets their command to open (deiconify) their 509 respective window. 510 """ 511 try: 512 self.lower_control_buttons_frame = GUIUtils.create_basic_frame( 513 self, row=4, column=0, rows=1, cols=4 514 ) 515 516 self.test_valves_button_frame, _ = GUIUtils.create_button( 517 parent=self.lower_control_buttons_frame, 518 button_text="Valve Testing / Prime Valves", 519 command=lambda: self.show_secondary_window("Valve Testing"), 520 bg="grey", 521 row=0, 522 column=0, 523 ) 524 self.valve_control_button_frame, _ = GUIUtils.create_button( 525 parent=self.lower_control_buttons_frame, 526 button_text="Valve Control", 527 command=lambda: self.show_secondary_window("Valve Control"), 528 bg="grey", 529 row=0, 530 column=1, 531 ) 532 self.program_schedule_button_frame, _ = GUIUtils.create_button( 533 parent=self.lower_control_buttons_frame, 534 button_text="Program Schedule", 535 command=lambda: self.show_secondary_window("Program Schedule"), 536 bg="grey", 537 row=0, 538 column=2, 539 ) 540 self.exp_ctrl_button_frame = GUIUtils.create_button( 541 parent=self.lower_control_buttons_frame, 542 button_text="Valve / Stimuli", 543 command=lambda: self.show_secondary_window("Experiment Control"), 544 bg="grey", 545 row=1, 546 column=0, 547 ) 548 self.lick_window_button_frame = GUIUtils.create_button( 549 parent=self.lower_control_buttons_frame, 550 button_text="Event Data", 551 command=lambda: self.show_secondary_window("Event Data"), 552 bg="grey", 553 row=1, 554 column=1, 555 ) 556 self.raster_plot_button_frame = GUIUtils.create_button( 557 parent=self.lower_control_buttons_frame, 558 button_text="Rasterized Data", 559 command=lambda: self.show_secondary_window("Raster Plot"), 560 bg="grey", 561 row=1, 562 column=2, 563 ) 564 self.save_data_button_frame = GUIUtils.create_button( 565 parent=self.lower_control_buttons_frame, 566 button_text="Save Data", 567 command=lambda: self.save_button_handler(), 568 bg="grey", 569 row=1, 570 column=3, 571 ) 572 logger.info("Lower control buttons created.") 573 except Exception as e: 574 logger.error(f"Error displaying lower control buttons: {e}") 575 raise 576 577 def save_button_handler(self) -> None: 578 """ 579 Here we define behavior for clicking the 'Save Data' button. If either main dataframe is empty, we inform the user of this, otherwise 580 we call the `save_all_data` method from `models.experiment_process_data`. 581 """ 582 sched_df = self.exp_data.program_schedule_df 583 event_df = self.exp_data.event_data.event_dataframe 584 585 if sched_df.empty or event_df.empty: 586 response = GUIUtils.askyesno( 587 "Hmm...", 588 "One some of your data appears missing or empty... save anyway?", 589 ) 590 591 if response: 592 self.exp_data.save_all_data() 593 else: 594 self.exp_data.save_all_data() 595 596 def update_clock_label(self) -> None: 597 """ 598 This method defines logic to update primary timers such as main program timer and state timer. Max time is only updated once, but this 599 operation is separated from this method. This task is scheduled via a tkinter.after call to execute every 100ms so that the main timer 600 is responsive without overwhelming the main thread. 601 """ 602 try: 603 elapsed_time = time.time() - self.exp_data.start_time 604 state_elapsed_time = time.time() - self.exp_data.state_start_time 605 606 min, sec = self.exp_data.convert_seconds_to_minutes_seconds(elapsed_time) 607 608 self.main_timer_text.configure(text="{:.1f}s".format(elapsed_time)) 609 self.main_timer_min_sec_text.configure( 610 text="| {:.0f} Minutes, {:.1f} S".format(min, sec) 611 ) 612 613 self.state_timer_text.configure(text="{:.1f}s".format(state_elapsed_time)) 614 615 clock_update_task = self.after(100, self.update_clock_label) 616 self.scheduled_tasks["CLOCK UPDATE"] = clock_update_task 617 except Exception as e: 618 logger.error(f"Error updating clock label: {e}") 619 raise 620 621 def update_max_time(self) -> None: 622 """ 623 This method updates the maximum runtime timer by retreiving this value from `models.experiment_process_data` and configuring the 624 timer label. 625 """ 626 try: 627 minutes, seconds = self.exp_data.calculate_max_runtime() 628 629 self.maximum_total_time.configure( 630 text="{:.0f} Minutes, {:.1f} S".format(minutes, seconds) 631 ) 632 except Exception as e: 633 logger.error(f"Error updating max time: {e}") 634 raise 635 636 def update_on_new_trial(self, side_1_stimulus: str, side_2_stimulus: str) -> None: 637 """ 638 Updates the new trial items such as program schedule window current trial highlighting and updates 639 the main_gui status information widgets with current trial number and updates progress bar. 640 641 Parameters 642 ---------- 643 - **side_1_stimulus** (*str*): This parameter is of type string and is generally pulled from the program schedule dataframe in 644 `app_logic` 'StateMachine' to provide the new stimulus for the upcoming trial for side one. 645 - **side_2_stimulus** (*str*): This parameter mirrors `side_1_stimulus` in all aspects except that this one reflects the stmulus for 646 side two. 647 """ 648 try: 649 trial_number = self.exp_data.current_trial_number 650 total_trials = self.exp_data.exp_var_entries["Num Trials"] 651 652 self.trials_completed_label.configure( 653 text=f"{trial_number} / {total_trials} Trials Completed" 654 ) 655 self.stimuli_label.configure( 656 text=f"Side One: {side_1_stimulus} | VS | Side Two: {side_2_stimulus}" 657 ) 658 659 self.trial_number_label.configure(text=f"Trial Number: {trial_number}") 660 661 self.windows["Program Schedule"].refresh_start_trial(trial_number) 662 663 # Set the new value for the progress bar 664 self.progress["value"] = (trial_number / total_trials) * 100 665 666 logger.info(f"Updated GUI for new trial {trial_number}.") 667 except Exception as e: 668 logger.error(f"Error updating GUI for new trial: {e}") 669 raise 670 671 def update_on_state_change(self, state_duration_ms: float, state: str) -> None: 672 """ 673 Here we define the GUI objects that need to be updated upon a state change. This includes the current state label as 674 well as the state timer. 675 676 Parameters 677 ---------- 678 - **state_duration_ms** (*float*): Provides the duration of this state in ms. Is generally pulled from the program schedule df for 679 the current trial. Converted to seconds by dividing the parameter by 1000.0. 680 - **state** (*str*): Provides the current to program state to update state label to inform user that program has changed state. 681 """ 682 try: 683 ### clear full state time ### 684 self.full_state_time_text.configure(text="/ {:.1f}s".format(0)) 685 686 ### UPDATE full state time ### 687 state_duration_seconds = state_duration_ms / 1000.0 688 self.full_state_time_text.configure( 689 text="/ {:.1f}s".format(state_duration_seconds) 690 ) 691 692 self.status_label.configure(text=f"Status: {state}") 693 694 logger.info(f"Updated GUI on state change to {state}.") 695 except Exception as e: 696 logger.error(f"Error updating GUI on state change: {e}") 697 raise 698 699 def update_on_stop(self) -> None: 700 """ 701 Here we define the GUI objects that need to be updated when the program is stopped for any reason. This includes the current 702 state label (change to "IDLE), and removing state timer information. 703 """ 704 705 try: 706 self.state_timer_text.configure(text="0.0s") 707 708 self.full_state_time_text.configure(text=" / 0.0s") 709 710 self.status_label.configure(text="Status: IDLE") 711 712 except Exception as e: 713 logger.error(f"Error updating GUI on stop: {e}") 714 raise 715 716 def on_close(self): 717 """ 718 This method is called any time the main program window is closed via the red X or <C-w> shortcut. We stop the listener thread if it 719 is running, then quit() the tkinter mainloop and destroy() the window and all descendent widgets. 720 """ 721 try: 722 if self.arduino_controller.listener_thread is not None: 723 # stop the listener thread so that it will not block exit 724 self.arduino_controller.stop_listener_thread() 725 726 # quit the mainloop and destroy the application 727 self.quit() 728 self.destroy() 729 730 logger.info("Mainloop terminated, widgets destroyed, application closed.") 731 except Exception as e: 732 logger.error(f"Error closing application: {e}") 733 raise
Get the logger in use for the app.
35class MainGUI(tk.Tk): 36 """ 37 The creator and controller of all GUI related items and actions for the program. Inherits from tk.Tk to create a tk.root() 38 from which tk.TopLevel windows can be sprouted and tk.after scheduling calls can be made. 39 40 Attributes 41 ---------- 42 - **exp_data** (*ExperimentProcessData*): An instance of `models.experiment_process_data` ExperimentProcessData, 43 this attribute allows for access and modification of experiment variables, and access for to more specific models like 44 `models.event_data` and `models.arduino_data`. 45 - **arduino_controller** (*ArduinoManager*): An instance of `controllers.arduino_control` ArduinoManager, this allows for communication between this program and the 46 Arduino board. 47 - **trigger** (*Callback method*): trigger is the callback method from `app_logic` module `StateMachine` class, it allows the 48 GUI to attempt state transitions. 49 - **scheduled_tasks** (*dict[str,str]*): self.scheduled_tasks is a dict where keys are short task descriptions (e.g ttc_to_iti) and the values are 50 the str ids for tkinter.after scheduling calls. This allows for tracking and cancellations of scheduled tasks. 51 - (**windows**) (*dict*): A dictionary that holds window titles as keys, and instances of GUI sublasses as values. Allows for easy access of windows and their methods 52 and attributes given a title. 53 54 Methods 55 ------- 56 - `setup_basic_window_attr`() 57 This method sets basic window attributes like title, icon, and expansion settings. 58 - `setup_tkinter_variables`() 59 GUI (Tkinter) variables and acutal stored data for the program are decoupled to separate concerns. Here we link them such that when the GUI variables are updated, 60 the data stored in the model is also updated. 61 - `build_gui_widgets`() 62 Setup all widgets to be placed in the MainGUI window. 63 - `preload_secondary_windows`() 64 This method loads all secondary windows and all content available at start time. This was done to reduce strain on system while program is running. Windows always 65 exist and are simply hidden when not shown to user. 66 - `show_secondary_window`() 67 Takes a window description string for the `windows` dictionary. Access is given to the instance of the class for the description, where the windows `show` method is 68 called. 69 - `hide_secondary_window`() 70 The opposing function to `show_secondary_window`. Hides the window specified as the `window` argument. 71 - `create_top_control_buttons`() 72 Create frames and buttons for top bar start an reset buttons. 73 - `create_timers`() 74 Create start and state start timer labels. 75 - `create_status_widgets`() 76 Create state status widgets such as state label, num trials progress bar and label, etc. 77 - `create_entry_widgets`() 78 Build entry widget and frames for ITI, TTC, Sample times, Num Trials, Num Stimuli. 79 - `create_lower_control_buttons`() 80 Create lower control buttons for opening windows and saving data. 81 - `save_button_handler`() 82 Define behavior for saving all data to xlsx files. 83 - `update_clock_label`() 84 Hold logic for updating GUI clocks every 100ms. 85 - `update_max_time`() 86 Calculate max program runtime. 87 - `update_on_new_trial`() 88 Define GUI objects to update each iteration (new ITI state) of program. 89 - `update_on_state_change`() 90 Define GUI objects to update upon each state (state timer, label, etc). 91 - `update_on_stop`() 92 Update GUI objects to reflect "IDLE" program state once stopped. 93 - `on_close`() 94 Defines GUI shutdown behavior when primary window is closed. 95 """ 96 97 def __init__( 98 self, 99 exp_data: ExperimentProcessData, 100 trigger: Callable[[str], None], 101 arduino_controller: ArduinoManager, 102 ) -> None: 103 """ 104 Initialize the MainGUI window. Build all frames and widgets required by the main window *and* loads all secondary windows that can 105 be opened by the lower control buttons. These are hidden after generation and deiconified (shown) when the button is pressed. 106 107 Parameters 108 ---------- 109 - **exp_data** (*ExperimentProcessData*): A reference to the `models.experiment_process_data` ExperimentProcessData instance, 110 this is used to model data based on user GUI interaction. 111 - **trigger** (*Callback method*): This callback is passed in so that this state can trigger state transitions from GUI objects 112 throughout the class. 113 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` Arduino controller instance, 114 this is the method by which the Arduino is communicated with in the program. `views.valve_testing.valve_testing_window` 115 ValveTestWindow, and `views.valve_control_window` require references here to communicate with Arduino to calibrate and open/close 116 valves. This is also used in `on_close` to stop the Arduino listener thread to avoid thread errors on window close. 117 """ 118 # init tk.Tk to use this class as a tk.root. 119 super().__init__() 120 121 self.exp_data = exp_data 122 self.arduino_controller = arduino_controller 123 self.trigger = trigger 124 125 self.scheduled_tasks: dict[str, str] = {} 126 127 self.setup_basic_window_attr() 128 self.setup_tkinter_variables() 129 self.build_gui_widgets() 130 131 # update_idletasks so that window knows what elements are inside and what width and height they require, 132 # then scale and center the window. 133 self.update_idletasks() 134 GUIUtils.center_window(self) 135 136 # create all secondary windows early so that loading when program is running is fast 137 self.preload_secondary_windows() 138 139 logger.info("MainGUI initialized.") 140 141 def setup_basic_window_attr(self): 142 """ 143 Set basic window attributes such as title, size, window icon, protocol for closing the window, etc. 144 We also bind <Control-w> keyboard shortcut to close the window for convenience. Finally, 145 we set the grid rows and columns to expand and contract as window gets larger or smaller (weight=1). 146 """ 147 148 self.title("Samuelsen Lab Photologic Rig") 149 self.bind("<Control-w>", lambda event: self.on_close()) 150 self.protocol("WM_DELETE_WINDOW", self.on_close) 151 152 icon_path = GUIUtils.get_window_icon_path() 153 GUIUtils.set_program_icon(self, icon_path=icon_path) 154 155 # set cols and rows to expand to fill space in main gui 156 for i in range(5): 157 self.grid_rowconfigure(i, weight=1) 158 159 self.grid_columnconfigure(0, weight=1) 160 161 def setup_tkinter_variables(self) -> None: 162 """ 163 Tkinter variables for tkinter entries are created here. These are the variables updated on user input. Default values 164 are set by finding the default value in `models.experiment_process_data`. Traces are added to each variable which update 165 the model upon an update (write) to the Tkinter variable. 166 """ 167 # tkinter variables for time related 168 # modifications in the experiment 169 self.tkinter_entries = { 170 "ITI_var": tk.IntVar(), 171 "TTC_var": tk.IntVar(), 172 "sample_var": tk.IntVar(), 173 "ITI_random_entry": tk.IntVar(), 174 "TTC_random_entry": tk.IntVar(), 175 "sample_random_entry": tk.IntVar(), 176 } 177 178 # fill the tkinter entry boxes with their default values as configured in self.exp_data 179 for key in self.tkinter_entries.keys(): 180 self.tkinter_entries[key].set(self.exp_data.get_default_value(key)) 181 182 # we add traces to all of our tkinter variables so that on value update 183 # we sent those updates to the corresponding model (data storage location) 184 for key, value in self.tkinter_entries.items(): 185 value.trace_add( 186 "write", 187 lambda *args, key=key, value=value: self.exp_data.update_model( 188 key, GUIUtils.safe_tkinter_get(value) 189 ), 190 ) 191 192 # tkinter variables for other variables in the experiment such as num stimuli 193 # or number of trial blocks 194 self.exp_var_entries = { 195 "Num Trial Blocks": tk.IntVar(), 196 "Num Stimuli": tk.IntVar(), 197 } 198 199 # fill the exp_var entry boxes with their default values as configured in self.exp_data 200 for key in self.exp_var_entries.keys(): 201 self.exp_var_entries[key].set(self.exp_data.get_default_value(key)) 202 203 for key, value in self.exp_var_entries.items(): 204 value.trace_add( 205 "write", 206 lambda *args, key=key, value=value: self.exp_data.update_model( 207 key, GUIUtils.safe_tkinter_get(value) 208 ), 209 ) 210 211 def build_gui_widgets(self) -> None: 212 """ 213 Build all GUI widgets by calling respective functions for each type of widget listed here. 214 """ 215 try: 216 self.create_top_control_buttons() 217 self.create_timers() 218 self.create_status_widgets() 219 self.create_entry_widgets() 220 self.create_lower_control_buttons() 221 logger.info("GUI setup completed.") 222 except Exception as e: 223 logger.error(f"Error setting up GUI: {e}") 224 raise 225 226 def preload_secondary_windows(self) -> None: 227 """ 228 Build all secondary windows (windows that require a button push to view) and get them ready 229 to be opened (deiconified in tkinter terms) when the user clicks the corresponding button. 230 """ 231 # define the data that ExperimentCtlWindow() needs to function 232 stimuli_data = self.exp_data.stimuli_data 233 event_data = self.exp_data.event_data 234 235 self.windows = { 236 "Experiment Control": ExperimentCtlWindow( 237 self.exp_data, stimuli_data, self.trigger 238 ), 239 "Program Schedule": ProgramScheduleWindow( 240 self.exp_data, 241 stimuli_data, 242 ), 243 "Raster Plot": ( 244 RasterizedDataWindow(1, self.exp_data), 245 RasterizedDataWindow(2, self.exp_data), 246 ), 247 "Event Data": EventWindow(event_data), 248 "Valve Testing": ValveTestWindow(self.arduino_controller), 249 "Valve Control": ValveControlWindow(self.arduino_controller), 250 } 251 252 def show_secondary_window(self, window: str) -> None: 253 """ 254 Show the window corresponding to a button press or other event calling this method. Accesses class attribute `windows` to gain 255 accesss to the desired instance and call the class `show` method. 256 257 Parameters 258 ---------- 259 - **window** (*str*): A key to the `windows` dictionary. This will return an instance of the corresponding class to the provided 260 'key' (window). 261 """ 262 if isinstance(self.windows[window], tuple): 263 for instance in self.windows[window]: 264 instance.deiconify() 265 else: 266 df = self.exp_data.program_schedule_df 267 if window == "Valve Testing" and not df.empty: 268 GUIUtils.display_error( 269 "CANNOT DISPLAY WINDOW", 270 "Since the schedule has been generated, valve testing has been disabled for this experiment. Reset the application to test again.", 271 ) 272 return 273 274 self.windows[window].show() 275 276 def hide_secondary_window(self, window: str) -> None: 277 """ 278 Performs the opposite action of `show_secondary_window`. Hides the window without destroyoing it by calling tkinter method 279 'withdraw' on the tk.TopLevel (class/`windows` dict value) instance. 280 281 Parameters 282 ---------- 283 - **window** (*str*): A key to the `windows` dictionary. This will return an instance of the corresponding class to the provided 284 'key' (window). 285 """ 286 self.windows[window].withdraw() 287 288 def create_top_control_buttons(self) -> None: 289 """ 290 Creates frame to contain top control buttons, then utilizes `views.gui_common` `GUIUtils` methods to create buttons for 291 'Start/Stop' and 'Reset' button functions. 292 """ 293 try: 294 self.main_control_button_frame = GUIUtils.create_basic_frame( 295 self, row=0, column=0, rows=1, cols=2 296 ) 297 298 self.start_button_frame, self.start_button = GUIUtils.create_button( 299 self.main_control_button_frame, 300 button_text="Start", 301 command=lambda: self.trigger("START"), 302 bg="green", 303 row=0, 304 column=0, 305 ) 306 307 self.reset_button_frame, _ = GUIUtils.create_button( 308 self.main_control_button_frame, 309 button_text="Reset", 310 command=lambda: self.trigger("RESET"), 311 bg="grey", 312 row=0, 313 column=1, 314 ) 315 logger.info("Main control buttons displayed.") 316 except Exception as e: 317 logger.error(f"Error displaying main control buttons: {e}") 318 raise 319 320 def create_timers(self) -> None: 321 """ 322 Creates frame to contain program timer, max runtime timer, and state timer. Then builds both timers and places them into the frame. 323 """ 324 try: 325 self.timers_frame = GUIUtils.create_basic_frame( 326 self, row=1, column=0, rows=1, cols=2 327 ) 328 329 self.main_timer_frame, _, self.main_timer_text = GUIUtils.create_timer( 330 self.timers_frame, "Time Elapsed:", "0.0s", 0, 0 331 ) 332 self.main_timer_min_sec_text = tk.Label( 333 self.main_timer_frame, text="", bg="light blue", font=("Helvetica", 24) 334 ) 335 self.main_timer_min_sec_text.grid(row=0, column=2) 336 337 ###### BEGIN max total time frame ##### 338 self.maximum_total_time_frame, _, self.maximum_total_time = ( 339 GUIUtils.create_timer( 340 self.timers_frame, "Maximum Total Time:", "0 Minutes, 0 S", 0, 1 341 ) 342 ) 343 self.maximum_total_time_frame.grid(sticky="e") 344 345 ###### BEGIN state time frame ##### 346 self.state_timer_frame, _, self.state_timer_text = GUIUtils.create_timer( 347 self.timers_frame, "State Time:", "0.0s", 1, 0 348 ) 349 self.full_state_time_text = tk.Label( 350 self.state_timer_frame, 351 text="/ 0.0s", 352 bg="light blue", 353 font=("Helvetica", 24), 354 ) 355 self.full_state_time_text.grid(row=0, column=2) 356 357 logger.info("Timers created.") 358 except Exception as e: 359 logger.error(f"Error displaying timers: {e}") 360 raise 361 362 def create_status_widgets(self) -> None: 363 """ 364 Creates frames for status widgets (state label, trial number/progress bar, current stimuli, etc), builds these widgets and places them 365 into their respective frames. 366 """ 367 try: 368 self.status_frame = GUIUtils.create_basic_frame( 369 self, row=2, column=0, rows=1, cols=2 370 ) 371 372 self.program_status_statistic_frame = GUIUtils.create_basic_frame( 373 self.status_frame, row=0, column=0, rows=1, cols=1 374 ) 375 376 self.stimuli_information_frame = GUIUtils.create_basic_frame( 377 self.status_frame, row=0, column=1, rows=1, cols=1 378 ) 379 380 self.status_label = tk.Label( 381 self.program_status_statistic_frame, 382 text="Status: Idle", 383 font=("Helvetica", 24), 384 bg="light blue", 385 highlightthickness=1, 386 highlightbackground="dark blue", 387 ) 388 self.status_label.grid(row=0, column=0) 389 390 self.trials_completed_label = tk.Label( 391 self.program_status_statistic_frame, 392 text="0 / 0 Trials Completed", 393 font=("Helvetica", 20), 394 bg="light blue", 395 highlightthickness=1, 396 highlightbackground="dark blue", 397 ) 398 self.trials_completed_label.grid(row=1, column=0) 399 400 self.progress = ttk.Progressbar( 401 self.program_status_statistic_frame, 402 orient="horizontal", 403 length=300, 404 mode="determinate", 405 value=0, 406 ) 407 self.progress.grid(row=2, column=0, pady=5) 408 409 self.stimuli_label = tk.Label( 410 self.stimuli_information_frame, 411 text="Side One | VS | Side Two", 412 font=("Helvetica", 24), 413 bg="light blue", 414 highlightthickness=1, 415 highlightbackground="dark blue", 416 ) 417 self.stimuli_label.grid(row=0, column=0, pady=5) 418 419 self.trial_number_label = tk.Label( 420 self.stimuli_information_frame, 421 text="Trial Number: ", 422 font=("Helvetica", 24), 423 bg="light blue", 424 highlightthickness=1, 425 highlightbackground="dark blue", 426 ) 427 self.trial_number_label.grid(row=1, column=0, pady=5) 428 logger.info("Status widgets displayed.") 429 except Exception as e: 430 logger.error(f"Error displaying status widget: {e}") 431 raise 432 433 def create_entry_widgets(self) -> None: 434 """ 435 Creates frame to store labels and entries for ITI, TTC, Sample times using `views.gui_common` GUIUtils static class methods. 436 """ 437 try: 438 self.entry_widgets_frame = GUIUtils.create_basic_frame( 439 self, row=3, column=0, rows=1, cols=4 440 ) 441 442 # Simplify the creation of labeled entries 443 self.ITI_Interval_Frame, _, _ = GUIUtils.create_labeled_entry( 444 parent=self.entry_widgets_frame, 445 label_text="ITI Time", 446 text_var=self.tkinter_entries["ITI_var"], 447 row=0, 448 column=0, 449 ) 450 451 self.TTC_Interval_Frame, _, _ = GUIUtils.create_labeled_entry( 452 parent=self.entry_widgets_frame, 453 label_text="TTC Time", 454 text_var=self.tkinter_entries["TTC_var"], 455 row=0, 456 column=1, 457 ) 458 self.Sample_Interval_Frame, _, _ = GUIUtils.create_labeled_entry( 459 parent=self.entry_widgets_frame, 460 label_text="Sample Time", 461 text_var=self.tkinter_entries["sample_var"], 462 row=0, 463 column=2, 464 ) 465 self.num_trial_blocks_frame, _, _ = GUIUtils.create_labeled_entry( 466 parent=self.entry_widgets_frame, 467 label_text="# Trial Blocks", 468 text_var=self.exp_var_entries["Num Trial Blocks"], 469 row=0, 470 column=3, 471 ) 472 473 # Similarly for random plus/minus intervals and the number of stimuli 474 self.ITI_Random_Interval_frame, _, _ = GUIUtils.create_labeled_entry( 475 parent=self.entry_widgets_frame, 476 label_text="+/- ITI", 477 text_var=self.tkinter_entries["ITI_random_entry"], 478 row=1, 479 column=0, 480 ) 481 self.TTC_Random_Interval_frame, _, _ = GUIUtils.create_labeled_entry( 482 parent=self.entry_widgets_frame, 483 label_text="+/- TTC", 484 text_var=self.tkinter_entries["TTC_random_entry"], 485 row=1, 486 column=1, 487 ) 488 self.Sample_Interval_Random_Frame, _, _ = GUIUtils.create_labeled_entry( 489 parent=self.entry_widgets_frame, 490 label_text="+/- Sample", 491 text_var=self.tkinter_entries["sample_random_entry"], 492 row=1, 493 column=2, 494 ) 495 self.num_stimuli_frame, _, _ = GUIUtils.create_labeled_entry( 496 parent=self.entry_widgets_frame, 497 label_text="# Stimuli", 498 text_var=self.exp_var_entries["Num Stimuli"], 499 row=1, 500 column=3, 501 ) 502 logger.info("Entry widgets displayed.") 503 except Exception as e: 504 logger.error(f"Error displaying entry widgets: {e}") 505 raise 506 507 def create_lower_control_buttons(self) -> None: 508 """ 509 Creates frame for lower control buttons (those that open windows and save data) and sets their command to open (deiconify) their 510 respective window. 511 """ 512 try: 513 self.lower_control_buttons_frame = GUIUtils.create_basic_frame( 514 self, row=4, column=0, rows=1, cols=4 515 ) 516 517 self.test_valves_button_frame, _ = GUIUtils.create_button( 518 parent=self.lower_control_buttons_frame, 519 button_text="Valve Testing / Prime Valves", 520 command=lambda: self.show_secondary_window("Valve Testing"), 521 bg="grey", 522 row=0, 523 column=0, 524 ) 525 self.valve_control_button_frame, _ = GUIUtils.create_button( 526 parent=self.lower_control_buttons_frame, 527 button_text="Valve Control", 528 command=lambda: self.show_secondary_window("Valve Control"), 529 bg="grey", 530 row=0, 531 column=1, 532 ) 533 self.program_schedule_button_frame, _ = GUIUtils.create_button( 534 parent=self.lower_control_buttons_frame, 535 button_text="Program Schedule", 536 command=lambda: self.show_secondary_window("Program Schedule"), 537 bg="grey", 538 row=0, 539 column=2, 540 ) 541 self.exp_ctrl_button_frame = GUIUtils.create_button( 542 parent=self.lower_control_buttons_frame, 543 button_text="Valve / Stimuli", 544 command=lambda: self.show_secondary_window("Experiment Control"), 545 bg="grey", 546 row=1, 547 column=0, 548 ) 549 self.lick_window_button_frame = GUIUtils.create_button( 550 parent=self.lower_control_buttons_frame, 551 button_text="Event Data", 552 command=lambda: self.show_secondary_window("Event Data"), 553 bg="grey", 554 row=1, 555 column=1, 556 ) 557 self.raster_plot_button_frame = GUIUtils.create_button( 558 parent=self.lower_control_buttons_frame, 559 button_text="Rasterized Data", 560 command=lambda: self.show_secondary_window("Raster Plot"), 561 bg="grey", 562 row=1, 563 column=2, 564 ) 565 self.save_data_button_frame = GUIUtils.create_button( 566 parent=self.lower_control_buttons_frame, 567 button_text="Save Data", 568 command=lambda: self.save_button_handler(), 569 bg="grey", 570 row=1, 571 column=3, 572 ) 573 logger.info("Lower control buttons created.") 574 except Exception as e: 575 logger.error(f"Error displaying lower control buttons: {e}") 576 raise 577 578 def save_button_handler(self) -> None: 579 """ 580 Here we define behavior for clicking the 'Save Data' button. If either main dataframe is empty, we inform the user of this, otherwise 581 we call the `save_all_data` method from `models.experiment_process_data`. 582 """ 583 sched_df = self.exp_data.program_schedule_df 584 event_df = self.exp_data.event_data.event_dataframe 585 586 if sched_df.empty or event_df.empty: 587 response = GUIUtils.askyesno( 588 "Hmm...", 589 "One some of your data appears missing or empty... save anyway?", 590 ) 591 592 if response: 593 self.exp_data.save_all_data() 594 else: 595 self.exp_data.save_all_data() 596 597 def update_clock_label(self) -> None: 598 """ 599 This method defines logic to update primary timers such as main program timer and state timer. Max time is only updated once, but this 600 operation is separated from this method. This task is scheduled via a tkinter.after call to execute every 100ms so that the main timer 601 is responsive without overwhelming the main thread. 602 """ 603 try: 604 elapsed_time = time.time() - self.exp_data.start_time 605 state_elapsed_time = time.time() - self.exp_data.state_start_time 606 607 min, sec = self.exp_data.convert_seconds_to_minutes_seconds(elapsed_time) 608 609 self.main_timer_text.configure(text="{:.1f}s".format(elapsed_time)) 610 self.main_timer_min_sec_text.configure( 611 text="| {:.0f} Minutes, {:.1f} S".format(min, sec) 612 ) 613 614 self.state_timer_text.configure(text="{:.1f}s".format(state_elapsed_time)) 615 616 clock_update_task = self.after(100, self.update_clock_label) 617 self.scheduled_tasks["CLOCK UPDATE"] = clock_update_task 618 except Exception as e: 619 logger.error(f"Error updating clock label: {e}") 620 raise 621 622 def update_max_time(self) -> None: 623 """ 624 This method updates the maximum runtime timer by retreiving this value from `models.experiment_process_data` and configuring the 625 timer label. 626 """ 627 try: 628 minutes, seconds = self.exp_data.calculate_max_runtime() 629 630 self.maximum_total_time.configure( 631 text="{:.0f} Minutes, {:.1f} S".format(minutes, seconds) 632 ) 633 except Exception as e: 634 logger.error(f"Error updating max time: {e}") 635 raise 636 637 def update_on_new_trial(self, side_1_stimulus: str, side_2_stimulus: str) -> None: 638 """ 639 Updates the new trial items such as program schedule window current trial highlighting and updates 640 the main_gui status information widgets with current trial number and updates progress bar. 641 642 Parameters 643 ---------- 644 - **side_1_stimulus** (*str*): This parameter is of type string and is generally pulled from the program schedule dataframe in 645 `app_logic` 'StateMachine' to provide the new stimulus for the upcoming trial for side one. 646 - **side_2_stimulus** (*str*): This parameter mirrors `side_1_stimulus` in all aspects except that this one reflects the stmulus for 647 side two. 648 """ 649 try: 650 trial_number = self.exp_data.current_trial_number 651 total_trials = self.exp_data.exp_var_entries["Num Trials"] 652 653 self.trials_completed_label.configure( 654 text=f"{trial_number} / {total_trials} Trials Completed" 655 ) 656 self.stimuli_label.configure( 657 text=f"Side One: {side_1_stimulus} | VS | Side Two: {side_2_stimulus}" 658 ) 659 660 self.trial_number_label.configure(text=f"Trial Number: {trial_number}") 661 662 self.windows["Program Schedule"].refresh_start_trial(trial_number) 663 664 # Set the new value for the progress bar 665 self.progress["value"] = (trial_number / total_trials) * 100 666 667 logger.info(f"Updated GUI for new trial {trial_number}.") 668 except Exception as e: 669 logger.error(f"Error updating GUI for new trial: {e}") 670 raise 671 672 def update_on_state_change(self, state_duration_ms: float, state: str) -> None: 673 """ 674 Here we define the GUI objects that need to be updated upon a state change. This includes the current state label as 675 well as the state timer. 676 677 Parameters 678 ---------- 679 - **state_duration_ms** (*float*): Provides the duration of this state in ms. Is generally pulled from the program schedule df for 680 the current trial. Converted to seconds by dividing the parameter by 1000.0. 681 - **state** (*str*): Provides the current to program state to update state label to inform user that program has changed state. 682 """ 683 try: 684 ### clear full state time ### 685 self.full_state_time_text.configure(text="/ {:.1f}s".format(0)) 686 687 ### UPDATE full state time ### 688 state_duration_seconds = state_duration_ms / 1000.0 689 self.full_state_time_text.configure( 690 text="/ {:.1f}s".format(state_duration_seconds) 691 ) 692 693 self.status_label.configure(text=f"Status: {state}") 694 695 logger.info(f"Updated GUI on state change to {state}.") 696 except Exception as e: 697 logger.error(f"Error updating GUI on state change: {e}") 698 raise 699 700 def update_on_stop(self) -> None: 701 """ 702 Here we define the GUI objects that need to be updated when the program is stopped for any reason. This includes the current 703 state label (change to "IDLE), and removing state timer information. 704 """ 705 706 try: 707 self.state_timer_text.configure(text="0.0s") 708 709 self.full_state_time_text.configure(text=" / 0.0s") 710 711 self.status_label.configure(text="Status: IDLE") 712 713 except Exception as e: 714 logger.error(f"Error updating GUI on stop: {e}") 715 raise 716 717 def on_close(self): 718 """ 719 This method is called any time the main program window is closed via the red X or <C-w> shortcut. We stop the listener thread if it 720 is running, then quit() the tkinter mainloop and destroy() the window and all descendent widgets. 721 """ 722 try: 723 if self.arduino_controller.listener_thread is not None: 724 # stop the listener thread so that it will not block exit 725 self.arduino_controller.stop_listener_thread() 726 727 # quit the mainloop and destroy the application 728 self.quit() 729 self.destroy() 730 731 logger.info("Mainloop terminated, widgets destroyed, application closed.") 732 except Exception as e: 733 logger.error(f"Error closing application: {e}") 734 raise
The creator and controller of all GUI related items and actions for the program. Inherits from tk.Tk to create a tk.root() from which tk.TopLevel windows can be sprouted and tk.after scheduling calls can be made.
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. - trigger (Callback method): trigger is the callback method from
app_logic
moduleStateMachine
class, it allows the GUI to attempt state transitions. - scheduled_tasks (dict[str,str]): self.scheduled_tasks is a dict where keys are short task descriptions (e.g ttc_to_iti) and the values are the str ids for tkinter.after scheduling calls. This allows for tracking and cancellations of scheduled tasks.
- (windows) (dict): A dictionary that holds window titles as keys, and instances of GUI sublasses as values. Allows for easy access of windows and their methods and attributes given a title.
Methods
setup_basic_window_attr
() This method sets basic window attributes like title, icon, and expansion settings.setup_tkinter_variables
() GUI (Tkinter) variables and acutal stored data for the program are decoupled to separate concerns. Here we link them such that when the GUI variables are updated, the data stored in the model is also updated.build_gui_widgets
() Setup all widgets to be placed in the MainGUI window.preload_secondary_windows
() This method loads all secondary windows and all content available at start time. This was done to reduce strain on system while program is running. Windows always exist and are simply hidden when not shown to user.show_secondary_window
() Takes a window description string for thewindows
dictionary. Access is given to the instance of the class for the description, where the windowsshow
method is called.hide_secondary_window
() The opposing function toshow_secondary_window
. Hides the window specified as thewindow
argument.create_top_control_buttons
() Create frames and buttons for top bar start an reset buttons.create_timers
() Create start and state start timer labels.create_status_widgets
() Create state status widgets such as state label, num trials progress bar and label, etc.create_entry_widgets
() Build entry widget and frames for ITI, TTC, Sample times, Num Trials, Num Stimuli.create_lower_control_buttons
() Create lower control buttons for opening windows and saving data.save_button_handler
() Define behavior for saving all data to xlsx files.update_clock_label
() Hold logic for updating GUI clocks every 100ms.update_max_time
() Calculate max program runtime.update_on_new_trial
() Define GUI objects to update each iteration (new ITI state) of program.update_on_state_change
() Define GUI objects to update upon each state (state timer, label, etc).update_on_stop
() Update GUI objects to reflect "IDLE" program state once stopped.on_close
() Defines GUI shutdown behavior when primary window is closed.
97 def __init__( 98 self, 99 exp_data: ExperimentProcessData, 100 trigger: Callable[[str], None], 101 arduino_controller: ArduinoManager, 102 ) -> None: 103 """ 104 Initialize the MainGUI window. Build all frames and widgets required by the main window *and* loads all secondary windows that can 105 be opened by the lower control buttons. These are hidden after generation and deiconified (shown) when the button is pressed. 106 107 Parameters 108 ---------- 109 - **exp_data** (*ExperimentProcessData*): A reference to the `models.experiment_process_data` ExperimentProcessData instance, 110 this is used to model data based on user GUI interaction. 111 - **trigger** (*Callback method*): This callback is passed in so that this state can trigger state transitions from GUI objects 112 throughout the class. 113 - **arduino_controller** (*ArduinoManager*): A reference to the `controllers.arduino_control` Arduino controller instance, 114 this is the method by which the Arduino is communicated with in the program. `views.valve_testing.valve_testing_window` 115 ValveTestWindow, and `views.valve_control_window` require references here to communicate with Arduino to calibrate and open/close 116 valves. This is also used in `on_close` to stop the Arduino listener thread to avoid thread errors on window close. 117 """ 118 # init tk.Tk to use this class as a tk.root. 119 super().__init__() 120 121 self.exp_data = exp_data 122 self.arduino_controller = arduino_controller 123 self.trigger = trigger 124 125 self.scheduled_tasks: dict[str, str] = {} 126 127 self.setup_basic_window_attr() 128 self.setup_tkinter_variables() 129 self.build_gui_widgets() 130 131 # update_idletasks so that window knows what elements are inside and what width and height they require, 132 # then scale and center the window. 133 self.update_idletasks() 134 GUIUtils.center_window(self) 135 136 # create all secondary windows early so that loading when program is running is fast 137 self.preload_secondary_windows() 138 139 logger.info("MainGUI initialized.")
Initialize the MainGUI window. Build all frames and widgets required by the main window and loads all secondary windows that can be opened by the lower control buttons. These are hidden after generation and deiconified (shown) when the button is pressed.
Parameters
- exp_data (ExperimentProcessData): A reference to the
models.experiment_process_data
ExperimentProcessData instance, this is used to model data based on user GUI interaction. - trigger (Callback method): This callback is passed in so that this state can trigger state transitions from GUI objects throughout the class.
- 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.views.valve_testing.valve_testing_window
ValveTestWindow, andviews.valve_control_window
require references here to communicate with Arduino to calibrate and open/close valves. This is also used inon_close
to stop the Arduino listener thread to avoid thread errors on window close.
141 def setup_basic_window_attr(self): 142 """ 143 Set basic window attributes such as title, size, window icon, protocol for closing the window, etc. 144 We also bind <Control-w> keyboard shortcut to close the window for convenience. Finally, 145 we set the grid rows and columns to expand and contract as window gets larger or smaller (weight=1). 146 """ 147 148 self.title("Samuelsen Lab Photologic Rig") 149 self.bind("<Control-w>", lambda event: self.on_close()) 150 self.protocol("WM_DELETE_WINDOW", self.on_close) 151 152 icon_path = GUIUtils.get_window_icon_path() 153 GUIUtils.set_program_icon(self, icon_path=icon_path) 154 155 # set cols and rows to expand to fill space in main gui 156 for i in range(5): 157 self.grid_rowconfigure(i, weight=1) 158 159 self.grid_columnconfigure(0, weight=1)
Set basic window attributes such as title, size, window icon, protocol for closing the window, etc.
We also bind
161 def setup_tkinter_variables(self) -> None: 162 """ 163 Tkinter variables for tkinter entries are created here. These are the variables updated on user input. Default values 164 are set by finding the default value in `models.experiment_process_data`. Traces are added to each variable which update 165 the model upon an update (write) to the Tkinter variable. 166 """ 167 # tkinter variables for time related 168 # modifications in the experiment 169 self.tkinter_entries = { 170 "ITI_var": tk.IntVar(), 171 "TTC_var": tk.IntVar(), 172 "sample_var": tk.IntVar(), 173 "ITI_random_entry": tk.IntVar(), 174 "TTC_random_entry": tk.IntVar(), 175 "sample_random_entry": tk.IntVar(), 176 } 177 178 # fill the tkinter entry boxes with their default values as configured in self.exp_data 179 for key in self.tkinter_entries.keys(): 180 self.tkinter_entries[key].set(self.exp_data.get_default_value(key)) 181 182 # we add traces to all of our tkinter variables so that on value update 183 # we sent those updates to the corresponding model (data storage location) 184 for key, value in self.tkinter_entries.items(): 185 value.trace_add( 186 "write", 187 lambda *args, key=key, value=value: self.exp_data.update_model( 188 key, GUIUtils.safe_tkinter_get(value) 189 ), 190 ) 191 192 # tkinter variables for other variables in the experiment such as num stimuli 193 # or number of trial blocks 194 self.exp_var_entries = { 195 "Num Trial Blocks": tk.IntVar(), 196 "Num Stimuli": tk.IntVar(), 197 } 198 199 # fill the exp_var entry boxes with their default values as configured in self.exp_data 200 for key in self.exp_var_entries.keys(): 201 self.exp_var_entries[key].set(self.exp_data.get_default_value(key)) 202 203 for key, value in self.exp_var_entries.items(): 204 value.trace_add( 205 "write", 206 lambda *args, key=key, value=value: self.exp_data.update_model( 207 key, GUIUtils.safe_tkinter_get(value) 208 ), 209 )
Tkinter variables for tkinter entries are created here. These are the variables updated on user input. Default values
are set by finding the default value in models.experiment_process_data
. Traces are added to each variable which update
the model upon an update (write) to the Tkinter variable.
211 def build_gui_widgets(self) -> None: 212 """ 213 Build all GUI widgets by calling respective functions for each type of widget listed here. 214 """ 215 try: 216 self.create_top_control_buttons() 217 self.create_timers() 218 self.create_status_widgets() 219 self.create_entry_widgets() 220 self.create_lower_control_buttons() 221 logger.info("GUI setup completed.") 222 except Exception as e: 223 logger.error(f"Error setting up GUI: {e}") 224 raise
Build all GUI widgets by calling respective functions for each type of widget listed here.
226 def preload_secondary_windows(self) -> None: 227 """ 228 Build all secondary windows (windows that require a button push to view) and get them ready 229 to be opened (deiconified in tkinter terms) when the user clicks the corresponding button. 230 """ 231 # define the data that ExperimentCtlWindow() needs to function 232 stimuli_data = self.exp_data.stimuli_data 233 event_data = self.exp_data.event_data 234 235 self.windows = { 236 "Experiment Control": ExperimentCtlWindow( 237 self.exp_data, stimuli_data, self.trigger 238 ), 239 "Program Schedule": ProgramScheduleWindow( 240 self.exp_data, 241 stimuli_data, 242 ), 243 "Raster Plot": ( 244 RasterizedDataWindow(1, self.exp_data), 245 RasterizedDataWindow(2, self.exp_data), 246 ), 247 "Event Data": EventWindow(event_data), 248 "Valve Testing": ValveTestWindow(self.arduino_controller), 249 "Valve Control": ValveControlWindow(self.arduino_controller), 250 }
Build all secondary windows (windows that require a button push to view) and get them ready to be opened (deiconified in tkinter terms) when the user clicks the corresponding button.
252 def show_secondary_window(self, window: str) -> None: 253 """ 254 Show the window corresponding to a button press or other event calling this method. Accesses class attribute `windows` to gain 255 accesss to the desired instance and call the class `show` method. 256 257 Parameters 258 ---------- 259 - **window** (*str*): A key to the `windows` dictionary. This will return an instance of the corresponding class to the provided 260 'key' (window). 261 """ 262 if isinstance(self.windows[window], tuple): 263 for instance in self.windows[window]: 264 instance.deiconify() 265 else: 266 df = self.exp_data.program_schedule_df 267 if window == "Valve Testing" and not df.empty: 268 GUIUtils.display_error( 269 "CANNOT DISPLAY WINDOW", 270 "Since the schedule has been generated, valve testing has been disabled for this experiment. Reset the application to test again.", 271 ) 272 return 273 274 self.windows[window].show()
Show the window corresponding to a button press or other event calling this method. Accesses class attribute windows
to gain
accesss to the desired instance and call the class show
method.
Parameters
- window (str): A key to the
windows
dictionary. This will return an instance of the corresponding class to the provided 'key' (window).
276 def hide_secondary_window(self, window: str) -> None: 277 """ 278 Performs the opposite action of `show_secondary_window`. Hides the window without destroyoing it by calling tkinter method 279 'withdraw' on the tk.TopLevel (class/`windows` dict value) instance. 280 281 Parameters 282 ---------- 283 - **window** (*str*): A key to the `windows` dictionary. This will return an instance of the corresponding class to the provided 284 'key' (window). 285 """ 286 self.windows[window].withdraw()
Performs the opposite action of show_secondary_window
. Hides the window without destroyoing it by calling tkinter method
'withdraw' on the tk.TopLevel (class/windows
dict value) instance.
Parameters
- window (str): A key to the
windows
dictionary. This will return an instance of the corresponding class to the provided 'key' (window).
320 def create_timers(self) -> None: 321 """ 322 Creates frame to contain program timer, max runtime timer, and state timer. Then builds both timers and places them into the frame. 323 """ 324 try: 325 self.timers_frame = GUIUtils.create_basic_frame( 326 self, row=1, column=0, rows=1, cols=2 327 ) 328 329 self.main_timer_frame, _, self.main_timer_text = GUIUtils.create_timer( 330 self.timers_frame, "Time Elapsed:", "0.0s", 0, 0 331 ) 332 self.main_timer_min_sec_text = tk.Label( 333 self.main_timer_frame, text="", bg="light blue", font=("Helvetica", 24) 334 ) 335 self.main_timer_min_sec_text.grid(row=0, column=2) 336 337 ###### BEGIN max total time frame ##### 338 self.maximum_total_time_frame, _, self.maximum_total_time = ( 339 GUIUtils.create_timer( 340 self.timers_frame, "Maximum Total Time:", "0 Minutes, 0 S", 0, 1 341 ) 342 ) 343 self.maximum_total_time_frame.grid(sticky="e") 344 345 ###### BEGIN state time frame ##### 346 self.state_timer_frame, _, self.state_timer_text = GUIUtils.create_timer( 347 self.timers_frame, "State Time:", "0.0s", 1, 0 348 ) 349 self.full_state_time_text = tk.Label( 350 self.state_timer_frame, 351 text="/ 0.0s", 352 bg="light blue", 353 font=("Helvetica", 24), 354 ) 355 self.full_state_time_text.grid(row=0, column=2) 356 357 logger.info("Timers created.") 358 except Exception as e: 359 logger.error(f"Error displaying timers: {e}") 360 raise
Creates frame to contain program timer, max runtime timer, and state timer. Then builds both timers and places them into the frame.
362 def create_status_widgets(self) -> None: 363 """ 364 Creates frames for status widgets (state label, trial number/progress bar, current stimuli, etc), builds these widgets and places them 365 into their respective frames. 366 """ 367 try: 368 self.status_frame = GUIUtils.create_basic_frame( 369 self, row=2, column=0, rows=1, cols=2 370 ) 371 372 self.program_status_statistic_frame = GUIUtils.create_basic_frame( 373 self.status_frame, row=0, column=0, rows=1, cols=1 374 ) 375 376 self.stimuli_information_frame = GUIUtils.create_basic_frame( 377 self.status_frame, row=0, column=1, rows=1, cols=1 378 ) 379 380 self.status_label = tk.Label( 381 self.program_status_statistic_frame, 382 text="Status: Idle", 383 font=("Helvetica", 24), 384 bg="light blue", 385 highlightthickness=1, 386 highlightbackground="dark blue", 387 ) 388 self.status_label.grid(row=0, column=0) 389 390 self.trials_completed_label = tk.Label( 391 self.program_status_statistic_frame, 392 text="0 / 0 Trials Completed", 393 font=("Helvetica", 20), 394 bg="light blue", 395 highlightthickness=1, 396 highlightbackground="dark blue", 397 ) 398 self.trials_completed_label.grid(row=1, column=0) 399 400 self.progress = ttk.Progressbar( 401 self.program_status_statistic_frame, 402 orient="horizontal", 403 length=300, 404 mode="determinate", 405 value=0, 406 ) 407 self.progress.grid(row=2, column=0, pady=5) 408 409 self.stimuli_label = tk.Label( 410 self.stimuli_information_frame, 411 text="Side One | VS | Side Two", 412 font=("Helvetica", 24), 413 bg="light blue", 414 highlightthickness=1, 415 highlightbackground="dark blue", 416 ) 417 self.stimuli_label.grid(row=0, column=0, pady=5) 418 419 self.trial_number_label = tk.Label( 420 self.stimuli_information_frame, 421 text="Trial Number: ", 422 font=("Helvetica", 24), 423 bg="light blue", 424 highlightthickness=1, 425 highlightbackground="dark blue", 426 ) 427 self.trial_number_label.grid(row=1, column=0, pady=5) 428 logger.info("Status widgets displayed.") 429 except Exception as e: 430 logger.error(f"Error displaying status widget: {e}") 431 raise
Creates frames for status widgets (state label, trial number/progress bar, current stimuli, etc), builds these widgets and places them into their respective frames.
433 def create_entry_widgets(self) -> None: 434 """ 435 Creates frame to store labels and entries for ITI, TTC, Sample times using `views.gui_common` GUIUtils static class methods. 436 """ 437 try: 438 self.entry_widgets_frame = GUIUtils.create_basic_frame( 439 self, row=3, column=0, rows=1, cols=4 440 ) 441 442 # Simplify the creation of labeled entries 443 self.ITI_Interval_Frame, _, _ = GUIUtils.create_labeled_entry( 444 parent=self.entry_widgets_frame, 445 label_text="ITI Time", 446 text_var=self.tkinter_entries["ITI_var"], 447 row=0, 448 column=0, 449 ) 450 451 self.TTC_Interval_Frame, _, _ = GUIUtils.create_labeled_entry( 452 parent=self.entry_widgets_frame, 453 label_text="TTC Time", 454 text_var=self.tkinter_entries["TTC_var"], 455 row=0, 456 column=1, 457 ) 458 self.Sample_Interval_Frame, _, _ = GUIUtils.create_labeled_entry( 459 parent=self.entry_widgets_frame, 460 label_text="Sample Time", 461 text_var=self.tkinter_entries["sample_var"], 462 row=0, 463 column=2, 464 ) 465 self.num_trial_blocks_frame, _, _ = GUIUtils.create_labeled_entry( 466 parent=self.entry_widgets_frame, 467 label_text="# Trial Blocks", 468 text_var=self.exp_var_entries["Num Trial Blocks"], 469 row=0, 470 column=3, 471 ) 472 473 # Similarly for random plus/minus intervals and the number of stimuli 474 self.ITI_Random_Interval_frame, _, _ = GUIUtils.create_labeled_entry( 475 parent=self.entry_widgets_frame, 476 label_text="+/- ITI", 477 text_var=self.tkinter_entries["ITI_random_entry"], 478 row=1, 479 column=0, 480 ) 481 self.TTC_Random_Interval_frame, _, _ = GUIUtils.create_labeled_entry( 482 parent=self.entry_widgets_frame, 483 label_text="+/- TTC", 484 text_var=self.tkinter_entries["TTC_random_entry"], 485 row=1, 486 column=1, 487 ) 488 self.Sample_Interval_Random_Frame, _, _ = GUIUtils.create_labeled_entry( 489 parent=self.entry_widgets_frame, 490 label_text="+/- Sample", 491 text_var=self.tkinter_entries["sample_random_entry"], 492 row=1, 493 column=2, 494 ) 495 self.num_stimuli_frame, _, _ = GUIUtils.create_labeled_entry( 496 parent=self.entry_widgets_frame, 497 label_text="# Stimuli", 498 text_var=self.exp_var_entries["Num Stimuli"], 499 row=1, 500 column=3, 501 ) 502 logger.info("Entry widgets displayed.") 503 except Exception as e: 504 logger.error(f"Error displaying entry widgets: {e}") 505 raise
Creates frame to store labels and entries for ITI, TTC, Sample times using views.gui_common
GUIUtils static class methods.
597 def update_clock_label(self) -> None: 598 """ 599 This method defines logic to update primary timers such as main program timer and state timer. Max time is only updated once, but this 600 operation is separated from this method. This task is scheduled via a tkinter.after call to execute every 100ms so that the main timer 601 is responsive without overwhelming the main thread. 602 """ 603 try: 604 elapsed_time = time.time() - self.exp_data.start_time 605 state_elapsed_time = time.time() - self.exp_data.state_start_time 606 607 min, sec = self.exp_data.convert_seconds_to_minutes_seconds(elapsed_time) 608 609 self.main_timer_text.configure(text="{:.1f}s".format(elapsed_time)) 610 self.main_timer_min_sec_text.configure( 611 text="| {:.0f} Minutes, {:.1f} S".format(min, sec) 612 ) 613 614 self.state_timer_text.configure(text="{:.1f}s".format(state_elapsed_time)) 615 616 clock_update_task = self.after(100, self.update_clock_label) 617 self.scheduled_tasks["CLOCK UPDATE"] = clock_update_task 618 except Exception as e: 619 logger.error(f"Error updating clock label: {e}") 620 raise
This method defines logic to update primary timers such as main program timer and state timer. Max time is only updated once, but this operation is separated from this method. This task is scheduled via a tkinter.after call to execute every 100ms so that the main timer is responsive without overwhelming the main thread.
622 def update_max_time(self) -> None: 623 """ 624 This method updates the maximum runtime timer by retreiving this value from `models.experiment_process_data` and configuring the 625 timer label. 626 """ 627 try: 628 minutes, seconds = self.exp_data.calculate_max_runtime() 629 630 self.maximum_total_time.configure( 631 text="{:.0f} Minutes, {:.1f} S".format(minutes, seconds) 632 ) 633 except Exception as e: 634 logger.error(f"Error updating max time: {e}") 635 raise
This method updates the maximum runtime timer by retreiving this value from models.experiment_process_data
and configuring the
timer label.
637 def update_on_new_trial(self, side_1_stimulus: str, side_2_stimulus: str) -> None: 638 """ 639 Updates the new trial items such as program schedule window current trial highlighting and updates 640 the main_gui status information widgets with current trial number and updates progress bar. 641 642 Parameters 643 ---------- 644 - **side_1_stimulus** (*str*): This parameter is of type string and is generally pulled from the program schedule dataframe in 645 `app_logic` 'StateMachine' to provide the new stimulus for the upcoming trial for side one. 646 - **side_2_stimulus** (*str*): This parameter mirrors `side_1_stimulus` in all aspects except that this one reflects the stmulus for 647 side two. 648 """ 649 try: 650 trial_number = self.exp_data.current_trial_number 651 total_trials = self.exp_data.exp_var_entries["Num Trials"] 652 653 self.trials_completed_label.configure( 654 text=f"{trial_number} / {total_trials} Trials Completed" 655 ) 656 self.stimuli_label.configure( 657 text=f"Side One: {side_1_stimulus} | VS | Side Two: {side_2_stimulus}" 658 ) 659 660 self.trial_number_label.configure(text=f"Trial Number: {trial_number}") 661 662 self.windows["Program Schedule"].refresh_start_trial(trial_number) 663 664 # Set the new value for the progress bar 665 self.progress["value"] = (trial_number / total_trials) * 100 666 667 logger.info(f"Updated GUI for new trial {trial_number}.") 668 except Exception as e: 669 logger.error(f"Error updating GUI for new trial: {e}") 670 raise
Updates the new trial items such as program schedule window current trial highlighting and updates the main_gui status information widgets with current trial number and updates progress bar.
Parameters
- side_1_stimulus (str): This parameter is of type string and is generally pulled from the program schedule dataframe in
app_logic
'StateMachine' to provide the new stimulus for the upcoming trial for side one. - side_2_stimulus (str): This parameter mirrors
side_1_stimulus
in all aspects except that this one reflects the stmulus for side two.
672 def update_on_state_change(self, state_duration_ms: float, state: str) -> None: 673 """ 674 Here we define the GUI objects that need to be updated upon a state change. This includes the current state label as 675 well as the state timer. 676 677 Parameters 678 ---------- 679 - **state_duration_ms** (*float*): Provides the duration of this state in ms. Is generally pulled from the program schedule df for 680 the current trial. Converted to seconds by dividing the parameter by 1000.0. 681 - **state** (*str*): Provides the current to program state to update state label to inform user that program has changed state. 682 """ 683 try: 684 ### clear full state time ### 685 self.full_state_time_text.configure(text="/ {:.1f}s".format(0)) 686 687 ### UPDATE full state time ### 688 state_duration_seconds = state_duration_ms / 1000.0 689 self.full_state_time_text.configure( 690 text="/ {:.1f}s".format(state_duration_seconds) 691 ) 692 693 self.status_label.configure(text=f"Status: {state}") 694 695 logger.info(f"Updated GUI on state change to {state}.") 696 except Exception as e: 697 logger.error(f"Error updating GUI on state change: {e}") 698 raise
Here we define the GUI objects that need to be updated upon a state change. This includes the current state label as well as the state timer.
Parameters
- state_duration_ms (float): Provides the duration of this state in ms. Is generally pulled from the program schedule df for the current trial. Converted to seconds by dividing the parameter by 1000.0.
- state (str): Provides the current to program state to update state label to inform user that program has changed state.
700 def update_on_stop(self) -> None: 701 """ 702 Here we define the GUI objects that need to be updated when the program is stopped for any reason. This includes the current 703 state label (change to "IDLE), and removing state timer information. 704 """ 705 706 try: 707 self.state_timer_text.configure(text="0.0s") 708 709 self.full_state_time_text.configure(text=" / 0.0s") 710 711 self.status_label.configure(text="Status: IDLE") 712 713 except Exception as e: 714 logger.error(f"Error updating GUI on stop: {e}") 715 raise
Here we define the GUI objects that need to be updated when the program is stopped for any reason. This includes the current state label (change to "IDLE), and removing state timer information.
717 def on_close(self): 718 """ 719 This method is called any time the main program window is closed via the red X or <C-w> shortcut. We stop the listener thread if it 720 is running, then quit() the tkinter mainloop and destroy() the window and all descendent widgets. 721 """ 722 try: 723 if self.arduino_controller.listener_thread is not None: 724 # stop the listener thread so that it will not block exit 725 self.arduino_controller.stop_listener_thread() 726 727 # quit the mainloop and destroy the application 728 self.quit() 729 self.destroy() 730 731 logger.info("Mainloop terminated, widgets destroyed, application closed.") 732 except Exception as e: 733 logger.error(f"Error closing application: {e}") 734 raise
This method is called any time the main program window is closed via the red X or