Hard-coded durations extended by slightly late button presses

Hello everyone!

Our PsychoPy task is doing this thing where hard-coded epoch durations within the trial (e.g., a 2.5 second decision phase) are logging much longer durations if the participant takes a slightly longer time to respond. For example in a 2.5 second decision epoch, if the participant responds in 3.03 seconds, it’s logging the duration as 3.03 seconds, and the next onset is thus also delayed relative to the extended previous duration, and thus the whole task is often lengthened by quite a bit.

To be clear, the task does move on to the next epoch and count that trial as missed eventually, but it’s unclear to me how long it’s allowing these late trials before it actually classifies it as missed. Additionally, fully missed trials seem to be very accurately timed (i.e., exactly matching the hard-coded duration), so these lengthened durations seem to only occur if the button press comes in relatively close to the hard-coded end of that phase/screen. The most egregious I’ve seen is an extended duration of ~0.43 seconds (duration hard-coded to be 2.75 seconds, response time logged as 3.2642114, duration of decision phase screen logged as 3.2645233), but most are less off of the hard-coded duration than that.

My concern for the fMRI analyses is if I specify that the decision phase has a duration of 2.5 seconds, but some of those durations are actually 3 seconds or more, then I might be pushing some of that epoch into the implicit baseline. Thus, I need the task to cut people off, log the trial as missed, and move on, even if their button press comes in a touch late.

We have encountered this on different computers (some Mac, some PC) with a a range of different PsychoPy versions (we most commonly use v2023.2.2), so I think this is not an issue specific to any one set-up, but more about me not understanding how to enforce specific durations! Any help or direction is sincerely appreciated.

Here is where we specify the length of the decision epoch:

    ## How long participants have to make their decision
    if task_type == 'Encoding':
        dur_decision = 2.5
    elif task_type == "Recall":
        dur_decision = 2.75

Here is an example of how we set the timing for the whole trial:

trial_timer = core.CountdownTimer(dur_decision + dur_isi + dur_feedback + dur_iti)

And here is an example for the main set up of a decision phase (apologies for the long block of code):

        ## While there's still time on the trial clock, do the following
        while trial_timer.getTime() > 0:
            # ----- Decision -----
            ## Start a countdown timer according to the duration of this event
            timer = core.CountdownTimer(dur_decision)
            ## Look for keys that we put on our response list
            resp = event.getKeys(keyList = responseKeys)
            ## Get the time when this event starts
            decision_onset = clock.getTime()
            ## Pulling frame and stimulus information from the reference document                  
            if task_type == "Encoding":
                if trial_type[row_counter] == 'win':
                    top_frame = top_frame_win
                    bottom_frame = bottom_frame_win
                    left_frame = left_frame_win
                    right_frame = right_frame_win
                else:
                    top_frame = top_frame_loss
                    bottom_frame = bottom_frame_loss
                    left_frame = left_frame_loss
                    right_frame = right_frame_loss
            ## While the timer for this event is still counting, do the following
            while timer.getTime() > 0:
                ## Draw task-specific objects (i.e., text, frames, pictures, etc.)
                if task_type == "Encoding":
                    pic_L.draw()
                    pic_R.draw()
                    top_frame.draw()
                    bottom_frame.draw()
                    left_frame.draw()
                    right_frame.draw()
                elif task_type == "Recall":
                    pic.draw()
                    feedback_positive_L.draw()
                    feedback_negative_L.draw()
                    feedback_neutral_L.draw()
                    feedback_positive_R.draw()
                    feedback_negative_R.draw()
                    feedback_neutral_R.draw()
                    text_left.draw()
                    text_right.draw()
                win.flip()
                ## Accept keys on the response key list
                resp = event.getKeys(keyList = responseKeys)
                ## If a key from that list is pressed ...
                if len(resp) > 0:
                    ## ... and it's a z ...
                    if 'z' in resp:
                        if task_type == "Recall" and vers != "practice":                
                            ## ... quit the task and save our current progress as a .csv 
                            bidsEvents.to_csv(os.path.join("data",subj_id, f"sub-{subj_id}_{task_type}_{domain}_run{run}_{date}.tsv"), sep='\t', index = False) 
                        else:                
                            ## ... quit the task and save our current progress as a .csv 
                            bidsEvents.to_csv(os.path.join("data",subj_id, f"sub-{subj_id}_{task_type}_{domain}_run{run}_{vers}_{date}.tsv"), sep='\t', index = False)                             
                        core.quit()
                    ## If it's an encoding task, record the responses, draw the frames that people select
                    if task_type == "Encoding":
                        if selected == 7 or 2:
                            # If the keypad was used, remove the "num_" prefix from the response
                            if resp[0].startswith("num_"):
                                resp[0] = resp[0][4:]
                            selected = int(resp[0])
                            if selected == 7:
                                pic_L.draw()
                                pic_R.draw()
                                top_frame.draw()
                                bottom_frame.draw()
                                left_frame.draw()
                                right_frame.draw()
                                l_r = 'left'
                                select_2.draw()
                                win.flip()
                                core.wait(.5)
                            elif selected == 2:
                                pic_R.draw()
                                pic_L.draw()
                                top_frame.draw()
                                bottom_frame.draw()
                                left_frame.draw()
                                right_frame.draw()
                                l_r = 'right'
                                select_3.draw()
                                win.flip()
                                core.wait(.5)
                            ## Get the time at which the response was recorded
                            resp_onset = clock.getTime()
                            ## Calculate the response time 
                            rt = resp_onset - decision_onset
                            ## Redraw relevant objects
                            border.autoDraw=True
                            border2.autoDraw=True
                            pic_L.draw()
                            pic_R.draw()
                            ## Wait the rest of the time for this event
                            core.wait(dur_decision - rt)
                            break

And lastly here is an example of logging trials as missed (this “else” belongs to the “if len(resp) > 0:” line in the above code block)

                # If no response was given, consider this event missed
                else:
                    resp = 'missed'
                    selected = 'missed'
                    rt = 'missed'
            decision_eventtime = clock.getTime() - decision_onset 
            border.autoDraw=False

Let me know if there is any other code snippets or info I can provide. Thank you so very much in advance!! :blush: