From 2aa219a128ecf9c943f459e207138fc2b1fa550f Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 27 Apr 2021 11:56:20 -0400 Subject: [PATCH] ENH: Allow triggering after onset (#419) * ENH: Allow triggering after onset * FIX: Naming * FIX: Get info * FIX: Try * FIX: Bad Pyglet version --- .circleci/config.yml | 1 + .travis.yml | 1 + appveyor.yml | 3 +- azure-pipelines.yml | 3 +- expyfun/_experiment_controller.py | 45 +++++++++++-------- .../_sound_controllers/_sound_controller.py | 28 ++++++++---- expyfun/_tdt_controller.py | 5 ++- expyfun/_trigger_controllers.py | 5 ++- expyfun/_utils.py | 9 +++- 9 files changed, 67 insertions(+), 33 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index e7d888fd..2f95f819 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -23,6 +23,7 @@ jobs: - run: pip install --quiet --upgrade --user pip - run: pip install --quiet --upgrade --user numpy scipy matplotlib sphinx pillow pandas h5py mne pyglet psutil sphinx_bootstrap_theme sphinx_fontawesome numpydoc https://api.github.com/repos/sphinx-gallery/sphinx-gallery/zipball/master - run: python -c "import mne; mne.sys_info()" + - run: python -c "import pyglet; print(pyglet.version)" - run: python setup.py develop --user - run: cd doc && make html diff --git a/.travis.yml b/.travis.yml index 13686e3f..f5ae5b1c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -74,6 +74,7 @@ before_install: pip install pyglet; fi; fi; + - python -c "import pyglet; print(pyglet.version)" # Import matplotlib ahead of time so it doesn't affect test timings # (e.g., building fc-cache) - python -c "import matplotlib.pyplot as plt" diff --git a/appveyor.yml b/appveyor.yml index 20afe415..4cabffdf 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -9,8 +9,9 @@ platform: install: - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" - "python --version" - - "pip install -q numpy scipy matplotlib coverage setuptools h5py pandas pytest pytest-cov pytest-timeout pytest-xdist codecov pyglet mne tdtpy joblib numpydoc pillow" + - "pip install -q numpy scipy matplotlib coverage setuptools h5py pandas pytest pytest-cov pytest-timeout pytest-xdist codecov \"pyglet!=1.5.16\" mne tdtpy joblib numpydoc pillow" - "python -c \"import mne; mne.sys_info()\"" + - "python -c \"import pyglet; print(pyglet.version)\"" # Get a virtual sound card / VBAudioVACWDM device - "git clone --depth 1 git://github.com/LABSN/sound-ci-helpers.git" - "powershell sound-ci-helpers/windows/setup_sound.ps1" diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 0686b0e6..2b8a932b 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -32,9 +32,10 @@ jobs: addToPath: true - powershell: | pip install --upgrade numpy scipy vtk - pip install -q numpy scipy matplotlib coverage setuptools h5py pandas pytest pytest-cov pytest-timeout pytest-xdist codecov pyglet pyglet-ffmpeg mne tdtpy joblib numpydoc pillow + pip install -q numpy scipy matplotlib coverage setuptools h5py pandas pytest pytest-cov pytest-timeout pytest-xdist codecov "pyglet!=1.5.16" pyglet-ffmpeg mne tdtpy joblib numpydoc pillow python -c "import mne; mne.sys_info()" python -c "import matplotlib.pyplot as plt" + python -c "import pyglet; print(pyglet.version)" displayName: 'Install pip dependencies' - powershell: | git clone --depth 1 git://github.com/LABSN/sound-ci-helpers.git diff --git a/expyfun/_experiment_controller.py b/expyfun/_experiment_controller.py index 24981a76..29272aec 100644 --- a/expyfun/_experiment_controller.py +++ b/expyfun/_experiment_controller.py @@ -98,8 +98,8 @@ class ExperimentController(object): If ``None``, the type will be read from the system configuration file. If a string, must be 'dummy', 'parallel', 'sound_card', or 'tdt'. By default the mode is 'dummy', since setting up the parallel port - can be a pain. Can also be a dict with entries 'type' ('parallel'), - and 'address' (None). + can be a pain. Can also be a dict with entries 'TYPE' ('parallel'), + and 'TRIGGER_ADDRESS' (None). session : str | None If ``None``, a GUI will be used to acquire this information. check_rms : str | None @@ -420,15 +420,22 @@ def __init__(self, exp_name, audio_controller=None, response_device=None, if trigger_controller is None: trigger_controller = get_config('TRIGGER_CONTROLLER', 'dummy') if isinstance(trigger_controller, string_types): - trigger_controller = dict(type=trigger_controller) - logger.info('Expyfun: Initializing {} triggering mode' - ''.format(trigger_controller['type'])) - if trigger_controller['type'] == 'tdt': + trigger_controller = dict(TYPE=trigger_controller) + assert isinstance(trigger_controller, dict) + trigger_controller = trigger_controller.copy() + known_keys = ('TYPE',) + if set(trigger_controller) != set(known_keys): + raise ValueError( + 'Unknown keys for trigger_controller, must be ' + f'{known_keys}, got {set(trigger_controller)}') + logger.info(f'Expyfun: Initializing {trigger_controller["TYPE"]} ' + 'triggering mode') + if trigger_controller['TYPE'] == 'tdt': if not isinstance(self._ac, TDTController): raise ValueError('trigger_controller can only be "tdt" if ' 'tdt is used for audio') self._tc = self._ac - elif trigger_controller['type'] == 'sound_card': + elif trigger_controller['TYPE'] == 'sound_card': if not isinstance(self._ac, SoundCardController): raise ValueError('trigger_controller can only be ' '"sound_card" if the sound card is ' @@ -438,23 +445,21 @@ def __init__(self, exp_name, audio_controller=None, response_device=None, 'when SOUND_CARD_TRIGGER_CHANNELS is ' 'zero') self._tc = self._ac - elif trigger_controller['type'] in ['parallel', 'dummy']: - if 'address' not in trigger_controller: - addr = get_config('TRIGGER_ADDRESS') - trigger_controller['address'] = addr + elif trigger_controller['TYPE'] in ['parallel', 'dummy']: + addr = trigger_controller.get( + 'TRIGGER_ADDRESS', get_config('TRIGGER_ADDRESS', None)) self._tc = ParallelTrigger( - trigger_controller['type'], - trigger_controller.get('address'), + trigger_controller['TYPE'], addr, trigger_duration, ec=self) self._extra_cleanup_fun.insert(0, self._tc.close) # The TDT always stamps "1" on stimulus onset. Here we need # to manually mimic that behavior. self._ofp_critical_funs.insert( - 0, lambda: self._stamp_ttl_triggers([1], False)) + 0, lambda: self._stamp_ttl_triggers([1], False, False)) else: raise ValueError('trigger_controller type must be ' '"parallel", "dummy", "sound_card", or "tdt",' - 'got {0}'.format(trigger_controller['type'])) + 'got {0}'.format(trigger_controller['TYPE'])) self._id_call_dict['ttl_id'] = self._stamp_binary_id # other basic components @@ -2072,7 +2077,7 @@ def _stamp_binary_id(self, id_, wait_for_last=True): if not np.all(np.in1d(id_, [0, 1])): raise ValueError('All values of id must be 0 or 1') id_ = (id_.astype(int) + 1) << 2 # 0, 1 -> 4, 8 - self._stamp_ttl_triggers(id_, wait_for_last) + self._stamp_ttl_triggers(id_, wait_for_last, True) def stamp_triggers(self, ids, check='binary', wait_for_last=True): """Stamp binary values @@ -2087,6 +2092,7 @@ def stamp_triggers(self, ids, check='binary', wait_for_last=True): 1 and 15. wait_for_last : bool If True, wait for last trigger to be stamped before returning. + If False, don't wait at all (if possible). Notes ----- @@ -2107,11 +2113,12 @@ def stamp_triggers(self, ids, check='binary', wait_for_last=True): if not all(id_ in _vals for id_ in ids): raise ValueError('with check="binary", ids must all be ' '1, 2, 4, or 8: {0}'.format(ids)) - self._stamp_ttl_triggers(ids, wait_for_last) + self._stamp_ttl_triggers(ids, wait_for_last, False) - def _stamp_ttl_triggers(self, ids, wait_for_last): + def _stamp_ttl_triggers(self, ids, wait_for_last, is_trial_id): logger.exp('Stamping TTL triggers: %s', ids) - self._tc.stamp_triggers(ids, wait_for_last=wait_for_last) + self._tc.stamp_triggers( + ids, wait_for_last=wait_for_last, is_trial_id=is_trial_id) self.flush() def flush(self): diff --git a/expyfun/_sound_controllers/_sound_controller.py b/expyfun/_sound_controllers/_sound_controller.py index 86fd0b87..9d60e17b 100644 --- a/expyfun/_sound_controllers/_sound_controller.py +++ b/expyfun/_sound_controllers/_sound_controller.py @@ -28,6 +28,7 @@ 'SOUND_CARD_NAME', 'SOUND_CARD_FS', 'SOUND_CARD_FIXED_DELAY', 'SOUND_CARD_TRIGGER_CHANNELS', 'SOUND_CARD_API_OPTIONS', 'SOUND_CARD_TRIGGER_SCALE', 'SOUND_CARD_TRIGGER_INSERTION', + 'SOUND_CARD_TRIGGER_ID_AFTER_ONSET', ) @@ -76,6 +77,8 @@ class SoundCardController(object): by 8). The default value (``1. / (2 ** 32 - 1)``) is meant to be appropriate for bit-perfect mapping to the 24 bit output of a SPDIF channel. + - 'SOUND_CARD_TRIGGER_ID_AFTER_ONSET': bool + If True, TTL IDs will be stored and stamped after the 1 trigger. Note that the defaults are superseded on individual machines by the configuration file. @@ -89,6 +92,7 @@ def __init__(self, params, stim_fs, n_channels=2, trigger_duration=0.01, SOUND_CARD_TRIGGER_CHANNELS=0, SOUND_CARD_TRIGGER_SCALE=1. / float(2 ** 31 - 1), SOUND_CARD_TRIGGER_INSERTION='prepend', + SOUND_CARD_TRIGGER_ID_AFTER_ONSET=False, ) # any omitted become None params = _check_params(params, _SOUND_CARD_KEYS, defaults, 'params') @@ -96,6 +100,9 @@ def __init__(self, params, stim_fs, n_channels=2, trigger_duration=0.01, params['SOUND_CARD_BACKEND']) self._n_channels_stim = int(params['SOUND_CARD_TRIGGER_CHANNELS']) trig_scale = float(params['SOUND_CARD_TRIGGER_SCALE']) + self._id_after_onset = ( + str(params['SOUND_CARD_TRIGGER_ID_AFTER_ONSET']).lower() == 'true') + self._extra_onset_triggers = list() assert self._n_channels_stim >= 0 self._n_channels = int(operator.index(n_channels)) del n_channels @@ -198,7 +205,7 @@ def load_buffer(self, samples): self.audio.delete() self.audio = None if self._n_channels_stim > 0: - stim = self._make_digital_trigger([1]) + stim = self._make_digital_trigger([1] + self._extra_onset_triggers) extra = len(samples) - len(stim) if extra > 0: # stim shorter than samples (typical) stim = np.pad(stim, ((0, extra), (0, 0)), 'constant') @@ -250,7 +257,8 @@ def _make_digital_trigger(self, trigs, delay=None): offset += n_each return stim - def stamp_triggers(self, triggers, delay=None, wait_for_last=True): + def stamp_triggers(self, triggers, delay=None, wait_for_last=True, + is_trial_id=False): """Stamp a list of triggers with a given inter-trigger delay. Parameters @@ -263,7 +271,13 @@ def stamp_triggers(self, triggers, delay=None, wait_for_last=True): If None, will use twice the trigger duration (50% duty cycle). wait_for_last : bool If True, wait for last trigger to be stamped before returning. + is_trial_id : bool + If True and SOUND_CARD_TRIGGER_ID_AFTER_ONSET, the triggers will + be stashed and appended to the 1 trigger for the sound onset. """ + if is_trial_id and self._id_after_onset: + self._extra_onset_triggers = list(triggers) + return if delay is None: delay = 2 * self._trigger_duration stim = self._make_digital_trigger(triggers, delay) @@ -274,12 +288,10 @@ def stamp_triggers(self, triggers, delay=None, wait_for_last=True): t_each = self._trigger_duration + delay duration = len(triggers) * t_each extra_delay = 0.1 - if not wait_for_last: - delta = (delay - self._trigger_duration) - duration -= delta - extra_delay += delta - self.ec.wait_secs(duration) - # Impose an extra delay on the "stop" action + if wait_for_last: + self.ec.wait_secs(duration) + else: + extra_delay += duration stim.stop(wait=False, extra_delay=extra_delay) def play(self): diff --git a/expyfun/_tdt_controller.py b/expyfun/_tdt_controller.py index 86e61f84..89ac7276 100644 --- a/expyfun/_tdt_controller.py +++ b/expyfun/_tdt_controller.py @@ -288,7 +288,8 @@ def _set_delay(self, delay, delay_trig): logger.info('Expyfun: Setting TDT trigger delay to %s' % delay_trig) # ############################### TRIGGER METHODS ############################# - def stamp_triggers(self, triggers, delay=None, wait_for_last=True): + def stamp_triggers(self, triggers, delay=None, wait_for_last=True, + is_trial_id=False): """Stamp a list of triggers with a given inter-trigger delay. Parameters @@ -301,6 +302,8 @@ def stamp_triggers(self, triggers, delay=None, wait_for_last=True): If None, will use twice the trigger duration (50% duty cycle). wait_for_last : bool If True, wait for last trigger to be stamped before returning. + is_trial_id : bool + No effect for this controller. """ if delay is None: delay = 0.02 # we have a fixed trig duration of 0.01 diff --git a/expyfun/_trigger_controllers.py b/expyfun/_trigger_controllers.py index b445911e..b735e846 100644 --- a/expyfun/_trigger_controllers.py +++ b/expyfun/_trigger_controllers.py @@ -106,7 +106,8 @@ def _stamp_trigger(self, trig): self.ec.wait_secs(self.trigger_duration) self._set_data(0) - def stamp_triggers(self, triggers, delay=None, wait_for_last=True): + def stamp_triggers(self, triggers, delay=None, wait_for_last=True, + is_trial_id=False): """Stamp a list of triggers with a given inter-trigger delay. Parameters @@ -119,6 +120,8 @@ def stamp_triggers(self, triggers, delay=None, wait_for_last=True): If None, will use twice the trigger duration (50% duty cycle). wait_for_last : bool If True, wait for last trigger to be stamped before returning. + is_trial_id : bool + No effect for this trigger controller. """ if delay is None: delay = 2 * self.trigger_duration diff --git a/expyfun/_utils.py b/expyfun/_utils.py index f0f2a0fd..1d646351 100644 --- a/expyfun/_utils.py +++ b/expyfun/_utils.py @@ -13,6 +13,7 @@ import os.path as op import inspect import sys +import time import tempfile import traceback import ssl @@ -588,6 +589,7 @@ def get_config_path(): 'SOUND_CARD_TRIGGER_CHANNELS', 'SOUND_CARD_TRIGGER_INSERTION', 'SOUND_CARD_TRIGGER_SCALE', + 'SOUND_CARD_TRIGGER_ID_AFTER_ONSET', 'TDT_CIRCUIT_PATH', 'TDT_DELAY', 'TDT_INTERFACE', @@ -765,10 +767,13 @@ def _wait_secs(secs, ec=None): while (clock() - t0) < secs: ec._dispatch_events() ec.check_force_quit() + time.sleep(0.0001) else: wins = _get_display().get_windows() - for win in wins: - win.dispatch_events() + while (clock() - t0) < secs: + for win in wins: + win.dispatch_events() + time.sleep(0.0001) def running_rms(signal, win_length):