experiment_process_data

This module defines the ExperimentProcessData class, which serves as the central data management hub for the Photologic-Experiment-Rig application.

It aggregates references to other data-holding classes (StimuliData, EventData, ArduinoData) and manages core experimental parameters like current trial, state time durations, and the overall experiment schedule DataFrame. It provides methods for generating the experimental schedule, updating parameters from the GUI, calculating runtime, and saving collected data.

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

Central data management class for the behavioral experiment.

This class acts as the primary container and manager for all data related to an ongoing experiment. It holds references to specialized data classes (EventData, StimuliData, ArduinoData) and maintains experiment-wide state variables, timing intervals, parameter entries (num_stimuli for example), and the generated program schedule (program_schedule_df). It performs the generation of the trial schedule and state intervals based on user inputs.

Attributes

  • start_time (float): Timestamp marking the absolute start of the program run.
  • trial_start_time (float): Timestamp marking the start of the current trial.
  • state_start_time (float): Timestamp marking the entry into the current FSM state.
  • current_trial_number (int): The 1-indexed number of the trial currently in progress or about to start.
  • event_data (EventData): Instance managing the DataFrame of recorded lick/motor events.
  • stimuli_data (StimuliData): Instance managing stimuli names and related information.
  • arduino_data (ArduinoData): Instance managing Arduino communication data (durations, schedule indices) and processing incoming Arduino messages.
  • ITI_intervals_final (npt.NDArray[np.int64] | None): Numpy array holding the calculated Inter-Trial Interval duration (ms) for each trial. None until schedule generated.
  • TTC_intervals_final (npt.NDArray[np.int64] | None): Numpy array holding the calculated Time-To-Contact duration (ms) for each trial. None until schedule generated.
  • sample_intervals_final (npt.NDArray[np.int64] | None): Numpy array holding the calculated Sample period duration (ms) for each trial. None until schedule generated.
  • TTC_LICK_THRESHOLD (int): The number of licks required during the TTC state to trigger an early transition to the SAMPLE state.
  • interval_vars (dict[str, int]): Dictionary storing base and random variation values (ms) for timing intervals (ITI, TTC, Sample), sourced from GUI entries.
  • exp_var_entries (dict[str, int]): Dictionary storing core experiment parameters (Num Trial Blocks, Num Stimuli), sourced from GUI entries. Num Trials is calculated based on these other values.
  • program_schedule_df (pd.DataFrame): Pandas DataFrame holding the generated trial-by-trial schedule, including stimuli presentation, calculated intervals, and placeholders for results. Initialized empty.

Methods

  • update_model(...) Updates internal parameter dictionaries (interval_vars, exp_var_entries) based on changes from GUI input fields (tkinter vars).
  • get_default_value(...) Retrieves the default or current value for a given parameter name from internal dictionaries, used to populate GUI fields initially.
  • generate_schedule() Performs the creation of the entire experimental schedule, including intervals and stimuli pairings. Returns status.
  • create_random_intervals() Calculates the final ITI, TTC, and Sample intervals for each trial based on base values and randomization ranges.
  • calculate_max_runtime() Estimates the maximum possible runtime based on the sum of all generated intervals.
  • create_trial_blocks() Determines the unique pairs of stimuli presented within a single block based on the number of stimuli.
  • generate_pairs(...) Generates the pseudo-randomized sequence of stimuli pairs across all trials and blocks.
  • build_frame(...) Constructs the program_schedule_df DataFrame using the generated stimuli sequences and intervals.
  • save_all_data() Initiates the process of saving the schedule and event log DataFrames to Excel files.
  • get_paired_index(...) Static method to determine the 0-indexed valve number on the opposite side corresponding to a given valve index on side one.
  • save_df_to_xlsx(...) Static method to handle saving a pandas DataFrame to an .xlsx file using a file dialog.
  • convert_seconds_to_minutes_seconds(...) Static method to convert a total number of seconds into minutes and remaining seconds.
ExperimentProcessData()
 90    def __init__(self):
 91        """
 92        Initializes the ExperimentProcessData central data hub.
 93
 94        Sets initial timestamps to 0.0, starts `current_trial_number` at 1.
 95        Instantiates `EventData`, `StimuliData`, and `ArduinoData` (passing self reference).
 96        Initializes interval arrays (`ITI_intervals_final`, etc.) to None.
 97        Sets the `TTC_LICK_THRESHOLD`.
 98        Defines default values for `interval_vars` and `exp_var_entries`.
 99        Initializes `program_schedule_df` as an empty DataFrame.
100        """
101
102        self.start_time: float = 0.0
103        self.trial_start_time: float = 0.0
104        self.state_start_time: float = 0.0
105
106        # this number is centralized here so that all state classes can access and update it easily without
107        # passing it through to each state every time a state change occurs
108        self.current_trial_number: int = 1
109
110        self.event_data = EventData()
111        self.stimuli_data = StimuliData()
112        self.arduino_data = ArduinoData(self)
113
114        self.ITI_intervals_final = None
115        self.TTC_intervals_final = None
116        self.sample_intervals_final = None
117
118        # this constant should be used in arduino_data to say how many licks moves to sample
119        self.TTC_LICK_THRESHOLD: int = 3
120
121        self.interval_vars: dict[str, int] = {
122            "ITI_var": 30000,
123            "TTC_var": 20000,
124            "sample_var": 15000,
125            "ITI_random_entry": 0,
126            "TTC_random_entry": 5000,
127            "sample_random_entry": 5000,
128        }
129        self.exp_var_entries: dict[str, int] = {
130            "Num Trial Blocks": 10,
131            "Num Stimuli": 4,
132            "Num Trials": 0,
133        }
134
135        # include an initial definition of program_schedule_df here as a blank df to avoid type errors and to
136        # make it clear that this is a class attriute
137        self.program_schedule_df = pd.DataFrame()

Initializes the ExperimentProcessData central data hub.

Sets initial timestamps to 0.0, starts current_trial_number at 1. Instantiates EventData, StimuliData, and ArduinoData (passing self reference). Initializes interval arrays (ITI_intervals_final, etc.) to None. Sets the TTC_LICK_THRESHOLD. Defines default values for interval_vars and exp_var_entries. Initializes program_schedule_df as an empty DataFrame.

start_time: float
trial_start_time: float
state_start_time: float
current_trial_number: int
event_data
stimuli_data
arduino_data
ITI_intervals_final
TTC_intervals_final
sample_intervals_final
TTC_LICK_THRESHOLD: int
interval_vars: dict[str, int]
exp_var_entries: dict[str, int]
program_schedule_df
def update_model(self, variable_name: str, value: int | None) -> None:
139    def update_model(self, variable_name: str, value: int | None) -> None:
140        """
141        Updates internal parameter dictionaries from GUI inputs.
142
143        Checks if the `variable_name` corresponds to a key in `interval_vars` or
144        `exp_var_entries` and updates the dictionary value if the provided `value`
145        is not None.
146
147        Parameters
148        ----------
149        - **variable_name** (*str*): The name identifier of the variable being updated (should match a dictionary key).
150        - **value** (*int | None*): The new integer value for the variable, or None if the input was invalid/empty.
151        """
152
153        # if the variable name for the tkinter entry item that we are updating is
154        # in the exp_data interval variables dictionary, update that entry
155        # with the value in the tkinter variable
156        if value is not None:
157            if variable_name in self.interval_vars.keys():
158                self.interval_vars[variable_name] = value
159            elif variable_name in self.exp_var_entries.keys():
160                # update other var types here
161                self.exp_var_entries[variable_name] = value

Updates internal parameter dictionaries from GUI inputs.

Checks if the variable_name corresponds to a key in interval_vars or exp_var_entries and updates the dictionary value if the provided value is not None.

Parameters

  • variable_name (str): The name identifier of the variable being updated (should match a dictionary key).
  • value (int | None): The new integer value for the variable, or None if the input was invalid/empty.
def get_default_value(self, variable_name: str) -> int:
163    def get_default_value(self, variable_name: str) -> int:
164        """
165        Retrieves the current value associated with a parameter name.
166
167        Searches `interval_vars` and `exp_var_entries` for the `variable_name`.
168        Used primarily to populate GUI fields with their initial/default values.
169
170        Parameters
171        ----------
172        - **variable_name** (*str*): The name identifier of the parameter whose value is requested.
173
174        Returns
175        -------
176        - *int*: The integer value associated with `variable_name` in the dictionaries, or -1 if the name is not found.
177        """
178        if variable_name in self.interval_vars.keys():
179            return self.interval_vars[variable_name]
180        elif variable_name in self.exp_var_entries.keys():
181            # update other var types here
182            return self.exp_var_entries[variable_name]
183
184        # couldn't find the value, return -1 as error code
185        return -1

Retrieves the current value associated with a parameter name.

Searches interval_vars and exp_var_entries for the variable_name. Used primarily to populate GUI fields with their initial/default values.

Parameters

  • variable_name (str): The name identifier of the parameter whose value is requested.

Returns

  • int: The integer value associated with variable_name in the dictionaries, or -1 if the name is not found.
def generate_schedule(self) -> bool:
187    def generate_schedule(self) -> bool:
188        """
189        Generates the complete pseudo-randomized experimental schedule.
190
191        Calculates the total number of trials based on stimuli count and block count.
192        Validates the number of stimuli. Calls helper methods:
193        - `create_random_intervals`() to determine ITI, TTC, Sample times per trial.
194        - `create_trial_blocks`() to define unique stimuli pairs that must occur per block.
195        - `generate_pairs`() to create the trial-by-trial stimuli sequence.
196        - `build_frame`() to assemble the final `program_schedule_df`.
197
198        Returns
199        -------
200        - *bool*: `True` if schedule generation was successful, `False` if an error occurred (e.g., invalid number of stimuli).
201        """
202        try:
203            # set number of trials based on number of stimuli, and number of trial blocks set by user
204            num_stimuli = self.exp_var_entries["Num Stimuli"]
205
206            if num_stimuli > 8 or num_stimuli < 2 or num_stimuli % 2 != 0:
207                GUIUtils.display_error(
208                    "NUMBER OF STIMULI EXCEEDS CURRENT MAXIMUM",
209                    "Program is currently configured for a minumim of 2 and maximum of 8 TOTAL vavles. You must have an even number of valves. If more are desired, program configuration must be modified.",
210                )
211                return False
212
213            num_trial_blocks = self.exp_var_entries["Num Trial Blocks"]
214
215            self.exp_var_entries["Num Trials"] = (num_stimuli // 2) * num_trial_blocks
216
217            self.create_random_intervals()
218
219            pairs = self.create_trial_blocks()
220
221            # stimuli_1 & stimuli_2 are lists that hold the stimuli to be introduced for each trial on their respective side
222            stimuli_side_one, stimuli_side_two = self.generate_pairs(pairs)
223
224            # args needed -> stimuli_1, stimuli_2
225            # these are lists of stimuli for each trial, for each side respectivelyc:w
226            self.build_frame(
227                stimuli_side_one,
228                stimuli_side_two,
229            )
230            return True
231
232        except Exception as e:
233            logger.error(f"Error generating program schedule {e}.")
234            return False

Generates the complete pseudo-randomized experimental schedule.

Calculates the total number of trials based on stimuli count and block count. Validates the number of stimuli. Calls helper methods:

Returns

  • bool: True if schedule generation was successful, False if an error occurred (e.g., invalid number of stimuli).
def create_random_intervals(self) -> None:
236    def create_random_intervals(self) -> None:
237        """
238        Calculates randomized ITI, TTC, and Sample intervals for all trials.
239
240        For each interval type (ITI, TTC, Sample):
241        - Retrieves the base duration and random variation range from `interval_vars`.
242        - Creates a base array repeating the base duration for the total number of trials.
243        - If random variation is specified, generates an array of random integers within the +/- range.
244        - Adds the random variation array to the base array.
245        - Stores the resulting final interval array in the corresponding class attribute (`ITI_intervals_final`, etc.).
246
247        Raises
248        ------
249        - Propagates NumPy errors (e.g., during random number generation or array addition).
250        - *KeyError*: If expected keys are missing from `interval_vars`.
251        """
252        try:
253            num_trials = self.exp_var_entries["Num Trials"]
254
255            final_intervals = [None, None, None]
256
257            interval_keys = ["ITI_var", "TTC_var", "sample_var"]
258
259            random_interval_keys = [
260                "ITI_random_entry",
261                "TTC_random_entry",
262                "sample_random_entry",
263            ]
264
265            # for each type of interval (iti, ttc, sample), generate final interval times
266            for i in range(len(interval_keys)):
267                base_val = self.interval_vars[interval_keys[i]]
268                # this makes an array that is n=num_trials length of base_val
269                base_intervals = np.repeat(base_val, num_trials)
270
271                # get plus minus value for this interval type
272                random_var = self.interval_vars[random_interval_keys[i]]
273
274                if random_var == 0:
275                    # if there is no value to add/subtract randomly, continue on
276                    final_intervals[i] = base_intervals
277
278                else:
279                    # otherwise make num_trials amount of random integers and add them to the final_intervals
280                    random_interval_arr = np.random.randint(
281                        -random_var, random_var, num_trials
282                    )
283
284                    final_intervals[i] = np.add(base_intervals, random_interval_arr)
285
286            # assign results for respective types to the model for storage and later use
287            self.ITI_intervals_final = final_intervals[0]
288            self.TTC_intervals_final = final_intervals[1]
289            self.sample_intervals_final = final_intervals[2]
290
291            logger.info("Random intervals created.")
292        except Exception as e:
293            logger.error(f"Error creating random intervals: {e}")
294            raise

Calculates randomized ITI, TTC, and Sample intervals for all trials.

For each interval type (ITI, TTC, Sample):

  • Retrieves the base duration and random variation range from interval_vars.
  • Creates a base array repeating the base duration for the total number of trials.
  • If random variation is specified, generates an array of random integers within the +/- range.
  • Adds the random variation array to the base array.
  • Stores the resulting final interval array in the corresponding class attribute (ITI_intervals_final, etc.).

Raises

  • Propagates NumPy errors (e.g., during random number generation or array addition).
  • KeyError: If expected keys are missing from interval_vars.
def calculate_max_runtime(self) -> tuple[int, int]:
296    def calculate_max_runtime(self) -> tuple[int, int]:
297        """
298        Estimates the maximum possible experiment runtime in minutes and seconds.
299
300        Sums all generated interval durations (ITI, TTC, Sample) across all trials.
301        Converts the total milliseconds to seconds, then uses `convert_seconds_to_minutes_seconds`.
302
303        Returns
304        -------
305        - *tuple[int, int]*: A tuple containing (estimated_max_minutes, estimated_max_seconds).
306        """
307        max_time = (
308            sum(self.ITI_intervals_final)
309            + sum(self.TTC_intervals_final)
310            + sum(self.sample_intervals_final)
311        )
312        minutes, seconds = self.convert_seconds_to_minutes_seconds(max_time / 1000)
313        return (minutes, seconds)

Estimates the maximum possible experiment runtime in minutes and seconds.

Sums all generated interval durations (ITI, TTC, Sample) across all trials. Converts the total milliseconds to seconds, then uses convert_seconds_to_minutes_seconds.

Returns

  • tuple[int, int]: A tuple containing (estimated_max_minutes, estimated_max_seconds).
def create_trial_blocks(self) -> List[tuple[str, str]]:
315    def create_trial_blocks(self) -> List[tuple[str, str]]:
316        """
317        Determines the unique stimuli pairings within a single trial block.
318
319        Calculates the number of pairs per block (`block_sz = num_stimuli / 2`).
320        Iterates from `i = 0` to `block_sz - 1`. For each `i`, finds the
321        corresponding paired valve index using `get_paired_index`. Appends the
322        tuple of (stimulus_name_at_i, stimulus_name_at_paired_index) to a list.
323
324        Returns
325        -------
326        - *List[tuple]*: A list of tuples, where each tuple represents a unique stimuli pairing (e.g., `[('Odor A', 'Odor B'), ('Odor C', 'Odor D')]`).
327
328        Raises
329        ------
330        - Propagates errors from `get_paired_index`.
331        - *KeyError*: If expected keys are missing from `exp_var_entries`.
332        - *IndexError*: If calculated indices are out of bounds for `stimuli_data.stimuli_vars`.
333        """
334        try:
335            # creating pairs list which stores all possible pairs
336            pairs = []
337            num_stimuli = self.exp_var_entries["Num Stimuli"]
338            block_sz = num_stimuli // 2
339
340            stimuli_names = list(self.stimuli_data.stimuli_vars.values())
341
342            for i in range(block_sz):
343                pair_index = self.get_paired_index(i, num_stimuli)
344                pairs.append((stimuli_names[i], stimuli_names[pair_index]))
345
346            return pairs
347        except Exception as e:
348            logger.error(f"Error creating trial blocks: {e}")
349            raise

Determines the unique stimuli pairings within a single trial block.

Calculates the number of pairs per block (block_sz = num_stimuli / 2). Iterates from i = 0 to block_sz - 1. For each i, finds the corresponding paired valve index using get_paired_index. Appends the tuple of (stimulus_name_at_i, stimulus_name_at_paired_index) to a list.

Returns

  • List[tuple]: A list of tuples, where each tuple represents a unique stimuli pairing (e.g., [('Odor A', 'Odor B'), ('Odor C', 'Odor D')]).

Raises

  • Propagates errors from get_paired_index.
  • KeyError: If expected keys are missing from exp_var_entries.
  • IndexError: If calculated indices are out of bounds for stimuli_data.stimuli_vars.
def generate_pairs(self, pairs: list[tuple[str, str]]) -> Tuple[list, list]:
351    def generate_pairs(self, pairs: list[tuple[str, str]]) -> Tuple[list, list]:
352        """
353        Generates the full, pseudo-randomized sequence of stimuli pairs for all trials.
354
355        Repeats the following for the number of trial blocks specified:
356        - Takes the list of unique `pairs` generated by `create_trial_blocks` that must be included in each block.
357        - Shuffles this list randomly (`np.random.shuffle`).
358        - Extends a master list `pseudo_random_lineup` (adds new list to that list) with the shuffled block.
359        After creating the full sequence, separates it into two lists: one for the
360        stimulus presented on side one each trial, and one for side two.
361
362        Parameters
363        ----------
364        - **pairs** (*List[tuple[str, str]]*): The list of unique stimuli pairings defining one block, generated by `create_trial_blocks`.
365
366        Returns
367        -------
368        - *Tuple[list, list]*: A tuple containing two lists:
369            - `stimulus_1`: List of stimuli names for Port 1 for each trial.
370            - `stimulus_2`: List of stimuli names for Port 2 for each trial.
371
372        Raises
373        ------
374        - Propagates errors from `np.random.shuffle`.
375        """
376        try:
377            pseudo_random_lineup: List[tuple] = []
378
379            stimulus_1: List[str] = []
380            stimulus_2: List[str] = []
381
382            for _ in range(self.exp_var_entries["Num Trial Blocks"]):
383                pairs_copy = [tuple(pair) for pair in pairs]
384                np.random.shuffle(pairs_copy)
385                pseudo_random_lineup.extend(pairs_copy)
386
387            for pair in pseudo_random_lineup:
388                stimulus_1.append(pair[0])
389                stimulus_2.append(pair[1])
390
391            return stimulus_1, stimulus_2
392        except Exception as e:
393            logger.error(f"Error generating pairs: {e}")
394            raise

Generates the full, pseudo-randomized sequence of stimuli pairs for all trials.

Repeats the following for the number of trial blocks specified:

  • Takes the list of unique pairs generated by create_trial_blocks that must be included in each block.
  • Shuffles this list randomly (np.random.shuffle).
  • Extends a master list pseudo_random_lineup (adds new list to that list) with the shuffled block. After creating the full sequence, separates it into two lists: one for the stimulus presented on side one each trial, and one for side two.

Parameters

  • pairs (List[tuple[str, str]]): The list of unique stimuli pairings defining one block, generated by create_trial_blocks.

Returns

  • Tuple[list, list]: A tuple containing two lists:
    • stimulus_1: List of stimuli names for Port 1 for each trial.
    • stimulus_2: List of stimuli names for Port 2 for each trial.

Raises

  • Propagates errors from np.random.shuffle.
def build_frame(self, stimuli_1: list[str], stimuli_2: list[str]) -> None:
396    def build_frame(
397        self,
398        stimuli_1: list[str],
399        stimuli_2: list[str],
400    ) -> None:
401        """
402        Constructs the main `program_schedule_df` pandas DataFrame.
403
404        Uses the generated stimuli sequences (`stimuli_1`, `stimuli_2`) and the
405        calculated interval arrays (`ITI_intervals_final`, etc.) to build the
406        DataFrame. Includes columns for trial block number, trial number, stimuli
407        per port, calculated intervals, and placeholders (NaN) for results columns
408        like lick counts and actual TTC duration.
409
410        Parameters
411        ----------
412        - **stimuli_1** (*list*): List of stimuli names for Port 1, one per trial.
413        - **stimuli_2** (*list*): List of stimuli names for Port 2, one per trial.
414
415        Raises
416        ------
417        - Propagates pandas errors during DataFrame creation.
418        - *ValueError*: If the lengths of input lists/arrays do not match the expected number of trials.
419        """
420        num_stimuli = self.exp_var_entries["Num Stimuli"]
421        num_trials = self.exp_var_entries["Num Trials"]
422        num_trial_blocks = self.exp_var_entries["Num Trial Blocks"]
423
424        # each block must contain each PAIR -> num pairs = num_stim / 2
425        block_size = int(num_stimuli / 2)
426        try:
427            data = {
428                "Trial Block": np.repeat(
429                    range(1, num_trial_blocks + 1),
430                    block_size,
431                ),
432                "Trial Number": np.repeat(range(1, num_trials + 1), 1),
433                "Port 1": stimuli_1,
434                "Port 2": stimuli_2,
435                "Port 1 Licks": np.full(num_trials, np.nan),
436                "Port 2 Licks": np.full(num_trials, np.nan),
437                "ITI": self.ITI_intervals_final,
438                "TTC": self.TTC_intervals_final,
439                "SAMPLE": self.sample_intervals_final,
440                "TTC Actual": np.full(num_trials, np.nan),
441            }
442
443            self.program_schedule_df = pd.DataFrame(data)
444
445            logger.info("Initialized stimuli dataframe.")
446        except Exception as e:
447            logger.debug(f"Error Building Stimuli Frame: {e}.")
448            raise

Constructs the main program_schedule_df pandas DataFrame.

Uses the generated stimuli sequences (stimuli_1, stimuli_2) and the calculated interval arrays (ITI_intervals_final, etc.) to build the DataFrame. Includes columns for trial block number, trial number, stimuli per port, calculated intervals, and placeholders (NaN) for results columns like lick counts and actual TTC duration.

Parameters

  • stimuli_1 (list): List of stimuli names for Port 1, one per trial.
  • stimuli_2 (list): List of stimuli names for Port 2, one per trial.

Raises

  • Propagates pandas errors during DataFrame creation.
  • ValueError: If the lengths of input lists/arrays do not match the expected number of trials.
def save_all_data(self):
450    def save_all_data(self):
451        """
452        Saves the experiment schedule and detailed event log DataFrames to Excel files.
453
454        Iterates through a dictionary mapping descriptive names to the DataFrame
455        references (`program_schedule_df`, `event_data.event_dataframe`) and calls
456        the static `save_df_to_xlsx` method for each.
457        """
458        dataframes = {
459            "Experiment Schedule": self.program_schedule_df,
460            "Detailed Event Log Data": self.event_data.event_dataframe,
461        }
462
463        for name, df_reference in dataframes.items():
464            self.save_df_to_xlsx(name, df_reference)

Saves the experiment schedule and detailed event log DataFrames to Excel files.

Iterates through a dictionary mapping descriptive names to the DataFrame references (program_schedule_df, event_data.event_dataframe) and calls the static save_df_to_xlsx method for each.

@staticmethod
def get_paired_index(i: int, num_stimuli: int) -> int | None:
466    @staticmethod
467    def get_paired_index(i: int, num_stimuli: int) -> int | None:
468        """
469        Calculates the 0-indexed valve number for the stimulus for side two based on give index (i) for side one.
470
471        Based on the total number of stimuli (`num_stimuli`), determines the index
472        of the valve paired with the valve at index `i`. Handles cases for 2, 4, or 8 total stimuli.
473
474        Parameters
475        ----------
476        - **i** (*int*): The 0-indexed number of the valve on one side.
477        - **num_stimuli** (*int*): The total number of stimuli/valves being used (must be 2, 4, or 8).
478
479        Returns
480        -------
481        - *int | None*: The 0-indexed number of the paired valve, or None if `num_stimuli` is not 2, 4, or 8.
482        """
483        match num_stimuli:
484            case 2:
485                # if num stim is 2, we are only running the same stimulus on both sides,
486                # we will only see i=0 here, and its paired with its sibling i=4
487                return i + 4
488            case 4:
489                # if num stim is 4, we are only running 2 stimuli per side,
490                # we will see i=0 and i =1 here, paired with siblings i=5 & i=4 respectively
491                match i:
492                    case 0:  # valve 1
493                        return i + 5  # (5) / valve 6
494                    case 1:  # valve 2
495                        return i + 3  # (4) / valve 5
496            case 8:
497                match i:
498                    case 0:
499                        return i + 5
500                    case 1:
501                        return i + 3
502                    case 2:
503                        return i + 5
504                    case 3:
505                        return i + 3
506            case _:
507                return None

Calculates the 0-indexed valve number for the stimulus for side two based on give index (i) for side one.

Based on the total number of stimuli (num_stimuli), determines the index of the valve paired with the valve at index i. Handles cases for 2, 4, or 8 total stimuli.

Parameters

  • i (int): The 0-indexed number of the valve on one side.
  • num_stimuli (int): The total number of stimuli/valves being used (must be 2, 4, or 8).

Returns

  • int | None: The 0-indexed number of the paired valve, or None if num_stimuli is not 2, 4, or 8.
@staticmethod
def save_df_to_xlsx(name: str, dataframe: pandas.core.frame.DataFrame) -> None:
509    @staticmethod
510    def save_df_to_xlsx(name: str, dataframe: pd.DataFrame) -> None:
511        """
512        Saves a pandas DataFrame to an Excel (.xlsx) file using a file save dialog.
513
514        Prompts the user with a standard 'Save As' dialog window. Suggests a default
515        filename including the provided `name` and the current date. Sets the default
516        directory to '../data_outputs'. If the user confirms a filename, saves the
517        DataFrame; otherwise, logs that the save was cancelled.
518
519        Parameters
520        ----------
521        - **name** (*str*): A descriptive name used in the default filename (e.g., "Experiment Schedule").
522        - **dataframe** (*pd.DataFrame*): The pandas DataFrame to be saved.
523
524        Raises
525        ------
526        - Propagates errors from `filedialog.asksaveasfilename` or `dataframe.to_excel`.
527        """
528        try:
529            file_name = filedialog.asksaveasfilename(
530                defaultextension=".xlsx",
531                filetypes=[("Excel Files", "*.xlsx")],
532                initialfile=f"{name}, {datetime.date.today()}",
533                initialdir=Path(__file__).parent.parent.parent.resolve()
534                / "data_outputs",
535                title="Save Excel file",
536            )
537
538            if file_name:
539                dataframe.to_excel(file_name, index=False)
540            else:
541                logger.info("User cancelled saving the stimuli dataframe.")
542
543            logger.info("Data saved to xlsx files.")
544        except Exception as e:
545            logger.error(f"Error saving data to xlsx: {e}")
546            raise

Saves a pandas DataFrame to an Excel (.xlsx) file using a file save dialog.

Prompts the user with a standard 'Save As' dialog window. Suggests a default filename including the provided name and the current date. Sets the default directory to '../data_outputs'. If the user confirms a filename, saves the DataFrame; otherwise, logs that the save was cancelled.

Parameters

  • name (str): A descriptive name used in the default filename (e.g., "Experiment Schedule").
  • dataframe (pd.DataFrame): The pandas DataFrame to be saved.

Raises

  • Propagates errors from filedialog.asksaveasfilename or dataframe.to_excel.
@staticmethod
def convert_seconds_to_minutes_seconds(total_seconds: int) -> tuple[int, int]:
548    @staticmethod
549    def convert_seconds_to_minutes_seconds(total_seconds: int) -> tuple[int, int]:
550        """
551        Converts a total number of seconds into whole minutes and remaining seconds.
552
553        Parameters
554        ----------
555        - **total_seconds** (*int*): The total duration in seconds.
556
557        Returns
558        -------
559        - *tuple[int, int]*: A tuple containing (minutes, seconds).
560
561        Raises
562        ------
563        - Propagates `TypeError` if `total_seconds` is not a number.
564        """
565        try:
566            # Integer division to get whole minutes
567            minutes = total_seconds // 60
568            # Modulo to get remaining seconds
569            seconds = total_seconds % 60
570            return minutes, seconds
571        except Exception as e:
572            logger.error(f"Error converting seconds to minutes and seconds: {e}")
573            raise

Converts a total number of seconds into whole minutes and remaining seconds.

Parameters

  • total_seconds (int): The total duration in seconds.

Returns

  • tuple[int, int]: A tuple containing (minutes, seconds).

Raises

  • Propagates TypeError if total_seconds is not a number.