valve_testing_window

This module defines the ValveTestWindow class, a Tkinter Toplevel window providing functionality for testing and priming valves. Test and prime procedures are facilitated via the controllers.arduino_control.ArduinoManager.

It allows users to:

  • Select individual valves for testing or priming.
  • Configure test parameters like desired dispense volume and number of actuations.
  • Initiate automated valve tests, calculating new opening durations based on user-provided dispensed volumes.
  • Initiate valve test/priming sequences.
  • Manually override and adjust valve timings via a separate window (ManualTimeAdjustment).
  • View test results and current valve timings in a table format.
  • Confirm or abort automatically calculated timing changes.

This window interacts heavily with the controllers.arduino_control.ArduinoManager controller for communication and the modesl.arduino_data.ArduinoData model for storing/retrieving valve duration configurations.

It uses views.gui_common.GUIUtils for common UI elements and window management.

   1"""
   2This module defines the ValveTestWindow class, a Tkinter Toplevel window
   3providing functionality for testing and priming valves. Test and prime procedures are
   4facilitated via the `controllers.arduino_control.ArduinoManager`.
   5
   6It allows users to:
   7- Select individual valves for testing or priming.
   8- Configure test parameters like desired dispense volume and number of actuations.
   9- Initiate automated valve tests, calculating new opening durations based on
  10  user-provided dispensed volumes.
  11- Initiate valve test/priming sequences.
  12- Manually override and adjust valve timings via a separate window (`ManualTimeAdjustment`).
  13- View test results and current valve timings in a table format.
  14- Confirm or abort automatically calculated timing changes.
  15
  16This window interacts heavily with the `controllers.arduino_control.ArduinoManager` controller for communication
  17and the `modesl.arduino_data.ArduinoData` model for storing/retrieving valve duration configurations.
  18
  19It uses `views.gui_common.GUIUtils` for common UI elements and window management.
  20"""
  21
  22import tkinter as tk
  23from tkinter import simpledialog
  24from tkinter import ttk
  25
  26
  27import numpy as np
  28
  29### USED FOR TYPE HINTING ###
  30import numpy.typing as npt
  31
  32from enum import Enum
  33import threading
  34import logging
  35import toml
  36
  37from controllers.arduino_control import ArduinoManager
  38from models.arduino_data import ArduinoData
  39from views.gui_common import GUIUtils
  40from views.valve_testing.manual_time_adjustment_window import ManualTimeAdjustment
  41from views.valve_testing.valve_changes_window import ValveChanges
  42import system_config
  43
  44
  45logger = logging.getLogger()
  46"""Get the logger in use for the app."""
  47
  48rig_config = system_config.get_rig_config()
  49
  50with open(rig_config, "r") as f:
  51    VALVE_CONFIG = toml.load(f)["valve_config"]
  52
  53# pull total valves constant from toml config
  54TOTAL_VALVES = VALVE_CONFIG["TOTAL_CURRENT_VALVES"]
  55
  56VALVES_PER_SIDE = TOTAL_VALVES // 2
  57
  58
  59class WindowMode(Enum):
  60    """
  61    Represents the two primary operational modes of the ValveTestWindow:
  62    Testing mode for calibrating valve durations, and Priming mode for
  63    actuating valves repeatedly without calibration.
  64    """
  65
  66    TESTING = "Testing"
  67    PRIMING = "Priming"
  68
  69
  70class ValveTestWindow(tk.Toplevel):
  71    """
  72    *This is a bit of a god class and should likely be refactored... but for now... it works!*
  73
  74    Implements the main Tkinter Toplevel window for valve testing and priming operations.
  75
  76    This window provides an interface to interact with the Arduino
  77    controller for calibrating valve open times based on dispensed volume or
  78    for running priming sequences. It features modes for testing and
  79    priming, manages valve selections, displays test progress and results,
  80    and handles communication with the Arduino.
  81
  82    Attributes
  83    ----------
  84    - **`arduino_controller`** (*ArduinoManager*): Reference to the main Arduino controller instance, used for sending commands and data.
  85    - **`arduino_data`** (*ArduinoData*): Reference to the data model holding valve duration configurations.
  86    - **`window_mode`** (*WindowMode*): Enum indicating the current operational mode (Testing or Priming).
  87    - **`test_running`** (*bool*): Flag indicating if an automated test sequence is currently active.
  88    - **`prime_running`** (*bool*): Flag indicating if a priming sequence is currently active.
  89    - **`desired_volume`** (*tk.DoubleVar*): Tkinter variable storing the target volume (in microliters) per actuation for automatic duration calculation.
  90    - **`actuations`** (*tk.IntVar*): Tkinter variable storing the number of times each valve should be actuated during a test or prime sequence.
  91    - **`ml_dispensed`** (*list[tuple[int, float]]*): Stores tuples of (valve_number, dispensed_volume_ml) entered by the user during a test.
  92    - **`valve_buttons`** (*list[tk.Button | None]*): List holding the Tkinter Button widgets for each valve selection.
  93    - **`valve_test_button`** (*tk.Button | None*): The main button used to start/abort tests or start/stop priming.
  94    - **`table_entries`** (*list[str | None]*): List holding the item IDs for each row in the ttk.Treeview table, used for modifying/hiding/showing specific rows.
  95    - **`valve_selections`** (*npt.NDArray[np.int8]*): Numpy array representing the selection state of each valve (0 if not selected, valve_number if selected).
  96    - **`side_one_tests`** (*npt.NDArray[np.int8] | None*): Numpy array holding the valve numbers (0-indexed) selected for testing/priming on side one.
  97    - **`side_two_tests`** (*npt.NDArray[np.int8] | None*): Numpy array holding the valve numbers (0-indexed) selected for testing/priming on side two.
  98    - **`stop_event`** (*threading.Event*): Event flag used to signal the Arduino listener thread (`run_test`) to stop.
  99    - **`mode_button`** (*tk.Button*): Button to switch between Testing and Priming modes.
 100    - **`dispensed_vol_frame`** (*tk.Frame*): Container frame for the desired volume input elements (visible in Testing mode).
 101    - **`valve_table_frame`** (*tk.Frame*): Container frame for the valve test results table (visible in Testing mode).
 102    - **`valve_table`** (*ttk.Treeview*): The table widget displaying valve numbers, test status, and timings.
 103    - **`valve_buttons_frame`** (*tk.Frame*): Main container frame for the side-by-side valve selection buttons.
 104    - **`side_one_valves_frame`** (*tk.Frame*): Frame holding valve selection buttons for the first side.
 105    - **`side_two_valves_frame`** (*tk.Frame*): Frame holding valve selection buttons for the second side.
 106    - **`manual_adjust_window`** (*ManualTimeAdjustment*): Instance of the separate window for manual timing adjustments.
 107
 108    Methods
 109    -------
 110    - `setup_basic_window_attr`()
 111        Configures basic window properties like title, key bindings, and icon.
 112    - `show`()
 113        Makes the window visible (deiconifies).
 114    - `switch_window_mode`()
 115        Toggles the window between Testing and Priming modes, adjusting UI elements accordingly.
 116    - `create_interface`()
 117        Builds the entire GUI layout, placing widgets and frames.
 118    - `start_testing_toggle`()
 119        Handles the press of the main test/prime button in Testing mode (starts or aborts test).
 120    - `create_buttons`()
 121        Creates and places the valve selection buttons and the main start/abort button.
 122    - `create_valve_test_table`()
 123        Creates the ttk.Treeview table for displaying valve test results and timings.
 124    - `update_table_entry_test_status`(...)
 125        Updates the 'Amount Dispensed' column for a specific valve in the table.
 126    - `update_table_entries`(...)
 127        Updates the entire valve table, showing/hiding rows based on selections and refreshing currently loaded durations.
 128    - `toggle_valve_button`(...)
 129        Handles clicks on valve selection buttons, updating their appearance and the `valve_selections` array.
 130    - `verify_variables`(...)
 131        Reads verification data back from Arduino to confirm test parameters were received correctly.
 132    - `verify_schedule`(...)
 133        Reads verification data back from Arduino to confirm the valve test/prime schedule was received correctly.
 134    - `send_schedules`()
 135        Sends the command, parameters (schedule lengths, actuations), and valve schedule to the Arduino. Handles mode-specific commands and confirmations.
 136    - `stop_priming`()
 137        Sends a command to the Arduino to stop an ongoing priming sequence.
 138    - `take_input`(...)
 139        Prompts the user (via simpledialog) to enter the dispensed volume for a completed valve test pair. Sends confirmation back to Arduino.
 140    - `run_test`()
 141        Runs in a separate thread to listen for messages from the Arduino during a test, triggering events for input prompts or completion.
 142    - `abort_test`()
 143        Sends a command to the Arduino to abort the current test and stops the listener thread.
 144    - `testing_complete`(...)
 145        Handles the event triggered when the Arduino signals the end of the entire test sequence. Initiates duration updates.
 146    - `auto_update_durations`()
 147        Calculates new valve durations based on test results (`ml_dispensed`) and opens the `ValveChanges` confirmation window.
 148    - `confirm_valve_changes`(...)
 149        Callback function executed if the user confirms changes in the `ValveChanges` window. Saves new durations and archives old ones via `ArduinoData`.
 150    """
 151
 152    def __init__(self, arduino_controller: ArduinoManager) -> None:
 153        """
 154        Initializes the ValveTestWindow.
 155
 156        Parameters
 157        ----------
 158        - **arduino_controller** (*ArduinoManager*): The application's instance of the Arduino controller.
 159
 160        Raises
 161        ------
 162        - Propagates exceptions from `GUIUtils` methods during icon setting.
 163        - Propagates exceptions from `arduino_data.load_durations()`.
 164        """
 165        super().__init__()
 166        self.arduino_controller: ArduinoManager = arduino_controller
 167        self.arduino_data: ArduinoData = arduino_controller.arduino_data
 168
 169        self.setup_basic_window_attr()
 170
 171        self.window_mode: WindowMode = WindowMode.TESTING
 172
 173        self.test_running: bool = False
 174        self.prime_running: bool = False
 175
 176        self.desired_volume: tk.DoubleVar = tk.DoubleVar(value=5)
 177
 178        self.actuations: tk.IntVar = tk.IntVar(value=1000)
 179
 180        self.ml_dispensed: list[tuple[int, float]] = []
 181
 182        self.valve_buttons: list[tk.Button | None] = [None] * TOTAL_VALVES
 183
 184        self.valve_test_button: tk.Button | None = None
 185
 186        self.table_entries: list[str | None] = [None] * TOTAL_VALVES
 187
 188        self.valve_selections: npt.NDArray[np.int8] = np.zeros(
 189            (TOTAL_VALVES,), dtype=np.int8
 190        )
 191
 192        self.side_one_tests: None | npt.NDArray[np.int8] = None
 193        self.side_two_tests: None | npt.NDArray[np.int8] = None
 194
 195        self.stop_event: threading.Event = threading.Event()
 196
 197        self.create_interface()
 198
 199        # hide main window for now until we deliberately enter this window.
 200        self.withdraw()
 201
 202        self.manual_adjust_window = ManualTimeAdjustment(
 203            self.arduino_controller.arduino_data
 204        )
 205        # hide the adjustment window until we need it.
 206        self.manual_adjust_window.withdraw()
 207
 208        logging.info("Valve Test Window initialized.")
 209
 210    def setup_basic_window_attr(self) -> None:
 211        """
 212        Sets up fundamental window properties.
 213
 214        Configures the window title, binds Ctrl+W and the close button ('X')
 215        to hide the window, sets grid column/row weights for resizing behavior,
 216        and applies the application icon using `GUIUtils`.
 217        """
 218
 219        self.title("Valve Testing")
 220        self.bind("<Control-w>", lambda event: self.withdraw())
 221        self.protocol("WM_DELETE_WINDOW", lambda: self.withdraw())
 222
 223        self.grid_columnconfigure(0, weight=1)
 224
 225        for i in range(5):
 226            self.grid_rowconfigure(i, weight=1)
 227        self.grid_rowconfigure(0, weight=0)
 228
 229        window_icon_path = GUIUtils.get_window_icon_path()
 230        GUIUtils.set_program_icon(self, icon_path=window_icon_path)
 231
 232    def show(self) -> None:
 233        """
 234        Makes the ValveTestWindow visible.
 235
 236        Calls `deiconify()` to show the window if it was previously hidden (withdrawn).
 237        """
 238        self.deiconify()
 239
 240    def switch_window_mode(self) -> None:
 241        """
 242        Toggles the window's operational mode between Testing and Priming.
 243
 244        Updates the window title and mode button text/color. Hides or shows
 245        mode-specific UI elements (desired volume input, test results table).
 246        Changes the command associated with the main start/stop button.
 247        Prevents mode switching if a test or prime sequence is currently running to avoid Arduino
 248        Communications errors. Resizes and centers the window after GUI changes.
 249        """
 250
 251        if self.window_mode == WindowMode.TESTING:
 252            if self.test_running:
 253                # if the test is currently running we do not want to switch modes. Stop testing to switch modes
 254                GUIUtils.display_error(
 255                    "ACTION PROHIBITED",
 256                    "You cannot switch modes while a test is running. If you'd like to switch modes, please abort the test and try again.",
 257                )
 258                return
 259            self.title("Valve Priming")
 260
 261            self.window_mode = WindowMode.PRIMING
 262
 263            self.mode_button.configure(text="Switch to Testing Mode", bg="DeepSkyBlue3")
 264
 265            # orig. row 1
 266            self.dispensed_vol_frame.grid_forget()
 267            # orig. row 3
 268            self.valve_table_frame.grid_forget()
 269
 270            # start testing button -> start priming button / command
 271
 272            if isinstance(self.valve_test_button, tk.Button):
 273                self.valve_test_button.configure(
 274                    text="Start Priming",
 275                    bg="coral",
 276                    command=lambda: self.send_schedules(),
 277                )
 278
 279        else:
 280            if self.prime_running:
 281                # if the test is currently running we do not want to switch modes. Stop testing to switch modes
 282                GUIUtils.display_error(
 283                    "ACTION PROHIBITED",
 284                    "You cannot switch modes while a valve prime operation is running. If you'd like to switch modes, please abort the prime and try again.",
 285                )
 286                return
 287
 288            self.title("Valve Testing")
 289
 290            self.window_mode = WindowMode.TESTING
 291
 292            self.mode_button.configure(text="Switch to Priming Mode", bg="coral")
 293
 294            self.valve_table_frame.grid(
 295                row=3, column=0, sticky="nsew", pady=10, padx=10
 296            )
 297            self.dispensed_vol_frame.grid(row=1, column=0, padx=5, sticky="nsew")
 298
 299            if isinstance(self.valve_test_button, tk.Button):
 300                self.valve_test_button.configure(
 301                    text="Start Testing",
 302                    bg="green",
 303                    command=lambda: self.start_testing_toggle(),
 304                )
 305
 306        # resize the window to fit current content
 307        self.update_idletasks()
 308        GUIUtils.center_window(self)
 309
 310    def create_interface(self) -> None:
 311        """
 312        Constructs and arranges all GUI elements within the window.
 313
 314        Creates frames for structure, input fields (desired volume, actuations),
 315        the mode switch button, the valve selection buttons, the test results table,
 316        and the main start/abort/prime button. Uses `GUIUtils` for
 317        widget creation where possible. Calls helper methods `create_valve_test_table`
 318        and `create_buttons`. Centers the window after creation.
 319        """
 320
 321        self.mode_button = GUIUtils.create_button(
 322            self,
 323            "Switch to Priming Mode",
 324            command=lambda: self.switch_window_mode(),
 325            bg="coral",
 326            row=0,
 327            column=0,
 328        )[1]
 329
 330        # create a new frame to contain the desired dispensed volume label and frame
 331        self.dispensed_vol_frame = tk.Frame(self)
 332        self.dispensed_vol_frame.grid(row=1, column=0, padx=5, sticky="nsew")
 333
 334        self.dispensed_vol_frame.grid_columnconfigure(0, weight=1)
 335        self.dispensed_vol_frame.grid_rowconfigure(0, weight=1)
 336        self.dispensed_vol_frame.grid_rowconfigure(1, weight=1)
 337
 338        label = tk.Label(
 339            self.dispensed_vol_frame,
 340            text="Desired Dispensed Volume in Microliters (ul)",
 341            bg="light blue",
 342            font=("Helvetica", 20),
 343            highlightthickness=1,
 344            highlightbackground="dark blue",
 345            height=2,
 346            width=50,
 347        )
 348        label.grid(row=0, pady=5)
 349
 350        entry = tk.Entry(
 351            self.dispensed_vol_frame,
 352            textvariable=self.desired_volume,
 353            font=("Helvetica", 24),
 354            highlightthickness=1,
 355            highlightbackground="black",
 356        )
 357        entry.grid(row=1, sticky="nsew", pady=5, ipady=14)
 358
 359        # create another new frame to contain the amount of times each
 360        # valve should actuate label and frame
 361        frame = tk.Frame(self)
 362        frame.grid(row=2, column=0, padx=5, sticky="nsew")
 363
 364        frame.grid_columnconfigure(0, weight=1)
 365        frame.grid_rowconfigure(0, weight=1)
 366        frame.grid_rowconfigure(1, weight=1)
 367
 368        label = tk.Label(
 369            frame,
 370            text="How Many Times Should the Valve Actuate?",
 371            bg="light blue",
 372            font=("Helvetica", 20),
 373            highlightthickness=1,
 374            highlightbackground="dark blue",
 375            height=2,
 376            width=50,
 377        )
 378        label.grid(row=0, pady=5)
 379
 380        entry = tk.Entry(
 381            frame,
 382            textvariable=self.actuations,
 383            font=("Helvetica", 24),
 384            highlightthickness=1,
 385            highlightbackground="black",
 386        )
 387        entry.grid(row=1, sticky="nsew", pady=5, ipady=12)
 388
 389        ### ROW 3 ###
 390        self.create_valve_test_table()
 391
 392        # setup frames for valve buttons
 393        self.valve_buttons_frame = tk.Frame(
 394            self, highlightbackground="black", highlightthickness=1
 395        )
 396        ### ROW 4 ###
 397        self.valve_buttons_frame.grid(row=4, column=0, pady=10, padx=10, sticky="nsew")
 398        self.valve_buttons_frame.grid_columnconfigure(0, weight=1)
 399        self.valve_buttons_frame.grid_rowconfigure(0, weight=1)
 400
 401        self.side_one_valves_frame = tk.Frame(
 402            self.valve_buttons_frame, highlightbackground="black", highlightthickness=1
 403        )
 404        self.side_one_valves_frame.grid(row=0, column=0, pady=10, padx=10, sticky="w")
 405        self.side_one_valves_frame.grid_columnconfigure(0, weight=1)
 406        for i in range(4):
 407            self.side_one_valves_frame.grid_rowconfigure(i, weight=1)
 408
 409        self.side_two_valves_frame = tk.Frame(
 410            self.valve_buttons_frame, highlightbackground="black", highlightthickness=1
 411        )
 412        self.side_two_valves_frame.grid(row=0, column=1, pady=10, padx=10, sticky="e")
 413        self.side_two_valves_frame.grid_columnconfigure(0, weight=1)
 414        for i in range(4):
 415            self.side_two_valves_frame.grid_rowconfigure(i, weight=1)
 416
 417        ### ROW 5 ###
 418        self.create_buttons()
 419
 420        self.update_idletasks()
 421        GUIUtils.center_window(self)
 422
 423    def start_testing_toggle(self) -> None:
 424        """
 425        Handles the action of the main button when in Testing mode.
 426
 427        If a test is running, it calls `abort_test()` and updates the button appearance.
 428        If no test is running, it calls `send_schedules()` to initiate a new test.
 429        """
 430
 431        if self.test_running:
 432            if isinstance(self.valve_test_button, tk.Button):
 433                self.valve_test_button.configure(text="Start Testing", bg="green")
 434            self.abort_test()
 435        else:
 436            self.send_schedules()
 437
 438    def create_buttons(self) -> None:
 439        """
 440        Creates and places the individual valve selection buttons and the main action button.
 441
 442        Generates buttons for each valve (1 to TOTAL_VALVES), arranging them
 443        into side-one and side-two frames using `GUIUtils.create_button`.
 444        Assigns the `toggle_valve_button` command to each valve button.
 445
 446        Creates the main 'Start Testing' / 'Start Priming' / 'Abort' button.
 447        """
 448        for i in range(VALVES_PER_SIDE):
 449            # GUIUtils.create_button returns both the buttons frame and button object in a tuple. using [1] on the
 450            # return item yields the button object.
 451
 452            # return the side one button and put it on the side one 'side'
 453            # of the list (0-4)
 454            self.valve_buttons[i] = GUIUtils.create_button(
 455                self.side_one_valves_frame,
 456                f"Valve {i + 1}",
 457                lambda i=i: self.toggle_valve_button(self.valve_buttons[i], i),
 458                "light blue",
 459                i,
 460                0,
 461            )[1]
 462
 463            # return the side two button and put it on the side two 'side'
 464            # of the list (5-8)
 465            self.valve_buttons[i + (VALVES_PER_SIDE)] = GUIUtils.create_button(
 466                self.side_two_valves_frame,
 467                f"Valve {i + (VALVES_PER_SIDE) + 1}",
 468                lambda i=i: self.toggle_valve_button(
 469                    self.valve_buttons[i + (VALVES_PER_SIDE)], i + (VALVES_PER_SIDE)
 470                ),
 471                "light blue",
 472                i,
 473                0,
 474            )[1]
 475
 476        self.valve_test_button = GUIUtils.create_button(
 477            self, "Start Testing", lambda: self.start_testing_toggle(), "green", 5, 0
 478        )[1]
 479
 480    def create_valve_test_table(self) -> None:
 481        """
 482        Creates, configures, and populates the initial state of the ttk.Treeview table.
 483
 484        Sets up the table columns ("Valve", "Amount Dispensed...", "Testing Opening Time...").
 485        Adds a button above the table to open the `ManualTimeAdjustment` window.
 486
 487        Loads the currently selected valve durations from `models.arduino_data.ArduinoData`.
 488        Inserts initial rows for all possible valves, marking them as "Test Not Complete"
 489        and displaying their current duration. Stores row IDs in `self.table_entries` for later access.
 490        """
 491
 492        # this function creates and labels the testing window table that displays the information recieved from the arduino reguarding the results of the tes
 493        self.valve_table_frame = tk.Frame(
 494            self, highlightbackground="black", highlightthickness=1
 495        )
 496        self.valve_table_frame.grid(row=3, column=0, sticky="nsew", pady=10, padx=10)
 497        # tell the frame to fill the available width
 498        self.valve_table_frame.grid_columnconfigure(0, weight=1)
 499
 500        self.valve_table = ttk.Treeview(
 501            self.valve_table_frame, show="headings", height=12
 502        )
 503
 504        headings = [
 505            "Valve",
 506            "Amount Dispensed Per Lick (ul)",
 507            "Testing Opening Time of (ms)",
 508        ]
 509
 510        self.valve_table["columns"] = headings
 511
 512        self.valve_table.grid(row=1, sticky="ew")
 513
 514        button = tk.Button(
 515            self.valve_table_frame,
 516            text="Manual Valve Duration Override",
 517            command=lambda: self.manual_adjust_window.show(),
 518            bg="light blue",
 519            highlightbackground="black",
 520            highlightthickness=1,
 521        )
 522
 523        button.grid(row=0, sticky="e")
 524
 525        # Configure the columns
 526        for col in self.valve_table["columns"]:
 527            self.valve_table.heading(col, text=col)
 528
 529        arduino_data = self.arduino_controller.arduino_data
 530
 531        side_one_durations, side_two_durations, date_used = (
 532            arduino_data.load_durations()
 533        )
 534
 535        # insert default entries for each total valve until user selects how many
 536        # they want to test
 537        for i in range(VALVES_PER_SIDE):
 538            self.table_entries[i] = self.valve_table.insert(
 539                "",
 540                i,
 541                values=(
 542                    f"{i + 1}",
 543                    "Test Not Complete",
 544                    f"{side_one_durations[i]} ms",
 545                ),
 546            )
 547            self.table_entries[i + 4] = self.valve_table.insert(
 548                "",
 549                i + VALVES_PER_SIDE,
 550                values=(
 551                    f"{i + VALVES_PER_SIDE + 1}",
 552                    "Test Not Complete",
 553                    f"{side_two_durations[i]} ms",
 554                ),
 555            )
 556
 557    def update_table_entry_test_status(self, valve: int, l_per_lick: float) -> None:
 558        """
 559        Updates the 'Amount Dispensed Per Lick (ul)' column for a specific valve row.
 560
 561        Parameters
 562        ----------
 563        - **valve** (*int*): The 1-indexed valve number whose row needs updating.
 564        - **l_per_lick** (*float*): The calculated volume dispensed per lick in milliliters (mL).
 565        """
 566
 567        self.valve_table.set(
 568            self.table_entries[valve - 1], column=1, value=f"{l_per_lick * 1000} uL"
 569        )
 570
 571    def update_table_entries(self) -> None:
 572        """
 573        Refreshes the entire valve test table based on current selections and durations.
 574
 575        Hides rows for unselected valves and shows rows for selected valves.
 576        Reloads the latest durations from `ArduinoData` and updates the
 577        'Testing Opening Time' column for all visible rows. Resets the
 578        'Amount Dispensed' column to "Test Not Complete" for visible rows.
 579        """
 580        side_one, side_two, _ = self.arduino_data.load_durations()
 581        # np array where the entry is not zero but the valve number itself
 582        selected_valves = self.valve_selections[self.valve_selections != 0]
 583
 584        # for all valve numbers, if that number is in selected_valves -1 (the entire array
 585        # decremented by one) place it in the table and ensure its duration is up-to-date
 586        for i in range(TOTAL_VALVES):
 587            # if the iter number is also contained in the selected valves arr
 588            if i in (selected_valves - 1):
 589                # check if it's side two or side one
 590                if i >= VALVES_PER_SIDE:
 591                    duration = side_two[i - VALVES_PER_SIDE]
 592                else:
 593                    duration = side_one[i]
 594                self.valve_table.item(
 595                    self.table_entries[i],
 596                    values=(
 597                        f"{i + 1}",
 598                        "Test Not Complete",
 599                        f"{duration} ms",
 600                    ),
 601                )
 602                self.valve_table.reattach(self.table_entries[i], "", i)
 603            else:
 604                self.valve_table.detach(self.table_entries[i])
 605
 606        logging.info("Valve test table updated.")
 607
 608    def toggle_valve_button(self, button: tk.Button, valve_num: int) -> None:
 609        """
 610        Handles the click event for a valve selection button.
 611
 612        Toggles the button's visual state (raised/sunken). Updates the
 613        corresponding entry in the `self.valve_selections` numpy array (0 for
 614        deselected, valve_number + 1 for selected). Calls `update_table_entries`
 615        to refresh the table display. Filling arrays this way allows us to tell
 616        the arduino exactly which valves should be toggled (1, 4, 7) as long as
 617        their value in the array is not 0.
 618
 619        Parameters
 620        ----------
 621        - **button** (*tk.Button*): The specific valve button widget that was clicked.
 622        - **valve_num** (*int*): The 0-indexed number of the valve corresponding to the button.
 623        """
 624        # Attempt to get the value to ensure it's a valid float as expected
 625        # If successful, call the function to update/create the valve test table
 626        if self.valve_selections[valve_num] == 0:
 627            button.configure(relief="sunken")
 628            self.valve_selections[valve_num] = valve_num + 1
 629
 630        else:
 631            button.configure(relief="raised")
 632            self.valve_selections[valve_num] = 0
 633        self.update_table_entries()
 634
 635    def verify_variables(self, original_data: bytes) -> bool:
 636        """
 637        Verifies that the Arduino correctly received the test/prime parameters.
 638
 639        Waits for and reads back the schedule lengths and actuation count echoed
 640        by the Arduino. Compares the received bytes with the originally sent data.
 641
 642        Parameters
 643        ----------
 644        - **original_data** (*bytes*): The byte string containing schedule lengths and actuation count originally sent to the Arduino.
 645
 646        Returns
 647        -------
 648        - *bool*: `True` if the received data matches the original data, `False` otherwise (logs error and shows message box on mismatch).
 649        """
 650        # wait until all data is on the wire ready to be read (4 bytes),
 651        # then read it all in quick succession
 652        if self.arduino_controller.arduino is None:
 653            msg = "====INCORRECT SCHEDULE DETECTED, TRY AGAIN===="
 654            GUIUtils.display_error("TRANSMISSION ERROR", msg)
 655            logger.error(msg)
 656            return False
 657
 658        while self.arduino_controller.arduino.in_waiting < 4:
 659            pass
 660
 661        ver_len_sched_sd_one = self.arduino_controller.arduino.read()
 662        ver_len_sched_sd_two = self.arduino_controller.arduino.read()
 663
 664        ver_valve_act_time = self.arduino_controller.arduino.read(2)
 665
 666        verification_data = (
 667            ver_len_sched_sd_one + ver_len_sched_sd_two + ver_valve_act_time
 668        )
 669        if verification_data == original_data:
 670            logger.info("====VERIFIED TEST VARIABLES====")
 671            return True
 672        else:
 673            msg = "====INCORRECT VARIABLES DETECTED, TRY AGAIN===="
 674            GUIUtils.display_error("TRANSMISSION ERROR", msg)
 675            logger.error(msg)
 676            return False
 677
 678    def verify_schedule(
 679        self, len_sched: int, sent_schedule: npt.NDArray[np.int8]
 680    ) -> bool:
 681        """
 682        Verifies that the Arduino correctly received the valve schedule.
 683
 684        Waits for and reads back the valve numbers (0-indexed) echoed byte-by-byte
 685        by the Arduino. Compares the reconstructed numpy array with the schedule
 686        that was originally sent.
 687
 688        Parameters
 689        ----------
 690        - **len_sched** (*int*): The total number of valves in the schedule being sent.
 691        - **sent_schedule** (*npt.NDArray[np.int8]*): The numpy array (0-indexed valve numbers) representing the schedule originally sent.
 692
 693        Returns
 694        -------
 695        - *bool*: `True` if the received schedule matches the sent schedule, `False` otherwise (logs error and shows message box on mismatch).
 696        """
 697        # np array used for verification later
 698        received_sched = np.zeros((len_sched,), dtype=np.int8)
 699
 700        if self.arduino_controller.arduino is None:
 701            msg = "====INCORRECT SCHEDULE DETECTED, TRY AGAIN===="
 702            GUIUtils.display_error("TRANSMISSION ERROR", msg)
 703            logger.error(msg)
 704            return False
 705
 706        while self.arduino_controller.arduino.in_waiting < len_sched:
 707            pass
 708
 709        for i in range(len_sched):
 710            received_sched[i] = int.from_bytes(
 711                self.arduino_controller.arduino.read(), byteorder="little", signed=False
 712            )
 713
 714        if np.array_equal(received_sched, sent_schedule):
 715            logger.info(f"===TESTING SCHEDULE VERIFIED as ==> {received_sched}===")
 716            logger.info("====================BEGIN TESTING NOW====================")
 717            return True
 718        else:
 719            msg = "====INCORRECT SCHEDULE DETECTED, TRY AGAIN===="
 720            GUIUtils.display_error("TRANSMISSION ERROR", msg)
 721            logger.error(msg)
 722            return False
 723
 724    def send_schedules(self) -> None:
 725        """
 726        Performs the process of sending test or prime configuration to the Arduino.
 727
 728        Determines selected valves and schedule lengths for each side. Sends current
 729        valve durations to Arduino. Then sends the appropriate command ('TEST VOL' or 'PRIME VALVES').
 730
 731        Sends test parameters (schedule lengths, actuations) and verifies them using `verify_variables`.
 732        Sends the actual valve schedule (0-indexed) and verifies it using `verify_schedule`.
 733
 734        User is then prompted to confirm before proceeding.
 735        If confirmed, initiates the test/prime sequence on the Arduino
 736        and starts the listener thread (`run_test`) if Testing, or sends start command if Priming.
 737        Updates button states accordingly.
 738        """
 739        len_sched_one = np.count_nonzero(self.valve_selections[:VALVES_PER_SIDE])
 740        len_sched_two = np.count_nonzero(self.valve_selections[VALVES_PER_SIDE:])
 741
 742        len_sched_one = np.int8(len_sched_one)
 743        len_sched_two = np.int8(len_sched_two)
 744        len_sched = len_sched_one + len_sched_two
 745
 746        # because self.actuations is a IntVar, we use .get() method to get its value
 747        valve_acuations = np.int16(self.actuations.get())
 748
 749        # schedule is TOTAL_VALVES long to accomodate testing of up to all valves at one time.
 750        # if we are not testing them all, filter out the zero entries (valves we are not testing)
 751        schedule = self.valve_selections[self.valve_selections != 0]
 752
 753        # decrement each element in the array by one to move to zero-indexing. We moved to 1
 754        # indexing becuase using zero indexing does not work with the method of toggling valves
 755        # defined above
 756        schedule -= 1
 757
 758        self.side_one_tests = schedule[:len_sched_one]
 759        self.side_two_tests = schedule[len_sched_one:]
 760
 761        len_sched_one = len_sched_one
 762        len_sched_two = len_sched_two
 763        valve_acuations = valve_acuations
 764
 765        self.arduino_controller.send_valve_durations()
 766        if self.window_mode == WindowMode.TESTING:
 767            ###====SENDING TEST COMMAND====###
 768            command = "TEST VOL\n".encode("utf-8")
 769            self.arduino_controller.send_command(command)
 770
 771        elif self.window_mode == WindowMode.PRIMING:
 772            self.prime_running = True
 773
 774            command = "PRIME VALVES\n".encode("utf-8")
 775            self.arduino_controller.send_command(command)
 776
 777        ###====SENDING TEST VARIABLES (len side one, two, num_actuations per test)====###
 778        data_bytes = (
 779            len_sched_one.tobytes()
 780            + len_sched_two.tobytes()
 781            + valve_acuations.tobytes()
 782        )
 783        self.arduino_controller.send_command(data_bytes)
 784
 785        # verify the data
 786        if not self.verify_variables(data_bytes):
 787            return
 788
 789        ###====SENDING SCHEDULE====###
 790        sched_data_bytes = schedule.tobytes()
 791        self.arduino_controller.send_command(sched_data_bytes)
 792
 793        if not self.verify_schedule(len_sched, schedule):
 794            return
 795
 796        schedule += 1
 797
 798        valves = ",".join(str(valve) for valve in schedule)
 799        if self.window_mode == WindowMode.TESTING:
 800            ####### ARDUINO HALTS HERE UNTIL THE GO-AHEAD IS GIVEN #######
 801            # inc each sched element by one so that it is 1-indexes and matches the valve labels
 802            test_confirmed = GUIUtils.askyesno(
 803                "CONFIRM THE TEST SCHEDULE",
 804                f"Valves {valves}, will be tested. Review the test table to confirm schedule and timings. Each valve will be actuated {valve_acuations} times. Ok to begin?",
 805            )
 806
 807            ### include section to manually modify timings
 808            if test_confirmed:
 809                self.bind("<<event0>>", self.testing_complete)
 810                self.bind("<<event1>>", self.take_input)
 811                threading.Thread(target=self.run_test).start()
 812            else:
 813                self.abort_test()
 814        elif self.window_mode == WindowMode.PRIMING:
 815            priming_confirmed = GUIUtils.askyesno(
 816                "CONFIRM THE ACTION",
 817                f"Valves {valves}, will be primed (opened and closed) {valve_acuations} times. Ok to begin?",
 818            )
 819            if priming_confirmed:
 820                # in case of prime, we send 0 to mean continue or start
 821                # and we send a 1 at any point to abort or cancel the prime
 822                start = np.int8(0).tobytes()
 823                self.arduino_controller.send_command(command=start)
 824
 825                if isinstance(self.valve_test_button, tk.Button):
 826                    self.valve_test_button.configure(
 827                        text="STOP Priming",
 828                        bg="gold",
 829                        command=lambda: self.stop_priming(),
 830                    )
 831
 832    def stop_priming(self) -> None:
 833        """
 834        Sends the stop command to the Arduino during a priming sequence.
 835
 836        Sets the `prime_running` flag to False. Updates the prime button state.
 837        Sends a byte representation of 1 (`np.int8(1).tobytes()`) to signal abort to the Arduino.
 838        """
 839        self.prime_running = False
 840
 841        if isinstance(self.valve_test_button, tk.Button):
 842            self.valve_test_button.configure(
 843                text="Start Priming",
 844                bg="coral",
 845                command=lambda: self.send_schedules(),
 846            )
 847
 848        stop = np.int8(1).tobytes()
 849        self.arduino_controller.send_command(command=stop)
 850
 851    def take_input(
 852        self, event: tk.Event | None, pair_num_override: int | None = None
 853    ) -> None:
 854        """
 855        Prompts the user to enter the dispensed volume for a completed test pair.
 856
 857        Determines which valve(s) were just tested based on the pair number received
 858        from the Arduino (via the event state or override). Uses `simpledialog.askfloat`
 859        to get the dispensed volume (in mL) for each valve in the pair. Stores the
 860        results in `self.ml_dispensed`.
 861
 862        Sends a byte back to the Arduino to instruct if further pairs will be tested or if this is the final iteration,
 863        (1 if via event, 0 if via override/final pair).
 864        Handles potential abort if the user cancels the dialog window.
 865
 866        Parameters
 867        ----------
 868        - **event** (*tk.Event | None*): Uses custom thread-safe event (`<<event1>>`) generated by `run_test`, carrying the pair number in `event.state`
 869        attribute. This is None if called directly by `testing_complete`, which indicated procedure is now finished.
 870        - **pair_num_override** (*int | None, optional*): Allows manually specifying the pair number, used when called from `testing_complete` for the
 871        final pair. Defaults to None.
 872        """
 873        if event is not None:
 874            pair_number = event.state
 875        else:
 876            pair_number = pair_num_override
 877
 878        # decide how many valves were tested last. if 0 we will not arrive
 879        # here so we need not test for this.
 880        if pair_number < len(self.side_one_tests) and pair_number < len(
 881            self.side_two_tests
 882        ):
 883            valves_tested = [
 884                self.side_one_tests[pair_number],
 885                self.side_two_tests[pair_number],
 886            ]
 887        elif pair_number < len(self.side_one_tests):
 888            valves_tested = [
 889                self.side_one_tests[pair_number],
 890            ]
 891        else:
 892            valves_tested = [
 893                self.side_two_tests[pair_number],
 894            ]
 895
 896        # for each test that we ran store the amount dispensed or abort
 897        for valve in valves_tested:
 898            response = simpledialog.askfloat(
 899                "INPUT AMOUNT DISPENSED",
 900                f"Please input the amount of liquid dispensed for the test of valve {valve}",
 901            )
 902            if response is None:
 903                self.abort_test()
 904            else:
 905                self.ml_dispensed.append((valve, response))
 906        if pair_num_override is not None:
 907            self.arduino_controller.send_command(np.int8(0).tobytes())
 908        else:
 909            self.arduino_controller.send_command(np.int8(1).tobytes())
 910
 911    def run_test(self) -> None:
 912        """
 913        Listens for messages from the Arduino during an active test sequence (runs in a background thread).
 914
 915        Sends the initial 'start test' command byte to the Arduino. Enters a loop
 916        that continues as long as `self.test_running` is True and `self.stop_event`
 917        is not set. Checks for incoming data from the Arduino. Reads bytes indicating
 918        if more tests remain and the number of the just-completed pair. Generates
 919        custom Tkinter events (`<<event1>>` for input dispensed liquid prompt, `<<event0>>` for test completion)
 920        to communicate back to the main GUI thread safely. Updates the main button state to 'ABORT TESTING'.
 921        """
 922        if self.stop_event.is_set():
 923            self.stop_event.clear()
 924
 925        if isinstance(self.valve_test_button, tk.Button):
 926            self.valve_test_button.configure(text="ABORT TESTING", bg="red")
 927
 928        self.test_running = True
 929        ###====SENDING BEGIN TEST COMMAND====###
 930        command = np.int8(1).tobytes()
 931        self.arduino_controller.send_command(command)
 932
 933        while self.test_running:
 934            # if stop event, shut down the thread
 935            if self.stop_event.is_set():
 936                break
 937            if self.arduino_controller.arduino.in_waiting > 0:
 938                remaining_tests = self.arduino_controller.arduino.read()
 939                pair_number = int.from_bytes(
 940                    self.arduino_controller.arduino.read(),
 941                    byteorder="little",
 942                    signed=False,
 943                )
 944
 945                if remaining_tests == np.int8(1).tobytes():
 946                    self.event_generate("<<event1>>", when="tail", state=pair_number)
 947                if remaining_tests == np.int8(0).tobytes():
 948                    self.event_generate("<<event0>>", when="tail", state=pair_number)
 949
 950    def abort_test(self) -> None:
 951        """
 952        Aborts an ongoing test sequence.
 953
 954        Sets the `self.stop_event` to signal the `run_test` listener thread to exit.
 955        Sets `self.test_running` to False. Sends the abort *testing* command byte
 956        (`np.int8(0).tobytes()`) to the Arduino. Resets the main button state to 'Start Testing'.
 957        """
 958        # if test is aborted, stop the test listener thread
 959        if isinstance(self.valve_test_button, tk.Button):
 960            self.valve_test_button.configure(text="Start Testing", bg="green")
 961
 962        self.stop_event.set()
 963        self.test_running = False
 964        ###====SENDING ABORT TEST COMMAND====###
 965        command = np.int8(0).tobytes()
 966        self.arduino_controller.send_command(command)
 967
 968    def testing_complete(self, event: tk.Event) -> None:
 969        """
 970        Handles the final steps after the Arduino signals test completion.
 971
 972        Calls `take_input` one last time for the final test pair (using `pair_num_override`).
 973
 974        Resets the main button state and `self.test_running` flag. Sets the
 975        `self.stop_event` to ensure the listener thread terminates cleanly.
 976        Calls `auto_update_durations` to process the collected data and suggest timing changes.
 977
 978        Parameters
 979        ----------
 980        - **event** (*tk.Event*): The custom event (`<<event0>>`) generated by `run_test`, carrying the final pair number in `event.state`.
 981        """
 982        # take input from last pair/valve
 983        self.take_input(event=None, pair_num_override=event.state)
 984        # reconfigure the testing button and testing state
 985        if isinstance(self.valve_test_button, tk.Button):
 986            self.valve_test_button.configure(text="Start Testing", bg="green")
 987
 988        self.test_running = False
 989        # stop the arduino testing listener thread
 990        self.stop_event.set()
 991
 992        # update valves and such
 993        self.auto_update_durations()
 994
 995    def auto_update_durations(self):
 996        """
 997        This function is similar to the `views.valve_testing.manual_time_adjustment_window.ManualTimeAdjustment.write_timing_changes` function, it
 998        calculates new valve durations based on test results and desired volume.
 999
1000        Loads the current 'selected' durations. Iterates through the collected
1001        `self.ml_dispensed` data. For each tested valve, it calculates the actual
1002        volume dispensed per actuation and compares it to the desired volume per actuation.
1003        It computes a new duration using a ratio (`new_duration = old_duration * (desired_vol / actual_vol)`).
1004
1005        Updates the corresponding duration in the local copies of the side_one/side_two arrays.
1006        Updates the table display for the valve using `update_table_entry_test_status`.
1007        Compiles a list of changes (`changed_durations`) and opens the `ValveChanges`
1008        confirmation window, passing the changes and the `confirm_valve_changes` callback.
1009        """
1010        # list of tuples of tuples ==> [(valve, (old_dur, new_dur))] sent to ChangesWindow instance, so that
1011        # user can confirm duration changes
1012        changed_durations = []
1013
1014        ## save current durations in the oldest valve duration archival location
1015        side_one, side_two, date_used = self.arduino_data.load_durations()
1016
1017        side_one_old = side_one.copy()
1018        side_two_old = side_two.copy()
1019
1020        for valve, dispensed_amt in self.ml_dispensed:
1021            logical_valve = None
1022            tested_duration = None
1023            side_durations = None
1024
1025            if valve > VALVES_PER_SIDE:
1026                logical_valve = (valve - 1) - VALVES_PER_SIDE
1027
1028                tested_duration = side_two[logical_valve]
1029                side_durations = side_two
1030            else:
1031                logical_valve = valve - 1
1032
1033                tested_duration = side_one[logical_valve]
1034                side_durations = side_one
1035
1036            # divide by 1000 to get to mL from ul
1037            desired_per_open_vol = self.desired_volume.get() / 1000
1038
1039            # divide by 1000 to get amount per opening, NOT changing units
1040            actual_per_open_vol = dispensed_amt / self.actuations.get()
1041
1042            self.update_table_entry_test_status(valve, actual_per_open_vol)
1043
1044            new_duration = round(
1045                tested_duration * (desired_per_open_vol / actual_per_open_vol)
1046            )
1047
1048            side_durations[logical_valve] = new_duration
1049
1050            changed_durations.append((valve, (tested_duration, new_duration)))
1051
1052        ValveChanges(
1053            changed_durations,
1054            lambda: self.confirm_valve_changes(
1055                side_one, side_two, side_one_old, side_two_old
1056            ),
1057        )
1058
1059    def confirm_valve_changes(
1060        self,
1061        side_one: npt.NDArray[np.int32],
1062        side_two: npt.NDArray[np.int32],
1063        side_one_old: npt.NDArray[np.int32],
1064        side_two_old: npt.NDArray[np.int32],
1065    ):
1066        """
1067        Callback function executed when changes are confirmed in the ValveChanges window.
1068
1069        Instructs `self.arduino_data` to first save the `side_one_old` and `side_two_old`
1070        arrays to an archive slot, and then save the newly calculated `side_one` and `side_two`
1071        arrays as the 'selected' durations.
1072
1073        Parameters
1074        ----------
1075        - **side_one** (*npt.NDArray[np.int32]*): The numpy array containing the newly calculated durations for side one valves.
1076        - **side_two** (*npt.NDArray[np.int32]*): The numpy array containing the newly calculated durations for side two valves.
1077        - **side_one_old** (*npt.NDArray[np.int32]*): The numpy array containing the durations for side one valves *before* the test.
1078        - **side_two_old** (*npt.NDArray[np.int32]*): The numpy array containing the durations for side two valves *before* the test.
1079        """
1080        self.arduino_data.save_durations(side_one_old, side_two_old, "archive")
1081        self.arduino_data.save_durations(side_one, side_two, "selected")
logger = <RootLogger root (INFO)>

Get the logger in use for the app.

rig_config = '/home/blake/Documents/Photologic-Experiment-Rig-Files/assets/rig_config.toml'
TOTAL_VALVES = 8
VALVES_PER_SIDE = 4
class WindowMode(enum.Enum):
60class WindowMode(Enum):
61    """
62    Represents the two primary operational modes of the ValveTestWindow:
63    Testing mode for calibrating valve durations, and Priming mode for
64    actuating valves repeatedly without calibration.
65    """
66
67    TESTING = "Testing"
68    PRIMING = "Priming"

Represents the two primary operational modes of the ValveTestWindow: Testing mode for calibrating valve durations, and Priming mode for actuating valves repeatedly without calibration.

TESTING = <WindowMode.TESTING: 'Testing'>
PRIMING = <WindowMode.PRIMING: 'Priming'>
class ValveTestWindow(tkinter.Toplevel):
  71class ValveTestWindow(tk.Toplevel):
  72    """
  73    *This is a bit of a god class and should likely be refactored... but for now... it works!*
  74
  75    Implements the main Tkinter Toplevel window for valve testing and priming operations.
  76
  77    This window provides an interface to interact with the Arduino
  78    controller for calibrating valve open times based on dispensed volume or
  79    for running priming sequences. It features modes for testing and
  80    priming, manages valve selections, displays test progress and results,
  81    and handles communication with the Arduino.
  82
  83    Attributes
  84    ----------
  85    - **`arduino_controller`** (*ArduinoManager*): Reference to the main Arduino controller instance, used for sending commands and data.
  86    - **`arduino_data`** (*ArduinoData*): Reference to the data model holding valve duration configurations.
  87    - **`window_mode`** (*WindowMode*): Enum indicating the current operational mode (Testing or Priming).
  88    - **`test_running`** (*bool*): Flag indicating if an automated test sequence is currently active.
  89    - **`prime_running`** (*bool*): Flag indicating if a priming sequence is currently active.
  90    - **`desired_volume`** (*tk.DoubleVar*): Tkinter variable storing the target volume (in microliters) per actuation for automatic duration calculation.
  91    - **`actuations`** (*tk.IntVar*): Tkinter variable storing the number of times each valve should be actuated during a test or prime sequence.
  92    - **`ml_dispensed`** (*list[tuple[int, float]]*): Stores tuples of (valve_number, dispensed_volume_ml) entered by the user during a test.
  93    - **`valve_buttons`** (*list[tk.Button | None]*): List holding the Tkinter Button widgets for each valve selection.
  94    - **`valve_test_button`** (*tk.Button | None*): The main button used to start/abort tests or start/stop priming.
  95    - **`table_entries`** (*list[str | None]*): List holding the item IDs for each row in the ttk.Treeview table, used for modifying/hiding/showing specific rows.
  96    - **`valve_selections`** (*npt.NDArray[np.int8]*): Numpy array representing the selection state of each valve (0 if not selected, valve_number if selected).
  97    - **`side_one_tests`** (*npt.NDArray[np.int8] | None*): Numpy array holding the valve numbers (0-indexed) selected for testing/priming on side one.
  98    - **`side_two_tests`** (*npt.NDArray[np.int8] | None*): Numpy array holding the valve numbers (0-indexed) selected for testing/priming on side two.
  99    - **`stop_event`** (*threading.Event*): Event flag used to signal the Arduino listener thread (`run_test`) to stop.
 100    - **`mode_button`** (*tk.Button*): Button to switch between Testing and Priming modes.
 101    - **`dispensed_vol_frame`** (*tk.Frame*): Container frame for the desired volume input elements (visible in Testing mode).
 102    - **`valve_table_frame`** (*tk.Frame*): Container frame for the valve test results table (visible in Testing mode).
 103    - **`valve_table`** (*ttk.Treeview*): The table widget displaying valve numbers, test status, and timings.
 104    - **`valve_buttons_frame`** (*tk.Frame*): Main container frame for the side-by-side valve selection buttons.
 105    - **`side_one_valves_frame`** (*tk.Frame*): Frame holding valve selection buttons for the first side.
 106    - **`side_two_valves_frame`** (*tk.Frame*): Frame holding valve selection buttons for the second side.
 107    - **`manual_adjust_window`** (*ManualTimeAdjustment*): Instance of the separate window for manual timing adjustments.
 108
 109    Methods
 110    -------
 111    - `setup_basic_window_attr`()
 112        Configures basic window properties like title, key bindings, and icon.
 113    - `show`()
 114        Makes the window visible (deiconifies).
 115    - `switch_window_mode`()
 116        Toggles the window between Testing and Priming modes, adjusting UI elements accordingly.
 117    - `create_interface`()
 118        Builds the entire GUI layout, placing widgets and frames.
 119    - `start_testing_toggle`()
 120        Handles the press of the main test/prime button in Testing mode (starts or aborts test).
 121    - `create_buttons`()
 122        Creates and places the valve selection buttons and the main start/abort button.
 123    - `create_valve_test_table`()
 124        Creates the ttk.Treeview table for displaying valve test results and timings.
 125    - `update_table_entry_test_status`(...)
 126        Updates the 'Amount Dispensed' column for a specific valve in the table.
 127    - `update_table_entries`(...)
 128        Updates the entire valve table, showing/hiding rows based on selections and refreshing currently loaded durations.
 129    - `toggle_valve_button`(...)
 130        Handles clicks on valve selection buttons, updating their appearance and the `valve_selections` array.
 131    - `verify_variables`(...)
 132        Reads verification data back from Arduino to confirm test parameters were received correctly.
 133    - `verify_schedule`(...)
 134        Reads verification data back from Arduino to confirm the valve test/prime schedule was received correctly.
 135    - `send_schedules`()
 136        Sends the command, parameters (schedule lengths, actuations), and valve schedule to the Arduino. Handles mode-specific commands and confirmations.
 137    - `stop_priming`()
 138        Sends a command to the Arduino to stop an ongoing priming sequence.
 139    - `take_input`(...)
 140        Prompts the user (via simpledialog) to enter the dispensed volume for a completed valve test pair. Sends confirmation back to Arduino.
 141    - `run_test`()
 142        Runs in a separate thread to listen for messages from the Arduino during a test, triggering events for input prompts or completion.
 143    - `abort_test`()
 144        Sends a command to the Arduino to abort the current test and stops the listener thread.
 145    - `testing_complete`(...)
 146        Handles the event triggered when the Arduino signals the end of the entire test sequence. Initiates duration updates.
 147    - `auto_update_durations`()
 148        Calculates new valve durations based on test results (`ml_dispensed`) and opens the `ValveChanges` confirmation window.
 149    - `confirm_valve_changes`(...)
 150        Callback function executed if the user confirms changes in the `ValveChanges` window. Saves new durations and archives old ones via `ArduinoData`.
 151    """
 152
 153    def __init__(self, arduino_controller: ArduinoManager) -> None:
 154        """
 155        Initializes the ValveTestWindow.
 156
 157        Parameters
 158        ----------
 159        - **arduino_controller** (*ArduinoManager*): The application's instance of the Arduino controller.
 160
 161        Raises
 162        ------
 163        - Propagates exceptions from `GUIUtils` methods during icon setting.
 164        - Propagates exceptions from `arduino_data.load_durations()`.
 165        """
 166        super().__init__()
 167        self.arduino_controller: ArduinoManager = arduino_controller
 168        self.arduino_data: ArduinoData = arduino_controller.arduino_data
 169
 170        self.setup_basic_window_attr()
 171
 172        self.window_mode: WindowMode = WindowMode.TESTING
 173
 174        self.test_running: bool = False
 175        self.prime_running: bool = False
 176
 177        self.desired_volume: tk.DoubleVar = tk.DoubleVar(value=5)
 178
 179        self.actuations: tk.IntVar = tk.IntVar(value=1000)
 180
 181        self.ml_dispensed: list[tuple[int, float]] = []
 182
 183        self.valve_buttons: list[tk.Button | None] = [None] * TOTAL_VALVES
 184
 185        self.valve_test_button: tk.Button | None = None
 186
 187        self.table_entries: list[str | None] = [None] * TOTAL_VALVES
 188
 189        self.valve_selections: npt.NDArray[np.int8] = np.zeros(
 190            (TOTAL_VALVES,), dtype=np.int8
 191        )
 192
 193        self.side_one_tests: None | npt.NDArray[np.int8] = None
 194        self.side_two_tests: None | npt.NDArray[np.int8] = None
 195
 196        self.stop_event: threading.Event = threading.Event()
 197
 198        self.create_interface()
 199
 200        # hide main window for now until we deliberately enter this window.
 201        self.withdraw()
 202
 203        self.manual_adjust_window = ManualTimeAdjustment(
 204            self.arduino_controller.arduino_data
 205        )
 206        # hide the adjustment window until we need it.
 207        self.manual_adjust_window.withdraw()
 208
 209        logging.info("Valve Test Window initialized.")
 210
 211    def setup_basic_window_attr(self) -> None:
 212        """
 213        Sets up fundamental window properties.
 214
 215        Configures the window title, binds Ctrl+W and the close button ('X')
 216        to hide the window, sets grid column/row weights for resizing behavior,
 217        and applies the application icon using `GUIUtils`.
 218        """
 219
 220        self.title("Valve Testing")
 221        self.bind("<Control-w>", lambda event: self.withdraw())
 222        self.protocol("WM_DELETE_WINDOW", lambda: self.withdraw())
 223
 224        self.grid_columnconfigure(0, weight=1)
 225
 226        for i in range(5):
 227            self.grid_rowconfigure(i, weight=1)
 228        self.grid_rowconfigure(0, weight=0)
 229
 230        window_icon_path = GUIUtils.get_window_icon_path()
 231        GUIUtils.set_program_icon(self, icon_path=window_icon_path)
 232
 233    def show(self) -> None:
 234        """
 235        Makes the ValveTestWindow visible.
 236
 237        Calls `deiconify()` to show the window if it was previously hidden (withdrawn).
 238        """
 239        self.deiconify()
 240
 241    def switch_window_mode(self) -> None:
 242        """
 243        Toggles the window's operational mode between Testing and Priming.
 244
 245        Updates the window title and mode button text/color. Hides or shows
 246        mode-specific UI elements (desired volume input, test results table).
 247        Changes the command associated with the main start/stop button.
 248        Prevents mode switching if a test or prime sequence is currently running to avoid Arduino
 249        Communications errors. Resizes and centers the window after GUI changes.
 250        """
 251
 252        if self.window_mode == WindowMode.TESTING:
 253            if self.test_running:
 254                # if the test is currently running we do not want to switch modes. Stop testing to switch modes
 255                GUIUtils.display_error(
 256                    "ACTION PROHIBITED",
 257                    "You cannot switch modes while a test is running. If you'd like to switch modes, please abort the test and try again.",
 258                )
 259                return
 260            self.title("Valve Priming")
 261
 262            self.window_mode = WindowMode.PRIMING
 263
 264            self.mode_button.configure(text="Switch to Testing Mode", bg="DeepSkyBlue3")
 265
 266            # orig. row 1
 267            self.dispensed_vol_frame.grid_forget()
 268            # orig. row 3
 269            self.valve_table_frame.grid_forget()
 270
 271            # start testing button -> start priming button / command
 272
 273            if isinstance(self.valve_test_button, tk.Button):
 274                self.valve_test_button.configure(
 275                    text="Start Priming",
 276                    bg="coral",
 277                    command=lambda: self.send_schedules(),
 278                )
 279
 280        else:
 281            if self.prime_running:
 282                # if the test is currently running we do not want to switch modes. Stop testing to switch modes
 283                GUIUtils.display_error(
 284                    "ACTION PROHIBITED",
 285                    "You cannot switch modes while a valve prime operation is running. If you'd like to switch modes, please abort the prime and try again.",
 286                )
 287                return
 288
 289            self.title("Valve Testing")
 290
 291            self.window_mode = WindowMode.TESTING
 292
 293            self.mode_button.configure(text="Switch to Priming Mode", bg="coral")
 294
 295            self.valve_table_frame.grid(
 296                row=3, column=0, sticky="nsew", pady=10, padx=10
 297            )
 298            self.dispensed_vol_frame.grid(row=1, column=0, padx=5, sticky="nsew")
 299
 300            if isinstance(self.valve_test_button, tk.Button):
 301                self.valve_test_button.configure(
 302                    text="Start Testing",
 303                    bg="green",
 304                    command=lambda: self.start_testing_toggle(),
 305                )
 306
 307        # resize the window to fit current content
 308        self.update_idletasks()
 309        GUIUtils.center_window(self)
 310
 311    def create_interface(self) -> None:
 312        """
 313        Constructs and arranges all GUI elements within the window.
 314
 315        Creates frames for structure, input fields (desired volume, actuations),
 316        the mode switch button, the valve selection buttons, the test results table,
 317        and the main start/abort/prime button. Uses `GUIUtils` for
 318        widget creation where possible. Calls helper methods `create_valve_test_table`
 319        and `create_buttons`. Centers the window after creation.
 320        """
 321
 322        self.mode_button = GUIUtils.create_button(
 323            self,
 324            "Switch to Priming Mode",
 325            command=lambda: self.switch_window_mode(),
 326            bg="coral",
 327            row=0,
 328            column=0,
 329        )[1]
 330
 331        # create a new frame to contain the desired dispensed volume label and frame
 332        self.dispensed_vol_frame = tk.Frame(self)
 333        self.dispensed_vol_frame.grid(row=1, column=0, padx=5, sticky="nsew")
 334
 335        self.dispensed_vol_frame.grid_columnconfigure(0, weight=1)
 336        self.dispensed_vol_frame.grid_rowconfigure(0, weight=1)
 337        self.dispensed_vol_frame.grid_rowconfigure(1, weight=1)
 338
 339        label = tk.Label(
 340            self.dispensed_vol_frame,
 341            text="Desired Dispensed Volume in Microliters (ul)",
 342            bg="light blue",
 343            font=("Helvetica", 20),
 344            highlightthickness=1,
 345            highlightbackground="dark blue",
 346            height=2,
 347            width=50,
 348        )
 349        label.grid(row=0, pady=5)
 350
 351        entry = tk.Entry(
 352            self.dispensed_vol_frame,
 353            textvariable=self.desired_volume,
 354            font=("Helvetica", 24),
 355            highlightthickness=1,
 356            highlightbackground="black",
 357        )
 358        entry.grid(row=1, sticky="nsew", pady=5, ipady=14)
 359
 360        # create another new frame to contain the amount of times each
 361        # valve should actuate label and frame
 362        frame = tk.Frame(self)
 363        frame.grid(row=2, column=0, padx=5, sticky="nsew")
 364
 365        frame.grid_columnconfigure(0, weight=1)
 366        frame.grid_rowconfigure(0, weight=1)
 367        frame.grid_rowconfigure(1, weight=1)
 368
 369        label = tk.Label(
 370            frame,
 371            text="How Many Times Should the Valve Actuate?",
 372            bg="light blue",
 373            font=("Helvetica", 20),
 374            highlightthickness=1,
 375            highlightbackground="dark blue",
 376            height=2,
 377            width=50,
 378        )
 379        label.grid(row=0, pady=5)
 380
 381        entry = tk.Entry(
 382            frame,
 383            textvariable=self.actuations,
 384            font=("Helvetica", 24),
 385            highlightthickness=1,
 386            highlightbackground="black",
 387        )
 388        entry.grid(row=1, sticky="nsew", pady=5, ipady=12)
 389
 390        ### ROW 3 ###
 391        self.create_valve_test_table()
 392
 393        # setup frames for valve buttons
 394        self.valve_buttons_frame = tk.Frame(
 395            self, highlightbackground="black", highlightthickness=1
 396        )
 397        ### ROW 4 ###
 398        self.valve_buttons_frame.grid(row=4, column=0, pady=10, padx=10, sticky="nsew")
 399        self.valve_buttons_frame.grid_columnconfigure(0, weight=1)
 400        self.valve_buttons_frame.grid_rowconfigure(0, weight=1)
 401
 402        self.side_one_valves_frame = tk.Frame(
 403            self.valve_buttons_frame, highlightbackground="black", highlightthickness=1
 404        )
 405        self.side_one_valves_frame.grid(row=0, column=0, pady=10, padx=10, sticky="w")
 406        self.side_one_valves_frame.grid_columnconfigure(0, weight=1)
 407        for i in range(4):
 408            self.side_one_valves_frame.grid_rowconfigure(i, weight=1)
 409
 410        self.side_two_valves_frame = tk.Frame(
 411            self.valve_buttons_frame, highlightbackground="black", highlightthickness=1
 412        )
 413        self.side_two_valves_frame.grid(row=0, column=1, pady=10, padx=10, sticky="e")
 414        self.side_two_valves_frame.grid_columnconfigure(0, weight=1)
 415        for i in range(4):
 416            self.side_two_valves_frame.grid_rowconfigure(i, weight=1)
 417
 418        ### ROW 5 ###
 419        self.create_buttons()
 420
 421        self.update_idletasks()
 422        GUIUtils.center_window(self)
 423
 424    def start_testing_toggle(self) -> None:
 425        """
 426        Handles the action of the main button when in Testing mode.
 427
 428        If a test is running, it calls `abort_test()` and updates the button appearance.
 429        If no test is running, it calls `send_schedules()` to initiate a new test.
 430        """
 431
 432        if self.test_running:
 433            if isinstance(self.valve_test_button, tk.Button):
 434                self.valve_test_button.configure(text="Start Testing", bg="green")
 435            self.abort_test()
 436        else:
 437            self.send_schedules()
 438
 439    def create_buttons(self) -> None:
 440        """
 441        Creates and places the individual valve selection buttons and the main action button.
 442
 443        Generates buttons for each valve (1 to TOTAL_VALVES), arranging them
 444        into side-one and side-two frames using `GUIUtils.create_button`.
 445        Assigns the `toggle_valve_button` command to each valve button.
 446
 447        Creates the main 'Start Testing' / 'Start Priming' / 'Abort' button.
 448        """
 449        for i in range(VALVES_PER_SIDE):
 450            # GUIUtils.create_button returns both the buttons frame and button object in a tuple. using [1] on the
 451            # return item yields the button object.
 452
 453            # return the side one button and put it on the side one 'side'
 454            # of the list (0-4)
 455            self.valve_buttons[i] = GUIUtils.create_button(
 456                self.side_one_valves_frame,
 457                f"Valve {i + 1}",
 458                lambda i=i: self.toggle_valve_button(self.valve_buttons[i], i),
 459                "light blue",
 460                i,
 461                0,
 462            )[1]
 463
 464            # return the side two button and put it on the side two 'side'
 465            # of the list (5-8)
 466            self.valve_buttons[i + (VALVES_PER_SIDE)] = GUIUtils.create_button(
 467                self.side_two_valves_frame,
 468                f"Valve {i + (VALVES_PER_SIDE) + 1}",
 469                lambda i=i: self.toggle_valve_button(
 470                    self.valve_buttons[i + (VALVES_PER_SIDE)], i + (VALVES_PER_SIDE)
 471                ),
 472                "light blue",
 473                i,
 474                0,
 475            )[1]
 476
 477        self.valve_test_button = GUIUtils.create_button(
 478            self, "Start Testing", lambda: self.start_testing_toggle(), "green", 5, 0
 479        )[1]
 480
 481    def create_valve_test_table(self) -> None:
 482        """
 483        Creates, configures, and populates the initial state of the ttk.Treeview table.
 484
 485        Sets up the table columns ("Valve", "Amount Dispensed...", "Testing Opening Time...").
 486        Adds a button above the table to open the `ManualTimeAdjustment` window.
 487
 488        Loads the currently selected valve durations from `models.arduino_data.ArduinoData`.
 489        Inserts initial rows for all possible valves, marking them as "Test Not Complete"
 490        and displaying their current duration. Stores row IDs in `self.table_entries` for later access.
 491        """
 492
 493        # this function creates and labels the testing window table that displays the information recieved from the arduino reguarding the results of the tes
 494        self.valve_table_frame = tk.Frame(
 495            self, highlightbackground="black", highlightthickness=1
 496        )
 497        self.valve_table_frame.grid(row=3, column=0, sticky="nsew", pady=10, padx=10)
 498        # tell the frame to fill the available width
 499        self.valve_table_frame.grid_columnconfigure(0, weight=1)
 500
 501        self.valve_table = ttk.Treeview(
 502            self.valve_table_frame, show="headings", height=12
 503        )
 504
 505        headings = [
 506            "Valve",
 507            "Amount Dispensed Per Lick (ul)",
 508            "Testing Opening Time of (ms)",
 509        ]
 510
 511        self.valve_table["columns"] = headings
 512
 513        self.valve_table.grid(row=1, sticky="ew")
 514
 515        button = tk.Button(
 516            self.valve_table_frame,
 517            text="Manual Valve Duration Override",
 518            command=lambda: self.manual_adjust_window.show(),
 519            bg="light blue",
 520            highlightbackground="black",
 521            highlightthickness=1,
 522        )
 523
 524        button.grid(row=0, sticky="e")
 525
 526        # Configure the columns
 527        for col in self.valve_table["columns"]:
 528            self.valve_table.heading(col, text=col)
 529
 530        arduino_data = self.arduino_controller.arduino_data
 531
 532        side_one_durations, side_two_durations, date_used = (
 533            arduino_data.load_durations()
 534        )
 535
 536        # insert default entries for each total valve until user selects how many
 537        # they want to test
 538        for i in range(VALVES_PER_SIDE):
 539            self.table_entries[i] = self.valve_table.insert(
 540                "",
 541                i,
 542                values=(
 543                    f"{i + 1}",
 544                    "Test Not Complete",
 545                    f"{side_one_durations[i]} ms",
 546                ),
 547            )
 548            self.table_entries[i + 4] = self.valve_table.insert(
 549                "",
 550                i + VALVES_PER_SIDE,
 551                values=(
 552                    f"{i + VALVES_PER_SIDE + 1}",
 553                    "Test Not Complete",
 554                    f"{side_two_durations[i]} ms",
 555                ),
 556            )
 557
 558    def update_table_entry_test_status(self, valve: int, l_per_lick: float) -> None:
 559        """
 560        Updates the 'Amount Dispensed Per Lick (ul)' column for a specific valve row.
 561
 562        Parameters
 563        ----------
 564        - **valve** (*int*): The 1-indexed valve number whose row needs updating.
 565        - **l_per_lick** (*float*): The calculated volume dispensed per lick in milliliters (mL).
 566        """
 567
 568        self.valve_table.set(
 569            self.table_entries[valve - 1], column=1, value=f"{l_per_lick * 1000} uL"
 570        )
 571
 572    def update_table_entries(self) -> None:
 573        """
 574        Refreshes the entire valve test table based on current selections and durations.
 575
 576        Hides rows for unselected valves and shows rows for selected valves.
 577        Reloads the latest durations from `ArduinoData` and updates the
 578        'Testing Opening Time' column for all visible rows. Resets the
 579        'Amount Dispensed' column to "Test Not Complete" for visible rows.
 580        """
 581        side_one, side_two, _ = self.arduino_data.load_durations()
 582        # np array where the entry is not zero but the valve number itself
 583        selected_valves = self.valve_selections[self.valve_selections != 0]
 584
 585        # for all valve numbers, if that number is in selected_valves -1 (the entire array
 586        # decremented by one) place it in the table and ensure its duration is up-to-date
 587        for i in range(TOTAL_VALVES):
 588            # if the iter number is also contained in the selected valves arr
 589            if i in (selected_valves - 1):
 590                # check if it's side two or side one
 591                if i >= VALVES_PER_SIDE:
 592                    duration = side_two[i - VALVES_PER_SIDE]
 593                else:
 594                    duration = side_one[i]
 595                self.valve_table.item(
 596                    self.table_entries[i],
 597                    values=(
 598                        f"{i + 1}",
 599                        "Test Not Complete",
 600                        f"{duration} ms",
 601                    ),
 602                )
 603                self.valve_table.reattach(self.table_entries[i], "", i)
 604            else:
 605                self.valve_table.detach(self.table_entries[i])
 606
 607        logging.info("Valve test table updated.")
 608
 609    def toggle_valve_button(self, button: tk.Button, valve_num: int) -> None:
 610        """
 611        Handles the click event for a valve selection button.
 612
 613        Toggles the button's visual state (raised/sunken). Updates the
 614        corresponding entry in the `self.valve_selections` numpy array (0 for
 615        deselected, valve_number + 1 for selected). Calls `update_table_entries`
 616        to refresh the table display. Filling arrays this way allows us to tell
 617        the arduino exactly which valves should be toggled (1, 4, 7) as long as
 618        their value in the array is not 0.
 619
 620        Parameters
 621        ----------
 622        - **button** (*tk.Button*): The specific valve button widget that was clicked.
 623        - **valve_num** (*int*): The 0-indexed number of the valve corresponding to the button.
 624        """
 625        # Attempt to get the value to ensure it's a valid float as expected
 626        # If successful, call the function to update/create the valve test table
 627        if self.valve_selections[valve_num] == 0:
 628            button.configure(relief="sunken")
 629            self.valve_selections[valve_num] = valve_num + 1
 630
 631        else:
 632            button.configure(relief="raised")
 633            self.valve_selections[valve_num] = 0
 634        self.update_table_entries()
 635
 636    def verify_variables(self, original_data: bytes) -> bool:
 637        """
 638        Verifies that the Arduino correctly received the test/prime parameters.
 639
 640        Waits for and reads back the schedule lengths and actuation count echoed
 641        by the Arduino. Compares the received bytes with the originally sent data.
 642
 643        Parameters
 644        ----------
 645        - **original_data** (*bytes*): The byte string containing schedule lengths and actuation count originally sent to the Arduino.
 646
 647        Returns
 648        -------
 649        - *bool*: `True` if the received data matches the original data, `False` otherwise (logs error and shows message box on mismatch).
 650        """
 651        # wait until all data is on the wire ready to be read (4 bytes),
 652        # then read it all in quick succession
 653        if self.arduino_controller.arduino is None:
 654            msg = "====INCORRECT SCHEDULE DETECTED, TRY AGAIN===="
 655            GUIUtils.display_error("TRANSMISSION ERROR", msg)
 656            logger.error(msg)
 657            return False
 658
 659        while self.arduino_controller.arduino.in_waiting < 4:
 660            pass
 661
 662        ver_len_sched_sd_one = self.arduino_controller.arduino.read()
 663        ver_len_sched_sd_two = self.arduino_controller.arduino.read()
 664
 665        ver_valve_act_time = self.arduino_controller.arduino.read(2)
 666
 667        verification_data = (
 668            ver_len_sched_sd_one + ver_len_sched_sd_two + ver_valve_act_time
 669        )
 670        if verification_data == original_data:
 671            logger.info("====VERIFIED TEST VARIABLES====")
 672            return True
 673        else:
 674            msg = "====INCORRECT VARIABLES DETECTED, TRY AGAIN===="
 675            GUIUtils.display_error("TRANSMISSION ERROR", msg)
 676            logger.error(msg)
 677            return False
 678
 679    def verify_schedule(
 680        self, len_sched: int, sent_schedule: npt.NDArray[np.int8]
 681    ) -> bool:
 682        """
 683        Verifies that the Arduino correctly received the valve schedule.
 684
 685        Waits for and reads back the valve numbers (0-indexed) echoed byte-by-byte
 686        by the Arduino. Compares the reconstructed numpy array with the schedule
 687        that was originally sent.
 688
 689        Parameters
 690        ----------
 691        - **len_sched** (*int*): The total number of valves in the schedule being sent.
 692        - **sent_schedule** (*npt.NDArray[np.int8]*): The numpy array (0-indexed valve numbers) representing the schedule originally sent.
 693
 694        Returns
 695        -------
 696        - *bool*: `True` if the received schedule matches the sent schedule, `False` otherwise (logs error and shows message box on mismatch).
 697        """
 698        # np array used for verification later
 699        received_sched = np.zeros((len_sched,), dtype=np.int8)
 700
 701        if self.arduino_controller.arduino is None:
 702            msg = "====INCORRECT SCHEDULE DETECTED, TRY AGAIN===="
 703            GUIUtils.display_error("TRANSMISSION ERROR", msg)
 704            logger.error(msg)
 705            return False
 706
 707        while self.arduino_controller.arduino.in_waiting < len_sched:
 708            pass
 709
 710        for i in range(len_sched):
 711            received_sched[i] = int.from_bytes(
 712                self.arduino_controller.arduino.read(), byteorder="little", signed=False
 713            )
 714
 715        if np.array_equal(received_sched, sent_schedule):
 716            logger.info(f"===TESTING SCHEDULE VERIFIED as ==> {received_sched}===")
 717            logger.info("====================BEGIN TESTING NOW====================")
 718            return True
 719        else:
 720            msg = "====INCORRECT SCHEDULE DETECTED, TRY AGAIN===="
 721            GUIUtils.display_error("TRANSMISSION ERROR", msg)
 722            logger.error(msg)
 723            return False
 724
 725    def send_schedules(self) -> None:
 726        """
 727        Performs the process of sending test or prime configuration to the Arduino.
 728
 729        Determines selected valves and schedule lengths for each side. Sends current
 730        valve durations to Arduino. Then sends the appropriate command ('TEST VOL' or 'PRIME VALVES').
 731
 732        Sends test parameters (schedule lengths, actuations) and verifies them using `verify_variables`.
 733        Sends the actual valve schedule (0-indexed) and verifies it using `verify_schedule`.
 734
 735        User is then prompted to confirm before proceeding.
 736        If confirmed, initiates the test/prime sequence on the Arduino
 737        and starts the listener thread (`run_test`) if Testing, or sends start command if Priming.
 738        Updates button states accordingly.
 739        """
 740        len_sched_one = np.count_nonzero(self.valve_selections[:VALVES_PER_SIDE])
 741        len_sched_two = np.count_nonzero(self.valve_selections[VALVES_PER_SIDE:])
 742
 743        len_sched_one = np.int8(len_sched_one)
 744        len_sched_two = np.int8(len_sched_two)
 745        len_sched = len_sched_one + len_sched_two
 746
 747        # because self.actuations is a IntVar, we use .get() method to get its value
 748        valve_acuations = np.int16(self.actuations.get())
 749
 750        # schedule is TOTAL_VALVES long to accomodate testing of up to all valves at one time.
 751        # if we are not testing them all, filter out the zero entries (valves we are not testing)
 752        schedule = self.valve_selections[self.valve_selections != 0]
 753
 754        # decrement each element in the array by one to move to zero-indexing. We moved to 1
 755        # indexing becuase using zero indexing does not work with the method of toggling valves
 756        # defined above
 757        schedule -= 1
 758
 759        self.side_one_tests = schedule[:len_sched_one]
 760        self.side_two_tests = schedule[len_sched_one:]
 761
 762        len_sched_one = len_sched_one
 763        len_sched_two = len_sched_two
 764        valve_acuations = valve_acuations
 765
 766        self.arduino_controller.send_valve_durations()
 767        if self.window_mode == WindowMode.TESTING:
 768            ###====SENDING TEST COMMAND====###
 769            command = "TEST VOL\n".encode("utf-8")
 770            self.arduino_controller.send_command(command)
 771
 772        elif self.window_mode == WindowMode.PRIMING:
 773            self.prime_running = True
 774
 775            command = "PRIME VALVES\n".encode("utf-8")
 776            self.arduino_controller.send_command(command)
 777
 778        ###====SENDING TEST VARIABLES (len side one, two, num_actuations per test)====###
 779        data_bytes = (
 780            len_sched_one.tobytes()
 781            + len_sched_two.tobytes()
 782            + valve_acuations.tobytes()
 783        )
 784        self.arduino_controller.send_command(data_bytes)
 785
 786        # verify the data
 787        if not self.verify_variables(data_bytes):
 788            return
 789
 790        ###====SENDING SCHEDULE====###
 791        sched_data_bytes = schedule.tobytes()
 792        self.arduino_controller.send_command(sched_data_bytes)
 793
 794        if not self.verify_schedule(len_sched, schedule):
 795            return
 796
 797        schedule += 1
 798
 799        valves = ",".join(str(valve) for valve in schedule)
 800        if self.window_mode == WindowMode.TESTING:
 801            ####### ARDUINO HALTS HERE UNTIL THE GO-AHEAD IS GIVEN #######
 802            # inc each sched element by one so that it is 1-indexes and matches the valve labels
 803            test_confirmed = GUIUtils.askyesno(
 804                "CONFIRM THE TEST SCHEDULE",
 805                f"Valves {valves}, will be tested. Review the test table to confirm schedule and timings. Each valve will be actuated {valve_acuations} times. Ok to begin?",
 806            )
 807
 808            ### include section to manually modify timings
 809            if test_confirmed:
 810                self.bind("<<event0>>", self.testing_complete)
 811                self.bind("<<event1>>", self.take_input)
 812                threading.Thread(target=self.run_test).start()
 813            else:
 814                self.abort_test()
 815        elif self.window_mode == WindowMode.PRIMING:
 816            priming_confirmed = GUIUtils.askyesno(
 817                "CONFIRM THE ACTION",
 818                f"Valves {valves}, will be primed (opened and closed) {valve_acuations} times. Ok to begin?",
 819            )
 820            if priming_confirmed:
 821                # in case of prime, we send 0 to mean continue or start
 822                # and we send a 1 at any point to abort or cancel the prime
 823                start = np.int8(0).tobytes()
 824                self.arduino_controller.send_command(command=start)
 825
 826                if isinstance(self.valve_test_button, tk.Button):
 827                    self.valve_test_button.configure(
 828                        text="STOP Priming",
 829                        bg="gold",
 830                        command=lambda: self.stop_priming(),
 831                    )
 832
 833    def stop_priming(self) -> None:
 834        """
 835        Sends the stop command to the Arduino during a priming sequence.
 836
 837        Sets the `prime_running` flag to False. Updates the prime button state.
 838        Sends a byte representation of 1 (`np.int8(1).tobytes()`) to signal abort to the Arduino.
 839        """
 840        self.prime_running = False
 841
 842        if isinstance(self.valve_test_button, tk.Button):
 843            self.valve_test_button.configure(
 844                text="Start Priming",
 845                bg="coral",
 846                command=lambda: self.send_schedules(),
 847            )
 848
 849        stop = np.int8(1).tobytes()
 850        self.arduino_controller.send_command(command=stop)
 851
 852    def take_input(
 853        self, event: tk.Event | None, pair_num_override: int | None = None
 854    ) -> None:
 855        """
 856        Prompts the user to enter the dispensed volume for a completed test pair.
 857
 858        Determines which valve(s) were just tested based on the pair number received
 859        from the Arduino (via the event state or override). Uses `simpledialog.askfloat`
 860        to get the dispensed volume (in mL) for each valve in the pair. Stores the
 861        results in `self.ml_dispensed`.
 862
 863        Sends a byte back to the Arduino to instruct if further pairs will be tested or if this is the final iteration,
 864        (1 if via event, 0 if via override/final pair).
 865        Handles potential abort if the user cancels the dialog window.
 866
 867        Parameters
 868        ----------
 869        - **event** (*tk.Event | None*): Uses custom thread-safe event (`<<event1>>`) generated by `run_test`, carrying the pair number in `event.state`
 870        attribute. This is None if called directly by `testing_complete`, which indicated procedure is now finished.
 871        - **pair_num_override** (*int | None, optional*): Allows manually specifying the pair number, used when called from `testing_complete` for the
 872        final pair. Defaults to None.
 873        """
 874        if event is not None:
 875            pair_number = event.state
 876        else:
 877            pair_number = pair_num_override
 878
 879        # decide how many valves were tested last. if 0 we will not arrive
 880        # here so we need not test for this.
 881        if pair_number < len(self.side_one_tests) and pair_number < len(
 882            self.side_two_tests
 883        ):
 884            valves_tested = [
 885                self.side_one_tests[pair_number],
 886                self.side_two_tests[pair_number],
 887            ]
 888        elif pair_number < len(self.side_one_tests):
 889            valves_tested = [
 890                self.side_one_tests[pair_number],
 891            ]
 892        else:
 893            valves_tested = [
 894                self.side_two_tests[pair_number],
 895            ]
 896
 897        # for each test that we ran store the amount dispensed or abort
 898        for valve in valves_tested:
 899            response = simpledialog.askfloat(
 900                "INPUT AMOUNT DISPENSED",
 901                f"Please input the amount of liquid dispensed for the test of valve {valve}",
 902            )
 903            if response is None:
 904                self.abort_test()
 905            else:
 906                self.ml_dispensed.append((valve, response))
 907        if pair_num_override is not None:
 908            self.arduino_controller.send_command(np.int8(0).tobytes())
 909        else:
 910            self.arduino_controller.send_command(np.int8(1).tobytes())
 911
 912    def run_test(self) -> None:
 913        """
 914        Listens for messages from the Arduino during an active test sequence (runs in a background thread).
 915
 916        Sends the initial 'start test' command byte to the Arduino. Enters a loop
 917        that continues as long as `self.test_running` is True and `self.stop_event`
 918        is not set. Checks for incoming data from the Arduino. Reads bytes indicating
 919        if more tests remain and the number of the just-completed pair. Generates
 920        custom Tkinter events (`<<event1>>` for input dispensed liquid prompt, `<<event0>>` for test completion)
 921        to communicate back to the main GUI thread safely. Updates the main button state to 'ABORT TESTING'.
 922        """
 923        if self.stop_event.is_set():
 924            self.stop_event.clear()
 925
 926        if isinstance(self.valve_test_button, tk.Button):
 927            self.valve_test_button.configure(text="ABORT TESTING", bg="red")
 928
 929        self.test_running = True
 930        ###====SENDING BEGIN TEST COMMAND====###
 931        command = np.int8(1).tobytes()
 932        self.arduino_controller.send_command(command)
 933
 934        while self.test_running:
 935            # if stop event, shut down the thread
 936            if self.stop_event.is_set():
 937                break
 938            if self.arduino_controller.arduino.in_waiting > 0:
 939                remaining_tests = self.arduino_controller.arduino.read()
 940                pair_number = int.from_bytes(
 941                    self.arduino_controller.arduino.read(),
 942                    byteorder="little",
 943                    signed=False,
 944                )
 945
 946                if remaining_tests == np.int8(1).tobytes():
 947                    self.event_generate("<<event1>>", when="tail", state=pair_number)
 948                if remaining_tests == np.int8(0).tobytes():
 949                    self.event_generate("<<event0>>", when="tail", state=pair_number)
 950
 951    def abort_test(self) -> None:
 952        """
 953        Aborts an ongoing test sequence.
 954
 955        Sets the `self.stop_event` to signal the `run_test` listener thread to exit.
 956        Sets `self.test_running` to False. Sends the abort *testing* command byte
 957        (`np.int8(0).tobytes()`) to the Arduino. Resets the main button state to 'Start Testing'.
 958        """
 959        # if test is aborted, stop the test listener thread
 960        if isinstance(self.valve_test_button, tk.Button):
 961            self.valve_test_button.configure(text="Start Testing", bg="green")
 962
 963        self.stop_event.set()
 964        self.test_running = False
 965        ###====SENDING ABORT TEST COMMAND====###
 966        command = np.int8(0).tobytes()
 967        self.arduino_controller.send_command(command)
 968
 969    def testing_complete(self, event: tk.Event) -> None:
 970        """
 971        Handles the final steps after the Arduino signals test completion.
 972
 973        Calls `take_input` one last time for the final test pair (using `pair_num_override`).
 974
 975        Resets the main button state and `self.test_running` flag. Sets the
 976        `self.stop_event` to ensure the listener thread terminates cleanly.
 977        Calls `auto_update_durations` to process the collected data and suggest timing changes.
 978
 979        Parameters
 980        ----------
 981        - **event** (*tk.Event*): The custom event (`<<event0>>`) generated by `run_test`, carrying the final pair number in `event.state`.
 982        """
 983        # take input from last pair/valve
 984        self.take_input(event=None, pair_num_override=event.state)
 985        # reconfigure the testing button and testing state
 986        if isinstance(self.valve_test_button, tk.Button):
 987            self.valve_test_button.configure(text="Start Testing", bg="green")
 988
 989        self.test_running = False
 990        # stop the arduino testing listener thread
 991        self.stop_event.set()
 992
 993        # update valves and such
 994        self.auto_update_durations()
 995
 996    def auto_update_durations(self):
 997        """
 998        This function is similar to the `views.valve_testing.manual_time_adjustment_window.ManualTimeAdjustment.write_timing_changes` function, it
 999        calculates new valve durations based on test results and desired volume.
1000
1001        Loads the current 'selected' durations. Iterates through the collected
1002        `self.ml_dispensed` data. For each tested valve, it calculates the actual
1003        volume dispensed per actuation and compares it to the desired volume per actuation.
1004        It computes a new duration using a ratio (`new_duration = old_duration * (desired_vol / actual_vol)`).
1005
1006        Updates the corresponding duration in the local copies of the side_one/side_two arrays.
1007        Updates the table display for the valve using `update_table_entry_test_status`.
1008        Compiles a list of changes (`changed_durations`) and opens the `ValveChanges`
1009        confirmation window, passing the changes and the `confirm_valve_changes` callback.
1010        """
1011        # list of tuples of tuples ==> [(valve, (old_dur, new_dur))] sent to ChangesWindow instance, so that
1012        # user can confirm duration changes
1013        changed_durations = []
1014
1015        ## save current durations in the oldest valve duration archival location
1016        side_one, side_two, date_used = self.arduino_data.load_durations()
1017
1018        side_one_old = side_one.copy()
1019        side_two_old = side_two.copy()
1020
1021        for valve, dispensed_amt in self.ml_dispensed:
1022            logical_valve = None
1023            tested_duration = None
1024            side_durations = None
1025
1026            if valve > VALVES_PER_SIDE:
1027                logical_valve = (valve - 1) - VALVES_PER_SIDE
1028
1029                tested_duration = side_two[logical_valve]
1030                side_durations = side_two
1031            else:
1032                logical_valve = valve - 1
1033
1034                tested_duration = side_one[logical_valve]
1035                side_durations = side_one
1036
1037            # divide by 1000 to get to mL from ul
1038            desired_per_open_vol = self.desired_volume.get() / 1000
1039
1040            # divide by 1000 to get amount per opening, NOT changing units
1041            actual_per_open_vol = dispensed_amt / self.actuations.get()
1042
1043            self.update_table_entry_test_status(valve, actual_per_open_vol)
1044
1045            new_duration = round(
1046                tested_duration * (desired_per_open_vol / actual_per_open_vol)
1047            )
1048
1049            side_durations[logical_valve] = new_duration
1050
1051            changed_durations.append((valve, (tested_duration, new_duration)))
1052
1053        ValveChanges(
1054            changed_durations,
1055            lambda: self.confirm_valve_changes(
1056                side_one, side_two, side_one_old, side_two_old
1057            ),
1058        )
1059
1060    def confirm_valve_changes(
1061        self,
1062        side_one: npt.NDArray[np.int32],
1063        side_two: npt.NDArray[np.int32],
1064        side_one_old: npt.NDArray[np.int32],
1065        side_two_old: npt.NDArray[np.int32],
1066    ):
1067        """
1068        Callback function executed when changes are confirmed in the ValveChanges window.
1069
1070        Instructs `self.arduino_data` to first save the `side_one_old` and `side_two_old`
1071        arrays to an archive slot, and then save the newly calculated `side_one` and `side_two`
1072        arrays as the 'selected' durations.
1073
1074        Parameters
1075        ----------
1076        - **side_one** (*npt.NDArray[np.int32]*): The numpy array containing the newly calculated durations for side one valves.
1077        - **side_two** (*npt.NDArray[np.int32]*): The numpy array containing the newly calculated durations for side two valves.
1078        - **side_one_old** (*npt.NDArray[np.int32]*): The numpy array containing the durations for side one valves *before* the test.
1079        - **side_two_old** (*npt.NDArray[np.int32]*): The numpy array containing the durations for side two valves *before* the test.
1080        """
1081        self.arduino_data.save_durations(side_one_old, side_two_old, "archive")
1082        self.arduino_data.save_durations(side_one, side_two, "selected")

This is a bit of a god class and should likely be refactored... but for now... it works!

Implements the main Tkinter Toplevel window for valve testing and priming operations.

This window provides an interface to interact with the Arduino controller for calibrating valve open times based on dispensed volume or for running priming sequences. It features modes for testing and priming, manages valve selections, displays test progress and results, and handles communication with the Arduino.

Attributes

  • arduino_controller (ArduinoManager): Reference to the main Arduino controller instance, used for sending commands and data.
  • arduino_data (ArduinoData): Reference to the data model holding valve duration configurations.
  • window_mode (WindowMode): Enum indicating the current operational mode (Testing or Priming).
  • test_running (bool): Flag indicating if an automated test sequence is currently active.
  • prime_running (bool): Flag indicating if a priming sequence is currently active.
  • desired_volume (tk.DoubleVar): Tkinter variable storing the target volume (in microliters) per actuation for automatic duration calculation.
  • actuations (tk.IntVar): Tkinter variable storing the number of times each valve should be actuated during a test or prime sequence.
  • ml_dispensed (list[tuple[int, float]]): Stores tuples of (valve_number, dispensed_volume_ml) entered by the user during a test.
  • valve_buttons (list[tk.Button | None]): List holding the Tkinter Button widgets for each valve selection.
  • valve_test_button (tk.Button | None): The main button used to start/abort tests or start/stop priming.
  • table_entries (list[str | None]): List holding the item IDs for each row in the ttk.Treeview table, used for modifying/hiding/showing specific rows.
  • valve_selections (npt.NDArray[np.int8]): Numpy array representing the selection state of each valve (0 if not selected, valve_number if selected).
  • side_one_tests (npt.NDArray[np.int8] | None): Numpy array holding the valve numbers (0-indexed) selected for testing/priming on side one.
  • side_two_tests (npt.NDArray[np.int8] | None): Numpy array holding the valve numbers (0-indexed) selected for testing/priming on side two.
  • stop_event (threading.Event): Event flag used to signal the Arduino listener thread (run_test) to stop.
  • mode_button (tk.Button): Button to switch between Testing and Priming modes.
  • dispensed_vol_frame (tk.Frame): Container frame for the desired volume input elements (visible in Testing mode).
  • valve_table_frame (tk.Frame): Container frame for the valve test results table (visible in Testing mode).
  • valve_table (ttk.Treeview): The table widget displaying valve numbers, test status, and timings.
  • valve_buttons_frame (tk.Frame): Main container frame for the side-by-side valve selection buttons.
  • side_one_valves_frame (tk.Frame): Frame holding valve selection buttons for the first side.
  • side_two_valves_frame (tk.Frame): Frame holding valve selection buttons for the second side.
  • manual_adjust_window (ManualTimeAdjustment): Instance of the separate window for manual timing adjustments.

Methods

  • setup_basic_window_attr() Configures basic window properties like title, key bindings, and icon.
  • show() Makes the window visible (deiconifies).
  • switch_window_mode() Toggles the window between Testing and Priming modes, adjusting UI elements accordingly.
  • create_interface() Builds the entire GUI layout, placing widgets and frames.
  • start_testing_toggle() Handles the press of the main test/prime button in Testing mode (starts or aborts test).
  • create_buttons() Creates and places the valve selection buttons and the main start/abort button.
  • create_valve_test_table() Creates the ttk.Treeview table for displaying valve test results and timings.
  • update_table_entry_test_status(...) Updates the 'Amount Dispensed' column for a specific valve in the table.
  • update_table_entries(...) Updates the entire valve table, showing/hiding rows based on selections and refreshing currently loaded durations.
  • toggle_valve_button(...) Handles clicks on valve selection buttons, updating their appearance and the valve_selections array.
  • verify_variables(...) Reads verification data back from Arduino to confirm test parameters were received correctly.
  • verify_schedule(...) Reads verification data back from Arduino to confirm the valve test/prime schedule was received correctly.
  • send_schedules() Sends the command, parameters (schedule lengths, actuations), and valve schedule to the Arduino. Handles mode-specific commands and confirmations.
  • stop_priming() Sends a command to the Arduino to stop an ongoing priming sequence.
  • take_input(...) Prompts the user (via simpledialog) to enter the dispensed volume for a completed valve test pair. Sends confirmation back to Arduino.
  • run_test() Runs in a separate thread to listen for messages from the Arduino during a test, triggering events for input prompts or completion.
  • abort_test() Sends a command to the Arduino to abort the current test and stops the listener thread.
  • testing_complete(...) Handles the event triggered when the Arduino signals the end of the entire test sequence. Initiates duration updates.
  • auto_update_durations() Calculates new valve durations based on test results (ml_dispensed) and opens the ValveChanges confirmation window.
  • confirm_valve_changes(...) Callback function executed if the user confirms changes in the ValveChanges window. Saves new durations and archives old ones via ArduinoData.
ValveTestWindow(arduino_controller: controllers.arduino_control.ArduinoManager)
153    def __init__(self, arduino_controller: ArduinoManager) -> None:
154        """
155        Initializes the ValveTestWindow.
156
157        Parameters
158        ----------
159        - **arduino_controller** (*ArduinoManager*): The application's instance of the Arduino controller.
160
161        Raises
162        ------
163        - Propagates exceptions from `GUIUtils` methods during icon setting.
164        - Propagates exceptions from `arduino_data.load_durations()`.
165        """
166        super().__init__()
167        self.arduino_controller: ArduinoManager = arduino_controller
168        self.arduino_data: ArduinoData = arduino_controller.arduino_data
169
170        self.setup_basic_window_attr()
171
172        self.window_mode: WindowMode = WindowMode.TESTING
173
174        self.test_running: bool = False
175        self.prime_running: bool = False
176
177        self.desired_volume: tk.DoubleVar = tk.DoubleVar(value=5)
178
179        self.actuations: tk.IntVar = tk.IntVar(value=1000)
180
181        self.ml_dispensed: list[tuple[int, float]] = []
182
183        self.valve_buttons: list[tk.Button | None] = [None] * TOTAL_VALVES
184
185        self.valve_test_button: tk.Button | None = None
186
187        self.table_entries: list[str | None] = [None] * TOTAL_VALVES
188
189        self.valve_selections: npt.NDArray[np.int8] = np.zeros(
190            (TOTAL_VALVES,), dtype=np.int8
191        )
192
193        self.side_one_tests: None | npt.NDArray[np.int8] = None
194        self.side_two_tests: None | npt.NDArray[np.int8] = None
195
196        self.stop_event: threading.Event = threading.Event()
197
198        self.create_interface()
199
200        # hide main window for now until we deliberately enter this window.
201        self.withdraw()
202
203        self.manual_adjust_window = ManualTimeAdjustment(
204            self.arduino_controller.arduino_data
205        )
206        # hide the adjustment window until we need it.
207        self.manual_adjust_window.withdraw()
208
209        logging.info("Valve Test Window initialized.")

Initializes the ValveTestWindow.

Parameters

  • arduino_controller (ArduinoManager): The application's instance of the Arduino controller.

Raises

  • Propagates exceptions from GUIUtils methods during icon setting.
  • Propagates exceptions from arduino_data.load_durations().
arduino_controller: controllers.arduino_control.ArduinoManager
arduino_data: models.arduino_data.ArduinoData
window_mode: WindowMode
test_running: bool
prime_running: bool
desired_volume: tkinter.DoubleVar
actuations: tkinter.IntVar
ml_dispensed: list[tuple[int, float]]
valve_buttons: list[tkinter.Button | None]
valve_test_button: tkinter.Button | None
table_entries: list[str | None]
valve_selections: numpy.ndarray[tuple[int, ...], numpy.dtype[numpy.int8]]
side_one_tests: None | numpy.ndarray[tuple[int, ...], numpy.dtype[numpy.int8]]
side_two_tests: None | numpy.ndarray[tuple[int, ...], numpy.dtype[numpy.int8]]
stop_event: threading.Event
manual_adjust_window
def setup_basic_window_attr(self) -> None:
211    def setup_basic_window_attr(self) -> None:
212        """
213        Sets up fundamental window properties.
214
215        Configures the window title, binds Ctrl+W and the close button ('X')
216        to hide the window, sets grid column/row weights for resizing behavior,
217        and applies the application icon using `GUIUtils`.
218        """
219
220        self.title("Valve Testing")
221        self.bind("<Control-w>", lambda event: self.withdraw())
222        self.protocol("WM_DELETE_WINDOW", lambda: self.withdraw())
223
224        self.grid_columnconfigure(0, weight=1)
225
226        for i in range(5):
227            self.grid_rowconfigure(i, weight=1)
228        self.grid_rowconfigure(0, weight=0)
229
230        window_icon_path = GUIUtils.get_window_icon_path()
231        GUIUtils.set_program_icon(self, icon_path=window_icon_path)

Sets up fundamental window properties.

Configures the window title, binds Ctrl+W and the close button ('X') to hide the window, sets grid column/row weights for resizing behavior, and applies the application icon using GUIUtils.

def show(self) -> None:
233    def show(self) -> None:
234        """
235        Makes the ValveTestWindow visible.
236
237        Calls `deiconify()` to show the window if it was previously hidden (withdrawn).
238        """
239        self.deiconify()

Makes the ValveTestWindow visible.

Calls deiconify() to show the window if it was previously hidden (withdrawn).

def switch_window_mode(self) -> None:
241    def switch_window_mode(self) -> None:
242        """
243        Toggles the window's operational mode between Testing and Priming.
244
245        Updates the window title and mode button text/color. Hides or shows
246        mode-specific UI elements (desired volume input, test results table).
247        Changes the command associated with the main start/stop button.
248        Prevents mode switching if a test or prime sequence is currently running to avoid Arduino
249        Communications errors. Resizes and centers the window after GUI changes.
250        """
251
252        if self.window_mode == WindowMode.TESTING:
253            if self.test_running:
254                # if the test is currently running we do not want to switch modes. Stop testing to switch modes
255                GUIUtils.display_error(
256                    "ACTION PROHIBITED",
257                    "You cannot switch modes while a test is running. If you'd like to switch modes, please abort the test and try again.",
258                )
259                return
260            self.title("Valve Priming")
261
262            self.window_mode = WindowMode.PRIMING
263
264            self.mode_button.configure(text="Switch to Testing Mode", bg="DeepSkyBlue3")
265
266            # orig. row 1
267            self.dispensed_vol_frame.grid_forget()
268            # orig. row 3
269            self.valve_table_frame.grid_forget()
270
271            # start testing button -> start priming button / command
272
273            if isinstance(self.valve_test_button, tk.Button):
274                self.valve_test_button.configure(
275                    text="Start Priming",
276                    bg="coral",
277                    command=lambda: self.send_schedules(),
278                )
279
280        else:
281            if self.prime_running:
282                # if the test is currently running we do not want to switch modes. Stop testing to switch modes
283                GUIUtils.display_error(
284                    "ACTION PROHIBITED",
285                    "You cannot switch modes while a valve prime operation is running. If you'd like to switch modes, please abort the prime and try again.",
286                )
287                return
288
289            self.title("Valve Testing")
290
291            self.window_mode = WindowMode.TESTING
292
293            self.mode_button.configure(text="Switch to Priming Mode", bg="coral")
294
295            self.valve_table_frame.grid(
296                row=3, column=0, sticky="nsew", pady=10, padx=10
297            )
298            self.dispensed_vol_frame.grid(row=1, column=0, padx=5, sticky="nsew")
299
300            if isinstance(self.valve_test_button, tk.Button):
301                self.valve_test_button.configure(
302                    text="Start Testing",
303                    bg="green",
304                    command=lambda: self.start_testing_toggle(),
305                )
306
307        # resize the window to fit current content
308        self.update_idletasks()
309        GUIUtils.center_window(self)

Toggles the window's operational mode between Testing and Priming.

Updates the window title and mode button text/color. Hides or shows mode-specific UI elements (desired volume input, test results table). Changes the command associated with the main start/stop button. Prevents mode switching if a test or prime sequence is currently running to avoid Arduino Communications errors. Resizes and centers the window after GUI changes.

def create_interface(self) -> None:
311    def create_interface(self) -> None:
312        """
313        Constructs and arranges all GUI elements within the window.
314
315        Creates frames for structure, input fields (desired volume, actuations),
316        the mode switch button, the valve selection buttons, the test results table,
317        and the main start/abort/prime button. Uses `GUIUtils` for
318        widget creation where possible. Calls helper methods `create_valve_test_table`
319        and `create_buttons`. Centers the window after creation.
320        """
321
322        self.mode_button = GUIUtils.create_button(
323            self,
324            "Switch to Priming Mode",
325            command=lambda: self.switch_window_mode(),
326            bg="coral",
327            row=0,
328            column=0,
329        )[1]
330
331        # create a new frame to contain the desired dispensed volume label and frame
332        self.dispensed_vol_frame = tk.Frame(self)
333        self.dispensed_vol_frame.grid(row=1, column=0, padx=5, sticky="nsew")
334
335        self.dispensed_vol_frame.grid_columnconfigure(0, weight=1)
336        self.dispensed_vol_frame.grid_rowconfigure(0, weight=1)
337        self.dispensed_vol_frame.grid_rowconfigure(1, weight=1)
338
339        label = tk.Label(
340            self.dispensed_vol_frame,
341            text="Desired Dispensed Volume in Microliters (ul)",
342            bg="light blue",
343            font=("Helvetica", 20),
344            highlightthickness=1,
345            highlightbackground="dark blue",
346            height=2,
347            width=50,
348        )
349        label.grid(row=0, pady=5)
350
351        entry = tk.Entry(
352            self.dispensed_vol_frame,
353            textvariable=self.desired_volume,
354            font=("Helvetica", 24),
355            highlightthickness=1,
356            highlightbackground="black",
357        )
358        entry.grid(row=1, sticky="nsew", pady=5, ipady=14)
359
360        # create another new frame to contain the amount of times each
361        # valve should actuate label and frame
362        frame = tk.Frame(self)
363        frame.grid(row=2, column=0, padx=5, sticky="nsew")
364
365        frame.grid_columnconfigure(0, weight=1)
366        frame.grid_rowconfigure(0, weight=1)
367        frame.grid_rowconfigure(1, weight=1)
368
369        label = tk.Label(
370            frame,
371            text="How Many Times Should the Valve Actuate?",
372            bg="light blue",
373            font=("Helvetica", 20),
374            highlightthickness=1,
375            highlightbackground="dark blue",
376            height=2,
377            width=50,
378        )
379        label.grid(row=0, pady=5)
380
381        entry = tk.Entry(
382            frame,
383            textvariable=self.actuations,
384            font=("Helvetica", 24),
385            highlightthickness=1,
386            highlightbackground="black",
387        )
388        entry.grid(row=1, sticky="nsew", pady=5, ipady=12)
389
390        ### ROW 3 ###
391        self.create_valve_test_table()
392
393        # setup frames for valve buttons
394        self.valve_buttons_frame = tk.Frame(
395            self, highlightbackground="black", highlightthickness=1
396        )
397        ### ROW 4 ###
398        self.valve_buttons_frame.grid(row=4, column=0, pady=10, padx=10, sticky="nsew")
399        self.valve_buttons_frame.grid_columnconfigure(0, weight=1)
400        self.valve_buttons_frame.grid_rowconfigure(0, weight=1)
401
402        self.side_one_valves_frame = tk.Frame(
403            self.valve_buttons_frame, highlightbackground="black", highlightthickness=1
404        )
405        self.side_one_valves_frame.grid(row=0, column=0, pady=10, padx=10, sticky="w")
406        self.side_one_valves_frame.grid_columnconfigure(0, weight=1)
407        for i in range(4):
408            self.side_one_valves_frame.grid_rowconfigure(i, weight=1)
409
410        self.side_two_valves_frame = tk.Frame(
411            self.valve_buttons_frame, highlightbackground="black", highlightthickness=1
412        )
413        self.side_two_valves_frame.grid(row=0, column=1, pady=10, padx=10, sticky="e")
414        self.side_two_valves_frame.grid_columnconfigure(0, weight=1)
415        for i in range(4):
416            self.side_two_valves_frame.grid_rowconfigure(i, weight=1)
417
418        ### ROW 5 ###
419        self.create_buttons()
420
421        self.update_idletasks()
422        GUIUtils.center_window(self)

Constructs and arranges all GUI elements within the window.

Creates frames for structure, input fields (desired volume, actuations), the mode switch button, the valve selection buttons, the test results table, and the main start/abort/prime button. Uses GUIUtils for widget creation where possible. Calls helper methods create_valve_test_table and create_buttons. Centers the window after creation.

def start_testing_toggle(self) -> None:
424    def start_testing_toggle(self) -> None:
425        """
426        Handles the action of the main button when in Testing mode.
427
428        If a test is running, it calls `abort_test()` and updates the button appearance.
429        If no test is running, it calls `send_schedules()` to initiate a new test.
430        """
431
432        if self.test_running:
433            if isinstance(self.valve_test_button, tk.Button):
434                self.valve_test_button.configure(text="Start Testing", bg="green")
435            self.abort_test()
436        else:
437            self.send_schedules()

Handles the action of the main button when in Testing mode.

If a test is running, it calls abort_test() and updates the button appearance. If no test is running, it calls send_schedules() to initiate a new test.

def create_buttons(self) -> None:
439    def create_buttons(self) -> None:
440        """
441        Creates and places the individual valve selection buttons and the main action button.
442
443        Generates buttons for each valve (1 to TOTAL_VALVES), arranging them
444        into side-one and side-two frames using `GUIUtils.create_button`.
445        Assigns the `toggle_valve_button` command to each valve button.
446
447        Creates the main 'Start Testing' / 'Start Priming' / 'Abort' button.
448        """
449        for i in range(VALVES_PER_SIDE):
450            # GUIUtils.create_button returns both the buttons frame and button object in a tuple. using [1] on the
451            # return item yields the button object.
452
453            # return the side one button and put it on the side one 'side'
454            # of the list (0-4)
455            self.valve_buttons[i] = GUIUtils.create_button(
456                self.side_one_valves_frame,
457                f"Valve {i + 1}",
458                lambda i=i: self.toggle_valve_button(self.valve_buttons[i], i),
459                "light blue",
460                i,
461                0,
462            )[1]
463
464            # return the side two button and put it on the side two 'side'
465            # of the list (5-8)
466            self.valve_buttons[i + (VALVES_PER_SIDE)] = GUIUtils.create_button(
467                self.side_two_valves_frame,
468                f"Valve {i + (VALVES_PER_SIDE) + 1}",
469                lambda i=i: self.toggle_valve_button(
470                    self.valve_buttons[i + (VALVES_PER_SIDE)], i + (VALVES_PER_SIDE)
471                ),
472                "light blue",
473                i,
474                0,
475            )[1]
476
477        self.valve_test_button = GUIUtils.create_button(
478            self, "Start Testing", lambda: self.start_testing_toggle(), "green", 5, 0
479        )[1]

Creates and places the individual valve selection buttons and the main action button.

Generates buttons for each valve (1 to TOTAL_VALVES), arranging them into side-one and side-two frames using GUIUtils.create_button. Assigns the toggle_valve_button command to each valve button.

Creates the main 'Start Testing' / 'Start Priming' / 'Abort' button.

def create_valve_test_table(self) -> None:
481    def create_valve_test_table(self) -> None:
482        """
483        Creates, configures, and populates the initial state of the ttk.Treeview table.
484
485        Sets up the table columns ("Valve", "Amount Dispensed...", "Testing Opening Time...").
486        Adds a button above the table to open the `ManualTimeAdjustment` window.
487
488        Loads the currently selected valve durations from `models.arduino_data.ArduinoData`.
489        Inserts initial rows for all possible valves, marking them as "Test Not Complete"
490        and displaying their current duration. Stores row IDs in `self.table_entries` for later access.
491        """
492
493        # this function creates and labels the testing window table that displays the information recieved from the arduino reguarding the results of the tes
494        self.valve_table_frame = tk.Frame(
495            self, highlightbackground="black", highlightthickness=1
496        )
497        self.valve_table_frame.grid(row=3, column=0, sticky="nsew", pady=10, padx=10)
498        # tell the frame to fill the available width
499        self.valve_table_frame.grid_columnconfigure(0, weight=1)
500
501        self.valve_table = ttk.Treeview(
502            self.valve_table_frame, show="headings", height=12
503        )
504
505        headings = [
506            "Valve",
507            "Amount Dispensed Per Lick (ul)",
508            "Testing Opening Time of (ms)",
509        ]
510
511        self.valve_table["columns"] = headings
512
513        self.valve_table.grid(row=1, sticky="ew")
514
515        button = tk.Button(
516            self.valve_table_frame,
517            text="Manual Valve Duration Override",
518            command=lambda: self.manual_adjust_window.show(),
519            bg="light blue",
520            highlightbackground="black",
521            highlightthickness=1,
522        )
523
524        button.grid(row=0, sticky="e")
525
526        # Configure the columns
527        for col in self.valve_table["columns"]:
528            self.valve_table.heading(col, text=col)
529
530        arduino_data = self.arduino_controller.arduino_data
531
532        side_one_durations, side_two_durations, date_used = (
533            arduino_data.load_durations()
534        )
535
536        # insert default entries for each total valve until user selects how many
537        # they want to test
538        for i in range(VALVES_PER_SIDE):
539            self.table_entries[i] = self.valve_table.insert(
540                "",
541                i,
542                values=(
543                    f"{i + 1}",
544                    "Test Not Complete",
545                    f"{side_one_durations[i]} ms",
546                ),
547            )
548            self.table_entries[i + 4] = self.valve_table.insert(
549                "",
550                i + VALVES_PER_SIDE,
551                values=(
552                    f"{i + VALVES_PER_SIDE + 1}",
553                    "Test Not Complete",
554                    f"{side_two_durations[i]} ms",
555                ),
556            )

Creates, configures, and populates the initial state of the ttk.Treeview table.

Sets up the table columns ("Valve", "Amount Dispensed...", "Testing Opening Time..."). Adds a button above the table to open the ManualTimeAdjustment window.

Loads the currently selected valve durations from models.arduino_data.ArduinoData. Inserts initial rows for all possible valves, marking them as "Test Not Complete" and displaying their current duration. Stores row IDs in self.table_entries for later access.

def update_table_entry_test_status(self, valve: int, l_per_lick: float) -> None:
558    def update_table_entry_test_status(self, valve: int, l_per_lick: float) -> None:
559        """
560        Updates the 'Amount Dispensed Per Lick (ul)' column for a specific valve row.
561
562        Parameters
563        ----------
564        - **valve** (*int*): The 1-indexed valve number whose row needs updating.
565        - **l_per_lick** (*float*): The calculated volume dispensed per lick in milliliters (mL).
566        """
567
568        self.valve_table.set(
569            self.table_entries[valve - 1], column=1, value=f"{l_per_lick * 1000} uL"
570        )

Updates the 'Amount Dispensed Per Lick (ul)' column for a specific valve row.

Parameters

  • valve (int): The 1-indexed valve number whose row needs updating.
  • l_per_lick (float): The calculated volume dispensed per lick in milliliters (mL).
def update_table_entries(self) -> None:
572    def update_table_entries(self) -> None:
573        """
574        Refreshes the entire valve test table based on current selections and durations.
575
576        Hides rows for unselected valves and shows rows for selected valves.
577        Reloads the latest durations from `ArduinoData` and updates the
578        'Testing Opening Time' column for all visible rows. Resets the
579        'Amount Dispensed' column to "Test Not Complete" for visible rows.
580        """
581        side_one, side_two, _ = self.arduino_data.load_durations()
582        # np array where the entry is not zero but the valve number itself
583        selected_valves = self.valve_selections[self.valve_selections != 0]
584
585        # for all valve numbers, if that number is in selected_valves -1 (the entire array
586        # decremented by one) place it in the table and ensure its duration is up-to-date
587        for i in range(TOTAL_VALVES):
588            # if the iter number is also contained in the selected valves arr
589            if i in (selected_valves - 1):
590                # check if it's side two or side one
591                if i >= VALVES_PER_SIDE:
592                    duration = side_two[i - VALVES_PER_SIDE]
593                else:
594                    duration = side_one[i]
595                self.valve_table.item(
596                    self.table_entries[i],
597                    values=(
598                        f"{i + 1}",
599                        "Test Not Complete",
600                        f"{duration} ms",
601                    ),
602                )
603                self.valve_table.reattach(self.table_entries[i], "", i)
604            else:
605                self.valve_table.detach(self.table_entries[i])
606
607        logging.info("Valve test table updated.")

Refreshes the entire valve test table based on current selections and durations.

Hides rows for unselected valves and shows rows for selected valves. Reloads the latest durations from ArduinoData and updates the 'Testing Opening Time' column for all visible rows. Resets the 'Amount Dispensed' column to "Test Not Complete" for visible rows.

def toggle_valve_button(self, button: tkinter.Button, valve_num: int) -> None:
609    def toggle_valve_button(self, button: tk.Button, valve_num: int) -> None:
610        """
611        Handles the click event for a valve selection button.
612
613        Toggles the button's visual state (raised/sunken). Updates the
614        corresponding entry in the `self.valve_selections` numpy array (0 for
615        deselected, valve_number + 1 for selected). Calls `update_table_entries`
616        to refresh the table display. Filling arrays this way allows us to tell
617        the arduino exactly which valves should be toggled (1, 4, 7) as long as
618        their value in the array is not 0.
619
620        Parameters
621        ----------
622        - **button** (*tk.Button*): The specific valve button widget that was clicked.
623        - **valve_num** (*int*): The 0-indexed number of the valve corresponding to the button.
624        """
625        # Attempt to get the value to ensure it's a valid float as expected
626        # If successful, call the function to update/create the valve test table
627        if self.valve_selections[valve_num] == 0:
628            button.configure(relief="sunken")
629            self.valve_selections[valve_num] = valve_num + 1
630
631        else:
632            button.configure(relief="raised")
633            self.valve_selections[valve_num] = 0
634        self.update_table_entries()

Handles the click event for a valve selection button.

Toggles the button's visual state (raised/sunken). Updates the corresponding entry in the self.valve_selections numpy array (0 for deselected, valve_number + 1 for selected). Calls update_table_entries to refresh the table display. Filling arrays this way allows us to tell the arduino exactly which valves should be toggled (1, 4, 7) as long as their value in the array is not 0.

Parameters

  • button (tk.Button): The specific valve button widget that was clicked.
  • valve_num (int): The 0-indexed number of the valve corresponding to the button.
def verify_variables(self, original_data: bytes) -> bool:
636    def verify_variables(self, original_data: bytes) -> bool:
637        """
638        Verifies that the Arduino correctly received the test/prime parameters.
639
640        Waits for and reads back the schedule lengths and actuation count echoed
641        by the Arduino. Compares the received bytes with the originally sent data.
642
643        Parameters
644        ----------
645        - **original_data** (*bytes*): The byte string containing schedule lengths and actuation count originally sent to the Arduino.
646
647        Returns
648        -------
649        - *bool*: `True` if the received data matches the original data, `False` otherwise (logs error and shows message box on mismatch).
650        """
651        # wait until all data is on the wire ready to be read (4 bytes),
652        # then read it all in quick succession
653        if self.arduino_controller.arduino is None:
654            msg = "====INCORRECT SCHEDULE DETECTED, TRY AGAIN===="
655            GUIUtils.display_error("TRANSMISSION ERROR", msg)
656            logger.error(msg)
657            return False
658
659        while self.arduino_controller.arduino.in_waiting < 4:
660            pass
661
662        ver_len_sched_sd_one = self.arduino_controller.arduino.read()
663        ver_len_sched_sd_two = self.arduino_controller.arduino.read()
664
665        ver_valve_act_time = self.arduino_controller.arduino.read(2)
666
667        verification_data = (
668            ver_len_sched_sd_one + ver_len_sched_sd_two + ver_valve_act_time
669        )
670        if verification_data == original_data:
671            logger.info("====VERIFIED TEST VARIABLES====")
672            return True
673        else:
674            msg = "====INCORRECT VARIABLES DETECTED, TRY AGAIN===="
675            GUIUtils.display_error("TRANSMISSION ERROR", msg)
676            logger.error(msg)
677            return False

Verifies that the Arduino correctly received the test/prime parameters.

Waits for and reads back the schedule lengths and actuation count echoed by the Arduino. Compares the received bytes with the originally sent data.

Parameters

  • original_data (bytes): The byte string containing schedule lengths and actuation count originally sent to the Arduino.

Returns

  • bool: True if the received data matches the original data, False otherwise (logs error and shows message box on mismatch).
def verify_schedule( self, len_sched: int, sent_schedule: numpy.ndarray[tuple[int, ...], numpy.dtype[numpy.int8]]) -> bool:
679    def verify_schedule(
680        self, len_sched: int, sent_schedule: npt.NDArray[np.int8]
681    ) -> bool:
682        """
683        Verifies that the Arduino correctly received the valve schedule.
684
685        Waits for and reads back the valve numbers (0-indexed) echoed byte-by-byte
686        by the Arduino. Compares the reconstructed numpy array with the schedule
687        that was originally sent.
688
689        Parameters
690        ----------
691        - **len_sched** (*int*): The total number of valves in the schedule being sent.
692        - **sent_schedule** (*npt.NDArray[np.int8]*): The numpy array (0-indexed valve numbers) representing the schedule originally sent.
693
694        Returns
695        -------
696        - *bool*: `True` if the received schedule matches the sent schedule, `False` otherwise (logs error and shows message box on mismatch).
697        """
698        # np array used for verification later
699        received_sched = np.zeros((len_sched,), dtype=np.int8)
700
701        if self.arduino_controller.arduino is None:
702            msg = "====INCORRECT SCHEDULE DETECTED, TRY AGAIN===="
703            GUIUtils.display_error("TRANSMISSION ERROR", msg)
704            logger.error(msg)
705            return False
706
707        while self.arduino_controller.arduino.in_waiting < len_sched:
708            pass
709
710        for i in range(len_sched):
711            received_sched[i] = int.from_bytes(
712                self.arduino_controller.arduino.read(), byteorder="little", signed=False
713            )
714
715        if np.array_equal(received_sched, sent_schedule):
716            logger.info(f"===TESTING SCHEDULE VERIFIED as ==> {received_sched}===")
717            logger.info("====================BEGIN TESTING NOW====================")
718            return True
719        else:
720            msg = "====INCORRECT SCHEDULE DETECTED, TRY AGAIN===="
721            GUIUtils.display_error("TRANSMISSION ERROR", msg)
722            logger.error(msg)
723            return False

Verifies that the Arduino correctly received the valve schedule.

Waits for and reads back the valve numbers (0-indexed) echoed byte-by-byte by the Arduino. Compares the reconstructed numpy array with the schedule that was originally sent.

Parameters

  • len_sched (int): The total number of valves in the schedule being sent.
  • sent_schedule (npt.NDArray[np.int8]): The numpy array (0-indexed valve numbers) representing the schedule originally sent.

Returns

  • bool: True if the received schedule matches the sent schedule, False otherwise (logs error and shows message box on mismatch).
def send_schedules(self) -> None:
725    def send_schedules(self) -> None:
726        """
727        Performs the process of sending test or prime configuration to the Arduino.
728
729        Determines selected valves and schedule lengths for each side. Sends current
730        valve durations to Arduino. Then sends the appropriate command ('TEST VOL' or 'PRIME VALVES').
731
732        Sends test parameters (schedule lengths, actuations) and verifies them using `verify_variables`.
733        Sends the actual valve schedule (0-indexed) and verifies it using `verify_schedule`.
734
735        User is then prompted to confirm before proceeding.
736        If confirmed, initiates the test/prime sequence on the Arduino
737        and starts the listener thread (`run_test`) if Testing, or sends start command if Priming.
738        Updates button states accordingly.
739        """
740        len_sched_one = np.count_nonzero(self.valve_selections[:VALVES_PER_SIDE])
741        len_sched_two = np.count_nonzero(self.valve_selections[VALVES_PER_SIDE:])
742
743        len_sched_one = np.int8(len_sched_one)
744        len_sched_two = np.int8(len_sched_two)
745        len_sched = len_sched_one + len_sched_two
746
747        # because self.actuations is a IntVar, we use .get() method to get its value
748        valve_acuations = np.int16(self.actuations.get())
749
750        # schedule is TOTAL_VALVES long to accomodate testing of up to all valves at one time.
751        # if we are not testing them all, filter out the zero entries (valves we are not testing)
752        schedule = self.valve_selections[self.valve_selections != 0]
753
754        # decrement each element in the array by one to move to zero-indexing. We moved to 1
755        # indexing becuase using zero indexing does not work with the method of toggling valves
756        # defined above
757        schedule -= 1
758
759        self.side_one_tests = schedule[:len_sched_one]
760        self.side_two_tests = schedule[len_sched_one:]
761
762        len_sched_one = len_sched_one
763        len_sched_two = len_sched_two
764        valve_acuations = valve_acuations
765
766        self.arduino_controller.send_valve_durations()
767        if self.window_mode == WindowMode.TESTING:
768            ###====SENDING TEST COMMAND====###
769            command = "TEST VOL\n".encode("utf-8")
770            self.arduino_controller.send_command(command)
771
772        elif self.window_mode == WindowMode.PRIMING:
773            self.prime_running = True
774
775            command = "PRIME VALVES\n".encode("utf-8")
776            self.arduino_controller.send_command(command)
777
778        ###====SENDING TEST VARIABLES (len side one, two, num_actuations per test)====###
779        data_bytes = (
780            len_sched_one.tobytes()
781            + len_sched_two.tobytes()
782            + valve_acuations.tobytes()
783        )
784        self.arduino_controller.send_command(data_bytes)
785
786        # verify the data
787        if not self.verify_variables(data_bytes):
788            return
789
790        ###====SENDING SCHEDULE====###
791        sched_data_bytes = schedule.tobytes()
792        self.arduino_controller.send_command(sched_data_bytes)
793
794        if not self.verify_schedule(len_sched, schedule):
795            return
796
797        schedule += 1
798
799        valves = ",".join(str(valve) for valve in schedule)
800        if self.window_mode == WindowMode.TESTING:
801            ####### ARDUINO HALTS HERE UNTIL THE GO-AHEAD IS GIVEN #######
802            # inc each sched element by one so that it is 1-indexes and matches the valve labels
803            test_confirmed = GUIUtils.askyesno(
804                "CONFIRM THE TEST SCHEDULE",
805                f"Valves {valves}, will be tested. Review the test table to confirm schedule and timings. Each valve will be actuated {valve_acuations} times. Ok to begin?",
806            )
807
808            ### include section to manually modify timings
809            if test_confirmed:
810                self.bind("<<event0>>", self.testing_complete)
811                self.bind("<<event1>>", self.take_input)
812                threading.Thread(target=self.run_test).start()
813            else:
814                self.abort_test()
815        elif self.window_mode == WindowMode.PRIMING:
816            priming_confirmed = GUIUtils.askyesno(
817                "CONFIRM THE ACTION",
818                f"Valves {valves}, will be primed (opened and closed) {valve_acuations} times. Ok to begin?",
819            )
820            if priming_confirmed:
821                # in case of prime, we send 0 to mean continue or start
822                # and we send a 1 at any point to abort or cancel the prime
823                start = np.int8(0).tobytes()
824                self.arduino_controller.send_command(command=start)
825
826                if isinstance(self.valve_test_button, tk.Button):
827                    self.valve_test_button.configure(
828                        text="STOP Priming",
829                        bg="gold",
830                        command=lambda: self.stop_priming(),
831                    )

Performs the process of sending test or prime configuration to the Arduino.

Determines selected valves and schedule lengths for each side. Sends current valve durations to Arduino. Then sends the appropriate command ('TEST VOL' or 'PRIME VALVES').

Sends test parameters (schedule lengths, actuations) and verifies them using verify_variables. Sends the actual valve schedule (0-indexed) and verifies it using verify_schedule.

User is then prompted to confirm before proceeding. If confirmed, initiates the test/prime sequence on the Arduino and starts the listener thread (run_test) if Testing, or sends start command if Priming. Updates button states accordingly.

def stop_priming(self) -> None:
833    def stop_priming(self) -> None:
834        """
835        Sends the stop command to the Arduino during a priming sequence.
836
837        Sets the `prime_running` flag to False. Updates the prime button state.
838        Sends a byte representation of 1 (`np.int8(1).tobytes()`) to signal abort to the Arduino.
839        """
840        self.prime_running = False
841
842        if isinstance(self.valve_test_button, tk.Button):
843            self.valve_test_button.configure(
844                text="Start Priming",
845                bg="coral",
846                command=lambda: self.send_schedules(),
847            )
848
849        stop = np.int8(1).tobytes()
850        self.arduino_controller.send_command(command=stop)

Sends the stop command to the Arduino during a priming sequence.

Sets the prime_running flag to False. Updates the prime button state. Sends a byte representation of 1 (np.int8(1).tobytes()) to signal abort to the Arduino.

def take_input( self, event: tkinter.Event | None, pair_num_override: int | None = None) -> None:
852    def take_input(
853        self, event: tk.Event | None, pair_num_override: int | None = None
854    ) -> None:
855        """
856        Prompts the user to enter the dispensed volume for a completed test pair.
857
858        Determines which valve(s) were just tested based on the pair number received
859        from the Arduino (via the event state or override). Uses `simpledialog.askfloat`
860        to get the dispensed volume (in mL) for each valve in the pair. Stores the
861        results in `self.ml_dispensed`.
862
863        Sends a byte back to the Arduino to instruct if further pairs will be tested or if this is the final iteration,
864        (1 if via event, 0 if via override/final pair).
865        Handles potential abort if the user cancels the dialog window.
866
867        Parameters
868        ----------
869        - **event** (*tk.Event | None*): Uses custom thread-safe event (`<<event1>>`) generated by `run_test`, carrying the pair number in `event.state`
870        attribute. This is None if called directly by `testing_complete`, which indicated procedure is now finished.
871        - **pair_num_override** (*int | None, optional*): Allows manually specifying the pair number, used when called from `testing_complete` for the
872        final pair. Defaults to None.
873        """
874        if event is not None:
875            pair_number = event.state
876        else:
877            pair_number = pair_num_override
878
879        # decide how many valves were tested last. if 0 we will not arrive
880        # here so we need not test for this.
881        if pair_number < len(self.side_one_tests) and pair_number < len(
882            self.side_two_tests
883        ):
884            valves_tested = [
885                self.side_one_tests[pair_number],
886                self.side_two_tests[pair_number],
887            ]
888        elif pair_number < len(self.side_one_tests):
889            valves_tested = [
890                self.side_one_tests[pair_number],
891            ]
892        else:
893            valves_tested = [
894                self.side_two_tests[pair_number],
895            ]
896
897        # for each test that we ran store the amount dispensed or abort
898        for valve in valves_tested:
899            response = simpledialog.askfloat(
900                "INPUT AMOUNT DISPENSED",
901                f"Please input the amount of liquid dispensed for the test of valve {valve}",
902            )
903            if response is None:
904                self.abort_test()
905            else:
906                self.ml_dispensed.append((valve, response))
907        if pair_num_override is not None:
908            self.arduino_controller.send_command(np.int8(0).tobytes())
909        else:
910            self.arduino_controller.send_command(np.int8(1).tobytes())

Prompts the user to enter the dispensed volume for a completed test pair.

Determines which valve(s) were just tested based on the pair number received from the Arduino (via the event state or override). Uses simpledialog.askfloat to get the dispensed volume (in mL) for each valve in the pair. Stores the results in self.ml_dispensed.

Sends a byte back to the Arduino to instruct if further pairs will be tested or if this is the final iteration, (1 if via event, 0 if via override/final pair). Handles potential abort if the user cancels the dialog window.

Parameters

  • event (tk.Event | None): Uses custom thread-safe event (<<event1>>) generated by run_test, carrying the pair number in event.state attribute. This is None if called directly by testing_complete, which indicated procedure is now finished.
  • pair_num_override (int | None, optional): Allows manually specifying the pair number, used when called from testing_complete for the final pair. Defaults to None.
def run_test(self) -> None:
912    def run_test(self) -> None:
913        """
914        Listens for messages from the Arduino during an active test sequence (runs in a background thread).
915
916        Sends the initial 'start test' command byte to the Arduino. Enters a loop
917        that continues as long as `self.test_running` is True and `self.stop_event`
918        is not set. Checks for incoming data from the Arduino. Reads bytes indicating
919        if more tests remain and the number of the just-completed pair. Generates
920        custom Tkinter events (`<<event1>>` for input dispensed liquid prompt, `<<event0>>` for test completion)
921        to communicate back to the main GUI thread safely. Updates the main button state to 'ABORT TESTING'.
922        """
923        if self.stop_event.is_set():
924            self.stop_event.clear()
925
926        if isinstance(self.valve_test_button, tk.Button):
927            self.valve_test_button.configure(text="ABORT TESTING", bg="red")
928
929        self.test_running = True
930        ###====SENDING BEGIN TEST COMMAND====###
931        command = np.int8(1).tobytes()
932        self.arduino_controller.send_command(command)
933
934        while self.test_running:
935            # if stop event, shut down the thread
936            if self.stop_event.is_set():
937                break
938            if self.arduino_controller.arduino.in_waiting > 0:
939                remaining_tests = self.arduino_controller.arduino.read()
940                pair_number = int.from_bytes(
941                    self.arduino_controller.arduino.read(),
942                    byteorder="little",
943                    signed=False,
944                )
945
946                if remaining_tests == np.int8(1).tobytes():
947                    self.event_generate("<<event1>>", when="tail", state=pair_number)
948                if remaining_tests == np.int8(0).tobytes():
949                    self.event_generate("<<event0>>", when="tail", state=pair_number)

Listens for messages from the Arduino during an active test sequence (runs in a background thread).

Sends the initial 'start test' command byte to the Arduino. Enters a loop that continues as long as self.test_running is True and self.stop_event is not set. Checks for incoming data from the Arduino. Reads bytes indicating if more tests remain and the number of the just-completed pair. Generates custom Tkinter events (<<event1>> for input dispensed liquid prompt, <<event0>> for test completion) to communicate back to the main GUI thread safely. Updates the main button state to 'ABORT TESTING'.

def abort_test(self) -> None:
951    def abort_test(self) -> None:
952        """
953        Aborts an ongoing test sequence.
954
955        Sets the `self.stop_event` to signal the `run_test` listener thread to exit.
956        Sets `self.test_running` to False. Sends the abort *testing* command byte
957        (`np.int8(0).tobytes()`) to the Arduino. Resets the main button state to 'Start Testing'.
958        """
959        # if test is aborted, stop the test listener thread
960        if isinstance(self.valve_test_button, tk.Button):
961            self.valve_test_button.configure(text="Start Testing", bg="green")
962
963        self.stop_event.set()
964        self.test_running = False
965        ###====SENDING ABORT TEST COMMAND====###
966        command = np.int8(0).tobytes()
967        self.arduino_controller.send_command(command)

Aborts an ongoing test sequence.

Sets the self.stop_event to signal the run_test listener thread to exit. Sets self.test_running to False. Sends the abort testing command byte (np.int8(0).tobytes()) to the Arduino. Resets the main button state to 'Start Testing'.

def testing_complete(self, event: tkinter.Event) -> None:
969    def testing_complete(self, event: tk.Event) -> None:
970        """
971        Handles the final steps after the Arduino signals test completion.
972
973        Calls `take_input` one last time for the final test pair (using `pair_num_override`).
974
975        Resets the main button state and `self.test_running` flag. Sets the
976        `self.stop_event` to ensure the listener thread terminates cleanly.
977        Calls `auto_update_durations` to process the collected data and suggest timing changes.
978
979        Parameters
980        ----------
981        - **event** (*tk.Event*): The custom event (`<<event0>>`) generated by `run_test`, carrying the final pair number in `event.state`.
982        """
983        # take input from last pair/valve
984        self.take_input(event=None, pair_num_override=event.state)
985        # reconfigure the testing button and testing state
986        if isinstance(self.valve_test_button, tk.Button):
987            self.valve_test_button.configure(text="Start Testing", bg="green")
988
989        self.test_running = False
990        # stop the arduino testing listener thread
991        self.stop_event.set()
992
993        # update valves and such
994        self.auto_update_durations()

Handles the final steps after the Arduino signals test completion.

Calls take_input one last time for the final test pair (using pair_num_override).

Resets the main button state and self.test_running flag. Sets the self.stop_event to ensure the listener thread terminates cleanly. Calls auto_update_durations to process the collected data and suggest timing changes.

Parameters

  • event (tk.Event): The custom event (<<event0>>) generated by run_test, carrying the final pair number in event.state.
def auto_update_durations(self):
 996    def auto_update_durations(self):
 997        """
 998        This function is similar to the `views.valve_testing.manual_time_adjustment_window.ManualTimeAdjustment.write_timing_changes` function, it
 999        calculates new valve durations based on test results and desired volume.
1000
1001        Loads the current 'selected' durations. Iterates through the collected
1002        `self.ml_dispensed` data. For each tested valve, it calculates the actual
1003        volume dispensed per actuation and compares it to the desired volume per actuation.
1004        It computes a new duration using a ratio (`new_duration = old_duration * (desired_vol / actual_vol)`).
1005
1006        Updates the corresponding duration in the local copies of the side_one/side_two arrays.
1007        Updates the table display for the valve using `update_table_entry_test_status`.
1008        Compiles a list of changes (`changed_durations`) and opens the `ValveChanges`
1009        confirmation window, passing the changes and the `confirm_valve_changes` callback.
1010        """
1011        # list of tuples of tuples ==> [(valve, (old_dur, new_dur))] sent to ChangesWindow instance, so that
1012        # user can confirm duration changes
1013        changed_durations = []
1014
1015        ## save current durations in the oldest valve duration archival location
1016        side_one, side_two, date_used = self.arduino_data.load_durations()
1017
1018        side_one_old = side_one.copy()
1019        side_two_old = side_two.copy()
1020
1021        for valve, dispensed_amt in self.ml_dispensed:
1022            logical_valve = None
1023            tested_duration = None
1024            side_durations = None
1025
1026            if valve > VALVES_PER_SIDE:
1027                logical_valve = (valve - 1) - VALVES_PER_SIDE
1028
1029                tested_duration = side_two[logical_valve]
1030                side_durations = side_two
1031            else:
1032                logical_valve = valve - 1
1033
1034                tested_duration = side_one[logical_valve]
1035                side_durations = side_one
1036
1037            # divide by 1000 to get to mL from ul
1038            desired_per_open_vol = self.desired_volume.get() / 1000
1039
1040            # divide by 1000 to get amount per opening, NOT changing units
1041            actual_per_open_vol = dispensed_amt / self.actuations.get()
1042
1043            self.update_table_entry_test_status(valve, actual_per_open_vol)
1044
1045            new_duration = round(
1046                tested_duration * (desired_per_open_vol / actual_per_open_vol)
1047            )
1048
1049            side_durations[logical_valve] = new_duration
1050
1051            changed_durations.append((valve, (tested_duration, new_duration)))
1052
1053        ValveChanges(
1054            changed_durations,
1055            lambda: self.confirm_valve_changes(
1056                side_one, side_two, side_one_old, side_two_old
1057            ),
1058        )

This function is similar to the views.valve_testing.manual_time_adjustment_window.ManualTimeAdjustment.write_timing_changes function, it calculates new valve durations based on test results and desired volume.

Loads the current 'selected' durations. Iterates through the collected self.ml_dispensed data. For each tested valve, it calculates the actual volume dispensed per actuation and compares it to the desired volume per actuation. It computes a new duration using a ratio (new_duration = old_duration * (desired_vol / actual_vol)).

Updates the corresponding duration in the local copies of the side_one/side_two arrays. Updates the table display for the valve using update_table_entry_test_status. Compiles a list of changes (changed_durations) and opens the ValveChanges confirmation window, passing the changes and the confirm_valve_changes callback.

def confirm_valve_changes( self, side_one: numpy.ndarray[tuple[int, ...], numpy.dtype[numpy.int32]], side_two: numpy.ndarray[tuple[int, ...], numpy.dtype[numpy.int32]], side_one_old: numpy.ndarray[tuple[int, ...], numpy.dtype[numpy.int32]], side_two_old: numpy.ndarray[tuple[int, ...], numpy.dtype[numpy.int32]]):
1060    def confirm_valve_changes(
1061        self,
1062        side_one: npt.NDArray[np.int32],
1063        side_two: npt.NDArray[np.int32],
1064        side_one_old: npt.NDArray[np.int32],
1065        side_two_old: npt.NDArray[np.int32],
1066    ):
1067        """
1068        Callback function executed when changes are confirmed in the ValveChanges window.
1069
1070        Instructs `self.arduino_data` to first save the `side_one_old` and `side_two_old`
1071        arrays to an archive slot, and then save the newly calculated `side_one` and `side_two`
1072        arrays as the 'selected' durations.
1073
1074        Parameters
1075        ----------
1076        - **side_one** (*npt.NDArray[np.int32]*): The numpy array containing the newly calculated durations for side one valves.
1077        - **side_two** (*npt.NDArray[np.int32]*): The numpy array containing the newly calculated durations for side two valves.
1078        - **side_one_old** (*npt.NDArray[np.int32]*): The numpy array containing the durations for side one valves *before* the test.
1079        - **side_two_old** (*npt.NDArray[np.int32]*): The numpy array containing the durations for side two valves *before* the test.
1080        """
1081        self.arduino_data.save_durations(side_one_old, side_two_old, "archive")
1082        self.arduino_data.save_durations(side_one, side_two, "selected")

Callback function executed when changes are confirmed in the ValveChanges window.

Instructs self.arduino_data to first save the side_one_old and side_two_old arrays to an archive slot, and then save the newly calculated side_one and side_two arrays as the 'selected' durations.

Parameters

  • side_one (npt.NDArray[np.int32]): The numpy array containing the newly calculated durations for side one valves.
  • side_two (npt.NDArray[np.int32]): The numpy array containing the newly calculated durations for side two valves.
  • side_one_old (npt.NDArray[np.int32]): The numpy array containing the durations for side one valves before the test.
  • side_two_old (npt.NDArray[np.int32]): The numpy array containing the durations for side two valves before the test.