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
logger = <RootLogger root (INFO)>

Get the logger in use for the app.

class MainGUI(tkinter.Tk):
 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 like models.event_data and models.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 module StateMachine 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 the windows dictionary. Access is given to the instance of the class for the description, where the windows show method is called.
  • hide_secondary_window() The opposing function to show_secondary_window. Hides the window specified as the window 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.
MainGUI( exp_data: models.experiment_process_data.ExperimentProcessData, trigger: Callable[[str], NoneType], arduino_controller: controllers.arduino_control.ArduinoManager)
 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, and views.valve_control_window require references here to communicate with Arduino to calibrate and open/close valves. This is also used in on_close to stop the Arduino listener thread to avoid thread errors on window close.
exp_data
arduino_controller
trigger
scheduled_tasks: dict[str, str]
def setup_basic_window_attr(self):
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 keyboard shortcut to close the window for convenience. Finally, we set the grid rows and columns to expand and contract as window gets larger or smaller (weight=1).

def setup_tkinter_variables(self) -> None:
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.

def build_gui_widgets(self) -> None:
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.

def preload_secondary_windows(self) -> None:
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.

def show_secondary_window(self, window: str) -> None:
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).
def hide_secondary_window(self, window: str) -> None:
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).
def create_top_control_buttons(self) -> None:
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

Creates frame to contain top control buttons, then utilizes views.gui_common GUIUtils methods to create buttons for 'Start/Stop' and 'Reset' button functions.

def create_timers(self) -> None:
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.

def create_status_widgets(self) -> None:
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.

def create_entry_widgets(self) -> None:
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.

def create_lower_control_buttons(self) -> None:
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

Creates frame for lower control buttons (those that open windows and save data) and sets their command to open (deiconify) their respective window.

def save_button_handler(self) -> None:
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()

Here we define behavior for clicking the 'Save Data' button. If either main dataframe is empty, we inform the user of this, otherwise we call the save_all_data method from models.experiment_process_data.

def update_clock_label(self) -> None:
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.

def update_max_time(self) -> None:
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.

def update_on_new_trial(self, side_1_stimulus: str, side_2_stimulus: str) -> None:
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.
def update_on_state_change(self, state_duration_ms: float, state: str) -> None:
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.
def update_on_stop(self) -> None:
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.

def on_close(self):
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 shortcut. We stop the listener thread if it is running, then quit() the tkinter mainloop and destroy() the window and all descendent widgets.