Skip to content

ENH: allow recursive callOnFlip() calls#6814

Merged
peircej merged 1 commit intopsychopy:devfrom
zeyus-research:master
Sep 16, 2024
Merged

ENH: allow recursive callOnFlip() calls#6814
peircej merged 1 commit intopsychopy:devfrom
zeyus-research:master

Conversation

@zeyus
Copy link
Copy Markdown
Contributor

@zeyus zeyus commented Aug 29, 2024

TL;DR:

This PR allows recursive once-per-flip calls to win.callOnFlip().

Independent sidechain functionality that runs every frame is now possible.

I've abstracted some functionality in psychopy to make running a suite of experiments easier to work with for our team. Unfortunately, during implementation of a VisualStim photodiode trigger that can synchronize with a ParallelPort trigger, I came across a bug that prevented scheduling callOnFlip within a function scheduled the previous frame.

This PR fixes that by first checking the length of self._toCall, then running those scheduled functions, and finally, deleting those elements up to the prior length.

This allows things like:

  • Show stim for n frames
  • Make trigger low after n frames
  • Show something for n seconds using this MonotonicClock
  • Send a pulse every n frames or seconds regardless of what is happening in the experiment (as long as frames are flipping)

This avoids requiring an explicit call to win.callOnFlip() in each context / trial / etc loop where a win.flip() call may occur (there are still all the same performance considerations, anything with recursive win.callOnFlip() should follow all the same guidelines as if you were running a loop with the scheduled function executing once per frame)

I'm using the change in my code and now the display no longer halts, but instead displays the stimuli correctly, and updates other elements as expected.

Example code making use of this:

import logging
from typing import TYPE_CHECKING, Literal

from psychopy.clock import MonotonicClock  # type: ignore
from psychopy.visual.rect import Rect  # type: ignore

if TYPE_CHECKING:
    from psychopy.visual.window import Window  # type: ignore


class ParallelPort:
    def send_trigger(self, trigger_code: int):
        logging.debug(f"Sending trigger code: {trigger_code}")


class Square(Rect):
    def __init__(self, win: Window, size: int, pos: tuple[int, int], color: tuple[int, int, int]):
        super().__init__(win, width=size, height=size, pos=pos, fillColor=color, lineColor=color)  # type: ignore

    def draw(self):
        super().draw()


class PhotoDiodeSquare(Square):
    def __init__(
        self,
        win: Window,
        size: int,
        corner: Literal["tl", "tr", "bl", "br"] = "br",
        color: tuple[int, int, int] = (1, 1, 1),
    ):
        if corner == "tl":
            pos = (-win.size[0] / 2 + size / 2, win.size[1] / 2 - size / 2)
        elif corner == "tr":
            pos = (win.size[0] / 2 - size / 2, win.size[1] / 2 - size / 2)
        elif corner == "bl":
            pos = (-win.size[0] / 2 + size / 2, -win.size[1] / 2 + size / 2)
        elif corner == "br":
            pos = (win.size[0] / 2 - size / 2, -win.size[1] / 2 + size / 2)
        super().__init__(win, size, pos, color)

    def draw(self):
        super().draw()


class ParallelPhotdiodeTrigger(PhotoDiodeSquare):
    """Class for sending a trigger signal a photodiode + simultaneous parallel port trigger."""

    def __init__(
        self,
        win: "Window",
        port: "ParallelPort | None",
        size: int = 100,
        corner: Literal["tl", "tr", "bl", "br"] = "br",
        color: tuple[int, int, int] = (1, 1, 1),
    ):
        if port is None:
            msg = "Parallel port is required to send triggers, perhaps you want to use PhotoDiodeSquare instead"
            raise ValueError(msg)
        self._port = port
        super().__init__(win, size, corner, color)

    def send_trigger(self, trigger_code: int, high_duration: float = 0.1, visual_duration: float | None = None):
        """Sends a trigger signal to the parallel port while drawing the photodiode square.

        NOTE: The trigger will be sent on the next flip. This means:
          - The trigger and the photodiode square are in sync.


        Args:
            trigger_code: The trigger code to send.
            high_duration: The number of frames to keep the trigger high.
            visual_duration: The number of frames to draw the photodiode square.
                If None, the square will be drawn for high_duration.
        """
        if visual_duration is None:
            visual_duration = high_duration
        self.draw()
        self.win.callOnFlip(self.start_schedule, trigger_code, high_duration, visual_duration)

    def start_schedule(self, trigger_code: int, high_duration: float, visual_duration: float):
        """Schedules a trigger signal to the parallel port.

        Args:
            trigger_code: The trigger code to send.
            high_duration: The number of frames to keep the trigger high.
            visual_duration: The number of frames to draw the photodiode square.
        """
        clock = MonotonicClock()
        logging.debug(f"Port trigger ({trigger_code}) set HIGH")
        self._port.send_trigger(trigger_code)
        self.draw()
        self.win.callOnFlip(self.schedule_low_trigger, trigger_code, high_duration, visual_duration, clock)

    def schedule_low_trigger(
        self, trigger_code: int, high_duration: float, visual_duration: float, clock: MonotonicClock
    ):
        """Schedules a low trigger signal to the parallel port.

        Args:
            trigger_code: The trigger code to send.
            high_duration: The number of frames to keep the trigger high.
            visual_duration: The number of frames to draw the photodiode square.
        """
        if high_duration > 0:
            if clock.getTime() >= high_duration:
                logging.debug(f"Port trigger ({trigger_code}) set LOW (0)")
                self._port.send_trigger(0)
                high_duration = 0

        if visual_duration > 0:
            if clock.getTime() < visual_duration:
                self.draw()
            else:
                logging.debug("Visual trigger hidden")
                visual_duration = 0

        if high_duration > 0 or visual_duration > 0:
            self.win.callOnFlip(self.schedule_low_trigger, trigger_code, high_duration, visual_duration, clock)

@codecov
Copy link
Copy Markdown

codecov bot commented Aug 29, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 49.61%. Comparing base (b7b6ac8) to head (e12db30).
Report is 81 commits behind head on dev.

Additional details and impacted files
@@            Coverage Diff             @@
##              dev    #6814      +/-   ##
==========================================
- Coverage   49.61%   49.61%   -0.01%     
==========================================
  Files         332      332              
  Lines       61227    61228       +1     
==========================================
  Hits        30378    30378              
- Misses      30849    30850       +1     
Components Coverage Δ
app ∅ <ø> (∅)
boilerplate ∅ <ø> (∅)
library ∅ <ø> (∅)
vm-safe library ∅ <ø> (∅)

@peircej peircej changed the title Fix infinite loop (or CPU hogging) with scheduled functions spanning multiple frames using callOnFlip ENH: allow recursive callOnFlip() calls Aug 29, 2024
@peircej peircej merged commit 6ea8121 into psychopy:dev Sep 16, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants