From e229746eda719c40ad312cf5850a86382c2bce1b Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Wed, 12 Jan 2022 15:18:12 -0500 Subject: [PATCH 01/11] BUG: Fix video on Windows for Pyglet > 1.3 (#439) * WIP: Fix video * FIX: Alpha * FIX: Add check * FIX: Newer Pyglet * FIX: Revert --- examples/stimuli/simple_video.py | 1 + expyfun/_experiment_controller.py | 7 ++++--- expyfun/visual/_visual.py | 1 + 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/examples/stimuli/simple_video.py b/examples/stimuli/simple_video.py index 53cf394a..335da5bd 100644 --- a/examples/stimuli/simple_video.py +++ b/examples/stimuli/simple_video.py @@ -33,6 +33,7 @@ screenshot = ec.screenshot() if building_doc: break + ec.check_force_quit() ec.delete_video() ec.flip() ec.screen_prompt('video over', max_wait=1.) diff --git a/expyfun/_experiment_controller.py b/expyfun/_experiment_controller.py index 17872ac4..396d3d94 100644 --- a/expyfun/_experiment_controller.py +++ b/expyfun/_experiment_controller.py @@ -890,13 +890,13 @@ def delete_video(self): self.video = None # ############################### PYGLET EVENTS ############################### +# https://pyglet.readthedocs.io/en/latest/programming_guide/eventloop.html#dispatching-events-manually # noqa def _setup_event_loop(self): from pyglet.app import platform_event_loop, event_loop event_loop.has_exit = False platform_event_loop.start() event_loop.dispatch_event('on_enter') - event_loop.is_running = True self._extra_cleanup_fun.append(self._end_event_loop) # This is when Pyglet calls: @@ -904,11 +904,12 @@ def _setup_event_loop(self): # which is a while loop with the contents of our dispatch_events. def _dispatch_events(self): - from pyglet.app import platform_event_loop + import pyglet + pyglet.clock.tick() self._win.dispatch_events() # timeout = self._event_loop.idle() timeout = 0 - platform_event_loop.step(timeout) + pyglet.app.platform_event_loop.step(timeout) def _end_event_loop(self): from pyglet.app import platform_event_loop, event_loop diff --git a/expyfun/visual/_visual.py b/expyfun/visual/_visual.py index 8a9af759..204ea920 100644 --- a/expyfun/visual/_visual.py +++ b/expyfun/visual/_visual.py @@ -1080,6 +1080,7 @@ def get_rect(self, units='norm'): void main() { gl_FragColor = texture2DRect(u_texture, v_texcoord); + gl_FragColor.a = 1.0; } ''' From 4e1f10bed45a125dfd06aac981658abc22c04028 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 18 Jan 2022 20:53:13 -0500 Subject: [PATCH 02/11] ENH: Prefer FFmpegDecoder for video (#441) --- expyfun/visual/_visual.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/expyfun/visual/_visual.py b/expyfun/visual/_visual.py index 204ea920..f40203b9 100644 --- a/expyfun/visual/_visual.py +++ b/expyfun/visual/_visual.py @@ -1127,7 +1127,17 @@ def __init__(self, ec, file_name, pos=(0, 0), units='norm', scale=1., center=True, visible=True): from pyglet.media import load, Player self._ec = ec - self._source = load(file_name) + # On Windows, the default is unaccelerated WMF, which is terribly slow. + decoder = None + if _new_pyglet(): + try: + from pyglet.media.codecs.ffmpeg import FFmpegDecoder + decoder = FFmpegDecoder() + except Exception as exc: + warnings.warn( + 'FFmpeg decoder could not be instantiated, decoding ' + f'performance could be compromised:\n{exc}') + self._source = load(file_name, decoder=decoder) self._player = Player() with warnings.catch_warnings(record=True): # deprecated eos_action self._player.queue(self._source) From 93a585459d7cbd387d3161889c7249944649be93 Mon Sep 17 00:00:00 2001 From: Steven Bierer <40672003+NeuroLaunch@users.noreply.github.com> Date: Thu, 20 Jan 2022 06:49:44 -0800 Subject: [PATCH 03/11] Doc updates addressing #431 (#442) --- .gitignore | 1 + doc/getting_started.rst | 16 +++++++++++----- environment_test.yml | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index c9cdeb81..128a90ef 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ *.orig .vscode doc/generated +.DS_Store # C extensions *.so diff --git a/doc/getting_started.rst b/doc/getting_started.rst index 158a4b92..1f5d1a19 100644 --- a/doc/getting_started.rst +++ b/doc/getting_started.rst @@ -53,7 +53,7 @@ To get started quickly, this should suffice for conda users on most systems: .. code-block:: console - $ conda create -n expy mne pyglet -c conda-forge + $ conda create -n expy mne "pyglet<1.6" -c conda-forge $ conda activate expy $ pip install pyparallel rtmixer $ pip install git+https://github.com/labsn/expyfun @@ -66,28 +66,34 @@ do this instead: $ conda create -n expy python=3 numpy scipy matplotlib pandas h5py joblib pillow $ conda activate expy - $ pip install mne pyglet pyparallel rtmixer + $ pip install mne "pyglet<1.6" pyparallel rtmixer $ pip install git+https://github.com/labsn/expyfun If you prefer using pip for everything, here are the minimum requirements: .. code-block:: console - $ pip install mne matplotlib pyglet pillow + $ pip install mne matplotlib "pyglet<1.6" pillow $ pip install git+https://github.com/labsn/expyfun and this does a full pip install of all required and optional dependencies: .. code-block:: console - $ pip install mne matplotlib pyglet pillow rtmixer pyparallel pandas joblib h5py TDTPy + $ pip install mne matplotlib "pyglet<1.6" pillow + $ pip install rtmixer pyparallel pandas joblib h5py TDTPy $ pip install git+https://github.com/labsn/expyfun +Note that the pyglet package for the recommended installs is constrained to version 1.5, as this +will be the last version compatible with legacy OpenGL (see pypi.org/project/pyglet/). If +you prefer to download pyglet via its github repository, please use the pyglet-1.5-maintenance +branch. + Expyfun ^^^^^^^ The recommended way to install expyfun on -development machines is to ``git clone`` the reposity then do: +development machines is to ``git clone`` the repository then do: .. code-block:: console diff --git a/environment_test.yml b/environment_test.yml index 1d356b0e..e84c914e 100644 --- a/environment_test.yml +++ b/environment_test.yml @@ -12,7 +12,7 @@ dependencies: - pillow - joblib - pip: - - pyglet + - pyglet<1.6 - codecov - pytest-sugar - numpydoc From 6c11b8ac5d0b3c3576c1a93ee49708e8e5c68079 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 14 Feb 2022 17:13:49 -0500 Subject: [PATCH 04/11] MAINT: Azure (#443) * MAINT: Azure * FIX: GH * FIX: Try again * FIX: Switch * FIX: Try again * FIX: Linux? * FIX: Env * FIX: jackd * FIX: Dash * FIX: Another * FIX: Give up * FIX: ffmpeg nowadays * FIX: ffmpeg * FIX: fine * FIX: Move back * FIX: Windows * FIX: Name * FIX: One more? * FIX: Name * FIX: Almost * FIX: Name * FIX: Name * FIX: Fine * FIX: Name * FIX: Cancel * FIX: Name * FIX: Break both? * FIX: pip * FIX: Up * FIX: Silent? * FIX: Linux * FIX: Linux * FIX: Linux * FIX: Linux * FIX: Try again --- .github/workflows/codespell_and_flake.yml | 3 + .github/workflows/compat_old.yml | 32 +++++++--- .github/workflows/linux.yml | 58 +++++++++++++++++++ .github/workflows/linux_conda.yml | 41 ------------- .github/workflows/macos_conda.yml | 15 +++-- azure-pipelines.yml | 7 ++- environment_test.yml | 15 +++-- .../_sound_controllers/_sound_controller.py | 2 + expyfun/stimuli/tests/test_stimuli.py | 2 +- expyfun/tests/test_version.py | 2 +- setup.cfg | 1 + 11 files changed, 113 insertions(+), 65 deletions(-) create mode 100644 .github/workflows/linux.yml delete mode 100644 .github/workflows/linux_conda.yml diff --git a/.github/workflows/codespell_and_flake.yml b/.github/workflows/codespell_and_flake.yml index ef483fbf..a96ab6a0 100644 --- a/.github/workflows/codespell_and_flake.yml +++ b/.github/workflows/codespell_and_flake.yml @@ -1,4 +1,7 @@ name: 'codespell_and_flake' +concurrency: + group: ${{ github.workflow }}-${{ github.event.number }}-${{ github.event.ref }} + cancel-in-progress: true on: push: branches: diff --git a/.github/workflows/compat_old.yml b/.github/workflows/compat_old.yml index fc09645e..66817b24 100644 --- a/.github/workflows/compat_old.yml +++ b/.github/workflows/compat_old.yml @@ -1,4 +1,7 @@ -name: 'compat / old' +name: 'compat' +concurrency: + group: ${{ github.workflow }}-${{ github.event.number }}-${{ github.event.ref }} + cancel-in-progress: true on: push: branches: @@ -9,33 +12,46 @@ on: jobs: job: - name: 'py3.7' + name: conda ${{ matrix.python }} runs-on: ubuntu-20.04 defaults: run: shell: bash -el {0} + strategy: + matrix: + python: ['3.7'] env: DISPLAY: ':99.0' - _EXPYFUN_SILENT: 'true' steps: - uses: actions/checkout@v2 + name: Checkout - run: /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1400x900x24 -ac +extension GLX +render -noreset - - run: sudo apt-get install libglu1-mesa dbus-x11 + name: Start Xvfb + - run: sudo apt update -q && sudo apt install -q libglu1-mesa + name: Install system dependencies - uses: conda-incubator/setup-miniconda@v2 with: activate-environment: 'test' - python-version: '3.7' + python-version: ${{ matrix.python }} environment-file: 'environment_test.yml' name: 'Setup conda' - - run: conda remove -n test pandas h5py && pip install "pyglet<1.4" - name: Make minimal - - run: git clone --depth=1 git://github.com/LABSN/sound-ci-helpers.git && sound-ci-helpers/linux/setup_sound.sh + - run: | + set -e + conda remove -n test pandas h5py + pip install sounddevice rtmixer "pyglet<1.4" + name: Dependencies + - run: git clone --depth=1 git://github.com/LABSN/sound-ci-helpers.git && sound-ci-helpers/auto.sh name: Get sound working - run: python -m sounddevice + name: List sound devices - run: python -c "import pyglet; print(pyglet.version)" + name: Print Pyglet version - run: python -c "import matplotlib.pyplot as plt" + name: Make sure matplotlib works - run: python setup.py develop + name: Install - run: pytest --tb=short --cov=expyfun --cov-report=xml expyfun + name: Pytest - uses: codecov/codecov-action@v1 if: success() name: 'Codecov' diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml new file mode 100644 index 00000000..d5cbb604 --- /dev/null +++ b/.github/workflows/linux.yml @@ -0,0 +1,58 @@ +name: 'linux' +concurrency: + group: ${{ github.workflow }}-${{ github.event.number }}-${{ github.event.ref }} + cancel-in-progress: true +on: + push: + branches: + - '*' + pull_request: + branches: + - '*' + +jobs: + job: + name: pip ${{ matrix.python }} + runs-on: ubuntu-20.04 + defaults: + run: + shell: bash -el {0} + strategy: + matrix: + python: ['3.10'] + env: + DISPLAY: ':99.0' + _EXPYFUN_SILENT: 'true' + SOUND_CARD_BACKEND: 'pyglet' + steps: + - uses: actions/checkout@v2 + name: Checkout + - run: /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1400x900x24 -ac +extension GLX +render -noreset + name: Start Xvfb + - run: sudo apt update -q && sudo apt install -q libavutil56 libavcodec58 libavformat58 libswscale5 libglu1-mesa gstreamer1.0-alsa gstreamer1.0-libav python3-gst-1.0 + name: Install system dependencies + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.PYTHON_VERSION }} + name: 'Setup python' + - run: pip install --upgrade pip setuptools wheel + name: Upgrade pip + - run: pip install --upgrade sounddevice rtmixer "pyglet<1.6" pyglet_ffmpeg scipy matplotlib pandas h5py coverage mne numpydoc pytest pytest-cov pytest-timeout pillow joblib codecov + name: Dependencies + - run: git clone --depth=1 git://github.com/LABSN/sound-ci-helpers.git && sound-ci-helpers/auto.sh + name: Get sound working + - run: python -m sounddevice + name: List sound devices + - run: python -c "import pyglet; print(pyglet.version)" + name: Print Pyglet version + - run: python -c "import matplotlib.pyplot as plt" + name: Make sure matplotlib works + - run: pip install -ve . + name: Install + - run: python -c "import expyfun; expyfun._utils._has_video(raise_error=True)" + name: Check video + - run: pytest --tb=short --cov=expyfun --cov-report=xml expyfun + name: Pytest + - uses: codecov/codecov-action@v1 + if: success() + name: Codecov diff --git a/.github/workflows/linux_conda.yml b/.github/workflows/linux_conda.yml deleted file mode 100644 index 6ed9f003..00000000 --- a/.github/workflows/linux_conda.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: 'linux / conda' -on: - push: - branches: - - '*' - pull_request: - branches: - - '*' - -jobs: - # Linux - job: - name: 'py3.9' - runs-on: ubuntu-20.04 - defaults: - run: - shell: bash -el {0} - env: - DISPLAY: ':99.0' - _EXPYFUN_SILENT: 'true' - steps: - - uses: actions/checkout@v2 - - run: /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1400x900x24 -ac +extension GLX +render -noreset - - run: sudo apt-get install libavutil56 libavcodec58 libavformat58 libswscale5 libglu1-mesa dbus-x11 - - uses: conda-incubator/setup-miniconda@v2 - with: - activate-environment: 'test' - python-version: '3.9' - environment-file: 'environment_test.yml' - name: 'Setup conda' - - run: git clone --depth=1 git://github.com/LABSN/sound-ci-helpers.git && sound-ci-helpers/linux/setup_sound.sh - name: Get sound working - - run: python -m sounddevice - - run: python -c "import pyglet; print(pyglet.version)" - - run: python -c "import matplotlib.pyplot as plt" - - run: python setup.py develop - - run: python -c "import expyfun; expyfun._utils._has_video(raise_error=True)" - - run: pytest --tb=short --cov=expyfun --cov-report=xml expyfun - - uses: codecov/codecov-action@v1 - if: success() - name: 'Codecov' diff --git a/.github/workflows/macos_conda.yml b/.github/workflows/macos_conda.yml index b1beb99c..16a8039a 100644 --- a/.github/workflows/macos_conda.yml +++ b/.github/workflows/macos_conda.yml @@ -1,4 +1,7 @@ -name: 'macos / conda' +name: 'macos' +concurrency: + group: ${{ github.workflow }}-${{ github.event.number }}-${{ github.event.ref }} + cancel-in-progress: true on: push: branches: @@ -9,8 +12,11 @@ on: jobs: job: - name: 'py3.9' + name: conda ${{ matrix.python }} runs-on: macos-latest + strategy: + matrix: + python: ['3.10'] defaults: run: shell: bash -el {0} @@ -19,10 +25,11 @@ jobs: - uses: conda-incubator/setup-miniconda@v2 with: activate-environment: 'test' - python-version: '3.9' + python-version: ${{ matrix.python }} environment-file: 'environment_test.yml' name: 'Setup conda' - - run: git clone --depth=1 git://github.com/LABSN/sound-ci-helpers.git && brew install ffmpeg && sound-ci-helpers/macos/setup_sound.sh + - run: pip install sounddevice rtmixer "pyglet<1.6" + - run: git clone --depth=1 git://github.com/LABSN/sound-ci-helpers.git && sound-ci-helpers/auto.sh name: Get sound working - run: python -m sounddevice - run: python -c "import pyglet; print(pyglet.version)" diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 19b9286b..0182c3ed 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -32,13 +32,16 @@ stages: jobs: - job: Windows pool: - vmIMage: 'VS2017-Win2016' + vmIMage: 'windows-latest' variables: MNE_LOGGING_LEVEL: 'warning' MNE_FORCE_SERIAL: 'true' PIP_DEPENDENCIES: 'codecov' OPENBLAS_NUM_THREADS: 1 AZURE_CI_WINDOWS: 'true' + SOUND_CARD_NAME: 'Speakers' + SOUND_CARD_FS: '44100' + SOUND_CARD_API: 'MME' strategy: maxParallel: 4 matrix: @@ -74,7 +77,7 @@ stages: - bash: | set -e git clone --depth 1 git://github.com/LABSN/sound-ci-helpers.git - powershell sound-ci-helpers/windows/setup_sound.ps1 + sound-ci-helpers/auto.sh pip install -q --user rtmixer python -m sounddevice displayName: 'Install rtmixer' diff --git a/environment_test.yml b/environment_test.yml index e84c914e..8ce8f254 100644 --- a/environment_test.yml +++ b/environment_test.yml @@ -1,4 +1,6 @@ name: test +channels: +- conda-forge dependencies: - scipy - matplotlib @@ -6,16 +8,13 @@ dependencies: - h5py - coverage - setuptools +- mne-base +- numpydoc - pytest - pytest-cov - pytest-timeout - pillow - joblib -- pip: - - pyglet<1.6 - - codecov - - pytest-sugar - - numpydoc - - sounddevice - - rtmixer - - mne +- ffmpeg +- codecov +# Do pip separately diff --git a/expyfun/_sound_controllers/_sound_controller.py b/expyfun/_sound_controllers/_sound_controller.py index 0aa1ab41..10187fce 100644 --- a/expyfun/_sound_controllers/_sound_controller.py +++ b/expyfun/_sound_controllers/_sound_controller.py @@ -102,6 +102,8 @@ def __init__(self, params, stim_fs, n_channels=2, trigger_duration=0.01, SOUND_CARD_DRIFT_TRIGGER='end', ) # any omitted become None params = _check_params(params, _SOUND_CARD_KEYS, defaults, 'params') + if params['SOUND_CARD_FS'] is not None: + params['SOUND_CARD_FS'] = float(params['SOUND_CARD_FS']) self.backend, self.backend_name = _import_backend( params['SOUND_CARD_BACKEND']) diff --git a/expyfun/stimuli/tests/test_stimuli.py b/expyfun/stimuli/tests/test_stimuli.py index cbad7417..c1432c9d 100644 --- a/expyfun/stimuli/tests/test_stimuli.py +++ b/expyfun/stimuli/tests/test_stimuli.py @@ -147,7 +147,7 @@ def test_rms(): assert_array_almost_equal(rms(np.ones((100, 2)) * 2, 0), [2, 2]) -@pytest.mark.timeout(30) # can be slow to load on CIs +@pytest.mark.timeout(60) # can be slow to load on CIs @requires_lib('mne') def test_crm(tmpdir): """Test CRM Corpus functions.""" diff --git a/expyfun/tests/test_version.py b/expyfun/tests/test_version.py index 66fb280e..98346128 100644 --- a/expyfun/tests/test_version.py +++ b/expyfun/tests/test_version.py @@ -11,7 +11,7 @@ from expyfun._git import _has_git -@pytest.mark.timeout(30) # can be slow to download +@pytest.mark.timeout(60) # can be slow to download def test_version_assertions(): """Test version assertions.""" pytest.raises(TypeError, assert_version, 1) diff --git a/setup.cfg b/setup.cfg index c86af328..5a1b0467 100644 --- a/setup.cfg +++ b/setup.cfg @@ -50,6 +50,7 @@ filterwarnings = ignore:.*distutils Version classes are deprecated.*:DeprecationWarning ignore:.*distutils\.sysconfig module is deprecated.*:DeprecationWarning ignore:.*isSet\(\) is deprecated.*:DeprecationWarning + always:.*Exception ignored in.*__del__.*: [flake8] exclude = __init__.py,decorator.py,ndarraysource.py From d6e602d98a7a95a54cacf1fe04645d5c1c921853 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 31 Oct 2022 17:33:38 -0400 Subject: [PATCH 05/11] MAINT: Fix CIs (#448) --- .github/workflows/compat_old.yml | 2 +- .github/workflows/linux.yml | 2 +- .github/workflows/macos_conda.yml | 2 +- appveyor.yml | 39 ------------------- azure-pipelines.yml | 16 ++++---- expyfun/_experiment_controller.py | 15 ++++++- expyfun/_git.py | 2 +- expyfun/_input_controllers.py | 4 +- expyfun/_sound_controllers/_rtmixer.py | 30 ++++++++++++-- .../_sound_controllers/_sound_controller.py | 6 ++- expyfun/_utils.py | 2 +- expyfun/analyze/_viz.py | 2 +- expyfun/codeblocks/_pupillometry.py | 2 +- expyfun/stimuli/_stimuli.py | 4 +- expyfun/stimuli/_tracker.py | 2 +- expyfun/stimuli/_vocoder.py | 8 ++-- expyfun/stimuli/tests/test_stimuli.py | 14 +++++-- expyfun/stimuli/tests/test_tracker.py | 18 ++++----- expyfun/tests/test_experiment_controller.py | 8 +++- expyfun/tests/test_parallel.py | 1 + git_flow.rst | 22 +++++------ ignore_words.txt | 1 + 22 files changed, 105 insertions(+), 97 deletions(-) delete mode 100644 appveyor.yml diff --git a/.github/workflows/compat_old.yml b/.github/workflows/compat_old.yml index 66817b24..ba2a6360 100644 --- a/.github/workflows/compat_old.yml +++ b/.github/workflows/compat_old.yml @@ -40,7 +40,7 @@ jobs: conda remove -n test pandas h5py pip install sounddevice rtmixer "pyglet<1.4" name: Dependencies - - run: git clone --depth=1 git://github.com/LABSN/sound-ci-helpers.git && sound-ci-helpers/auto.sh + - run: git clone --depth=1 https://github.com/LABSN/sound-ci-helpers.git && sound-ci-helpers/auto.sh name: Get sound working - run: python -m sounddevice name: List sound devices diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index d5cbb604..9c71e81d 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -39,7 +39,7 @@ jobs: name: Upgrade pip - run: pip install --upgrade sounddevice rtmixer "pyglet<1.6" pyglet_ffmpeg scipy matplotlib pandas h5py coverage mne numpydoc pytest pytest-cov pytest-timeout pillow joblib codecov name: Dependencies - - run: git clone --depth=1 git://github.com/LABSN/sound-ci-helpers.git && sound-ci-helpers/auto.sh + - run: git clone --depth=1 https://github.com/LABSN/sound-ci-helpers.git && sound-ci-helpers/auto.sh name: Get sound working - run: python -m sounddevice name: List sound devices diff --git a/.github/workflows/macos_conda.yml b/.github/workflows/macos_conda.yml index 16a8039a..f43fb45a 100644 --- a/.github/workflows/macos_conda.yml +++ b/.github/workflows/macos_conda.yml @@ -29,7 +29,7 @@ jobs: environment-file: 'environment_test.yml' name: 'Setup conda' - run: pip install sounddevice rtmixer "pyglet<1.6" - - run: git clone --depth=1 git://github.com/LABSN/sound-ci-helpers.git && sound-ci-helpers/auto.sh + - run: git clone --depth=1 https://github.com/LABSN/sound-ci-helpers.git && sound-ci-helpers/auto.sh name: Get sound working - run: python -m sounddevice - run: python -c "import pyglet; print(pyglet.version)" diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 4cabffdf..00000000 --- a/appveyor.yml +++ /dev/null @@ -1,39 +0,0 @@ -environment: - matrix: - - PYTHON: "C:\\Python37-x64" - PYTHON_ARCH: "64" - -platform: - -x64 - -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!=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" - - "pip install rtmixer" - - "python -m sounddevice" - # OpenGL (should provide a Gallium driver) - - "git clone --depth 1 git://github.com/pyvista/gl-ci-helpers.git" - - "powershell gl-ci-helpers/appveyor/install_opengl.ps1" - - "python -c \"import pyglet; r = pyglet.gl.gl_info.get_renderer(); print(r); assert 'gallium' in r.lower()\"" - # expyfun - - "powershell make/get_video.ps1" - - "python setup.py develop" - -build: false # Not a C# project, build stuff at the test step instead. - -test_script: - # Ensure that video works - - "python -c \"from ctypes import cdll; print(cdll.LoadLibrary('avcodec-58'))\"" - - "python -c \"from ctypes import cdll; print(cdll.LoadLibrary('avformat-58'))\"" - - "python -c \"import expyfun; assert expyfun._utils._has_video(raise_error=True)\"" - # Run the project tests - - "pytest -n 1 --tb=short --cov=expyfun expyfun" - -on_success: - - "codecov" diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 0182c3ed..3e8d635b 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -40,15 +40,15 @@ stages: OPENBLAS_NUM_THREADS: 1 AZURE_CI_WINDOWS: 'true' SOUND_CARD_NAME: 'Speakers' - SOUND_CARD_FS: '44100' - SOUND_CARD_API: 'MME' + SOUND_CARD_FS: '48000' + SOUND_CARD_API: 'Windows WDM-KS' strategy: maxParallel: 4 matrix: Python37: PYTHON_VERSION: '3.7' Python39: - PYTHON_VERSION: '3.9' + PYTHON_VERSION: '3.10' steps: - task: UsePythonVersion@0 inputs: @@ -57,12 +57,12 @@ stages: addToPath: true - script: echo "##vso[task.prependpath]C:\Users\VssAdministrator\AppData\Roaming\Python\Python39\site-packages\pywin32_system32;" displayName: Add local bin to PATH - condition: in(variables['PYTHON_VERSION'], '3.9') + condition: in(variables['PYTHON_VERSION'], '3.10') - bash: | set -e pip install --user --upgrade pip setuptools wheel pip install --user --upgrade numpy scipy matplotlib - if [[ "$PYTHON_VERSION" == "3.9" ]]; then + if [[ "$PYTHON_VERSION" == "3.10" ]]; then # Until https://github.com/pyglet/pyglet/pull/516 is reverted or fixed, we need to use an older one pip install --user --upgrade https://github.com/pyglet/pyglet/zipball/pyglet-1.5-maintenance else @@ -76,14 +76,14 @@ stages: displayName: 'Install pip dependencies' - bash: | set -e - git clone --depth 1 git://github.com/LABSN/sound-ci-helpers.git + git clone --depth 1 https://github.com/LABSN/sound-ci-helpers.git sound-ci-helpers/auto.sh pip install -q --user rtmixer python -m sounddevice displayName: 'Install rtmixer' - bash: | set -e - git clone --depth 1 git://github.com/pyvista/gl-ci-helpers.git + git clone --depth 1 https://github.com/pyvista/gl-ci-helpers.git powershell gl-ci-helpers/appveyor/install_opengl.ps1 python -c "import pyglet; r = pyglet.gl.gl_info.get_renderer(); print(r); assert 'gallium' in r.lower()" displayName: 'Get OpenGL' @@ -100,7 +100,7 @@ stages: python setup.py develop displayName: 'Install' - bash: | - pytest --tb=short --cov=expyfun expyfun + pytest --tb=short --cov=expyfun expyfun -x displayName: 'Run tests' - bash: | codecov --root %BUILD_REPOSITORY_LOCALPATH% -t %CODECOV_TOKEN% diff --git a/expyfun/_experiment_controller.py b/expyfun/_experiment_controller.py index 396d3d94..d828b342 100644 --- a/expyfun/_experiment_controller.py +++ b/expyfun/_experiment_controller.py @@ -1717,9 +1717,15 @@ def _playing(self): """Whether or not a stimulus is currently playing""" return self._ac.playing - def stop(self): + def stop(self, wait=False): """Stop audio buffer playback and reset cursor to beginning of buffer + Parameters + ---------- + wait : bool + If True, try to wait until the end of the sound stimulus + (not guaranteed to yield accurate timings!). + See Also -------- ExperimentController.load_buffer @@ -1728,7 +1734,7 @@ def stop(self): ExperimentController.start_stimulus """ if self._ac is not None: # need to check b/c used in __exit__ - self._ac.stop() + self._ac.stop(wait=wait) self.write_data_line('stop') logger.exp('Expyfun: Audio stopped and reset.') @@ -2231,6 +2237,11 @@ def _fs_mismatch(self): """ return not np.allclose(self.stim_fs, self.fs, rtol=0, atol=0.5) + # Testing cruft to work around "queue full" errors on Windows + def _ac_flush(self): + if isinstance(getattr(self, '_ac', None), SoundCardController): + self._ac.halt() + def get_keyboard_input(prompt, default=None, out_type=str, valid=None): """Get keyboard input of a specific type diff --git a/expyfun/_git.py b/expyfun/_git.py index 5c66a215..16257b90 100644 --- a/expyfun/_git.py +++ b/expyfun/_git.py @@ -66,7 +66,7 @@ def download_version(version='current', dest_dir=None): # fetch locally and get the proper version tempdir = _TempDir() expyfun_dir = op.join(tempdir, 'expyfun') # git will auto-create this dir - repo_url = 'git://github.com/LABSN/expyfun.git' + repo_url = 'https://github.com/LABSN/expyfun.git' run_subprocess(['git', 'clone', repo_url, expyfun_dir]) version = _active_version(expyfun_dir) if version == 'current' else version try: diff --git a/expyfun/_input_controllers.py b/expyfun/_input_controllers.py index 5fb7a082..046c44cb 100644 --- a/expyfun/_input_controllers.py +++ b/expyfun/_input_controllers.py @@ -149,7 +149,7 @@ def wait_one_press(self, max_wait, min_wait, live_keys, timestamp, Parameters ---------- max_wait : float - Maxmimum time to wait. + Maximum time to wait. min_wait : float Minimum time to wait. live_keys : list | None @@ -186,7 +186,7 @@ def wait_for_presses(self, max_wait, min_wait, live_keys, Parameters ---------- max_wait : float - Maxmimum time to wait. + Maximum time to wait. min_wait : float Minimum time to wait. live_keys : list | None diff --git a/expyfun/_sound_controllers/_rtmixer.py b/expyfun/_sound_controllers/_rtmixer.py index 233f6b60..dc38ab5b 100644 --- a/expyfun/_sound_controllers/_rtmixer.py +++ b/expyfun/_sound_controllers/_rtmixer.py @@ -110,7 +110,8 @@ def _init_mixer(fs, n_channels, api, name, api_options=None): dither_off=True, device=di, extra_settings=extra_settings) except Exception as exp: - raise RuntimeError('Could not set up %s:\n%s' % (param_str, exp)) + raise RuntimeError( + f'Could not set up {param_str}:\n{exp}') from None assert mixer.channels == n_channels if fs is None: param_str += ' @ %d Hz' % (mixer.samplerate,) @@ -195,9 +196,30 @@ def delete(self): if getattr(self, '_mixer', None) is not None: self.stop(wait=False) mixer, self._mixer = self._mixer, None - stats = mixer.fetch_and_reset_stats().stats - logger.exp('%d underflows %d blocks' - % (stats.output_underflows, stats.blocks)) + try: + stats = mixer.fetch_and_reset_stats().stats + except RuntimeError as exc: # action queue is full + logger.exp(f'Could not fetch mixer stats ({exc})') + else: + logger.exp( + f'{stats.output_underflows} underflows ' + f'{stats.blocks} blocks') def __del__(self): # noqa self.delete() + + +def _abort_all_queues(): + for mixer in _mixer_registry.values(): + if len(mixer.actions) == 0: + continue + do_start_stop = mixer.stopped + if do_start_stop: + mixer.start() + for action in list(mixer.actions): + mixer.wait(mixer.cancel(action)) + mixer.wait() + assert len(mixer.actions) == 0, mixer.actions + if do_start_stop: + mixer.abort(ignore_errors=False) + assert len(mixer.actions) == 0, mixer.actions diff --git a/expyfun/_sound_controllers/_sound_controller.py b/expyfun/_sound_controllers/_sound_controller.py index 10187fce..fd7ec85b 100644 --- a/expyfun/_sound_controllers/_sound_controller.py +++ b/expyfun/_sound_controllers/_sound_controller.py @@ -150,8 +150,8 @@ def __init__(self, params, stim_fs, n_channels=2, trigger_duration=0.01, 'fs', 'api', 'name', 'fixed_delay', 'api_options')} temp_sound = np.zeros((self._n_channels_tot, 1000)) temp_sound = self.backend.SoundPlayer(temp_sound, **self._kwargs) - self.fs = temp_sound.fs - temp_sound.stop(wait=False) + self.fs = float(temp_sound.fs) + self._mixer = getattr(temp_sound, '_mixer', None) del temp_sound # Need to generate at RMS=1 to match TDT circuit, and use a power of @@ -404,6 +404,8 @@ def halt(self): """Halt.""" self.stop(wait=True) self.stop_noise(wait=True) + abort_all = getattr(self.backend, '_abort_all_queues', lambda: None) + abort_all() def _import_backend(backend): diff --git a/expyfun/_utils.py b/expyfun/_utils.py index 412191d8..ffd105f3 100644 --- a/expyfun/_utils.py +++ b/expyfun/_utils.py @@ -412,7 +412,7 @@ def verbose_dec(function, *args, **kwargs): else: default_level = None - if('verbose' in arg_names): + if 'verbose' in arg_names: verbose_level = args[arg_names.index('verbose')] else: verbose_level = default_level diff --git a/expyfun/analyze/_viz.py b/expyfun/analyze/_viz.py index c83ee779..362a448b 100644 --- a/expyfun/analyze/_viz.py +++ b/expyfun/analyze/_viz.py @@ -63,7 +63,7 @@ def format_pval(pval, latex=True, scheme='default'): brk_r + wrap for x in expon[pval < 0.0001]] if single_value: pv = pv[0] - return(pv) + return pv def _instantiate(obj, typ): diff --git a/expyfun/codeblocks/_pupillometry.py b/expyfun/codeblocks/_pupillometry.py index 021094ec..3477d769 100644 --- a/expyfun/codeblocks/_pupillometry.py +++ b/expyfun/codeblocks/_pupillometry.py @@ -182,7 +182,7 @@ def find_pupil_tone_impulse_response(ec, el, bgcolor, fcolor, prompt=True, n_targs = int(targ_prop * n_stimuli) targs = np.zeros(n_stimuli, bool) targs[np.linspace(0, n_stimuli - 1, n_targs + 2)[1:-1].astype(int)] = True - while(True): # ensure we randomize but don't start with a target + while True: # ensure we randomize but don't start with a target idx = rng.permutation(np.arange(n_stimuli)) isis = isis[idx] targs = targs[idx] diff --git a/expyfun/stimuli/_stimuli.py b/expyfun/stimuli/_stimuli.py index 627e8e77..4f4689a5 100644 --- a/expyfun/stimuli/_stimuli.py +++ b/expyfun/stimuli/_stimuli.py @@ -163,8 +163,8 @@ def add_pad(sounds, alignment='start'): will be 2-dimensional (channels, samples). """ if alignment not in ['start', 'center', 'end']: - raise(ValueError("alignment must be either 'start', 'center', " - "or 'end'")) + raise ValueError("alignment must be either 'start', 'center', " + "or 'end'") x = [np.atleast_2d(y) for y in sounds] if not np.all(y.ndim == 2 for y in x): raise ValueError('Sound data must have no more than 2 dimensions.') diff --git a/expyfun/stimuli/_tracker.py b/expyfun/stimuli/_tracker.py index f14832ed..3371d6e6 100644 --- a/expyfun/stimuli/_tracker.py +++ b/expyfun/stimuli/_tracker.py @@ -877,7 +877,7 @@ def next(self): The level of the selected tracker. """ if self.stopped: - raise(StopIteration) + raise StopIteration if not self._trial_complete: # Chose a new tracker before responding, so record non-response self._response_history = np.append(self._response_history, diff --git a/expyfun/stimuli/_vocoder.py b/expyfun/stimuli/_vocoder.py index a3d6271a..321f05b0 100644 --- a/expyfun/stimuli/_vocoder.py +++ b/expyfun/stimuli/_vocoder.py @@ -65,7 +65,7 @@ def get_band_freqs(fs, n_bands=16, freq_lims=(200., 8000.), scale='erb', delta = np.diff(freq_lims) / n_bands cutoffs = freq_lims[0] + delta * np.arange(n_bands + 1) edges = zip(cutoffs[:-1], cutoffs[1:]) - return(edges) + return edges def get_bands(data, fs, edges, order=2, zero_phase=False, axis=-1): @@ -105,7 +105,7 @@ def get_bands(data, fs, edges, order=2, zero_phase=False, axis=-1): band = filt(b, a, data, axis=axis) bands.append(band) filts.append((b, a)) - return(bands, filts) + return bands, filts def get_env(data, fs, lp_order=4, lp_cutoff=160., zero_phase=False, axis=-1): @@ -140,7 +140,7 @@ def get_env(data, fs, lp_order=4, lp_cutoff=160., zero_phase=False, axis=-1): b, a = butter(lp_order, cutoff, 'lowpass') filt = filtfilt if zero_phase else lfilter env = filt(b, a, data, axis=axis) - return(env, (b, a)) + return env, (b, a) def get_carriers(data, fs, edges, order=2, axis=-1, mode='tone', rate=None, @@ -213,7 +213,7 @@ def get_carriers(data, fs, edges, order=2, axis=-1, mode='tone', rate=None, carrier /= np.sqrt(np.mean(carrier * carrier, axis=axis, keepdims=True)) # rms of 1 carrs.append(carrier) - return(carrs) + return carrs @verbose_dec diff --git a/expyfun/stimuli/tests/test_stimuli.py b/expyfun/stimuli/tests/test_stimuli.py index c1432c9d..f0939404 100644 --- a/expyfun/stimuli/tests/test_stimuli.py +++ b/expyfun/stimuli/tests/test_stimuli.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +import os + import numpy as np import pytest from numpy.testing import (assert_array_equal, assert_array_almost_equal, @@ -76,17 +78,21 @@ def test_hrtf_convolution(): assert (rmss[0] > 4 * rmss[1]) +@pytest.mark.skipif(os.getenv('AZURE_CI_WINDOWS', '') == 'true', + reason='Azure CI Windows has problems') @pytest.mark.parametrize('backend', ('auto',) + _BACKENDS) def test_play_sound(backend, hide_window): # only works if windowing works """Test playing a sound.""" _check_skip_backend(backend) + fs = 48000 data = np.zeros((2, 100)) - play_sound(data).stop() - play_sound(data[0], norm=False, wait=True) - pytest.raises(ValueError, play_sound, data[:, :, np.newaxis]) + play_sound(data, fs=fs).stop() + play_sound(data[0], norm=False, wait=True, fs=fs) + with pytest.raises(ValueError, match='sound must be'): + play_sound(data[:, :, np.newaxis], fs=fs) # Make sure each backend can handle a lot of sounds for _ in range(10): - snd = play_sound(data) + snd = play_sound(data, fs=fs) # we manually stop and delete here, because we don't want to # have to wait for our Timer instances to get around to doing # it... this also checks to make sure calling `delete()` more diff --git a/expyfun/stimuli/tests/test_tracker.py b/expyfun/stimuli/tests/test_tracker.py index b22ca737..906d6e9b 100644 --- a/expyfun/stimuli/tests/test_tracker.py +++ b/expyfun/stimuli/tests/test_tracker.py @@ -85,8 +85,8 @@ def test_tracker_ud(hide_window): with pytest.warns(UserWarning, match='exceeded x_min'): for r in responses: # run long enough to encounter change_indices tr.respond(r) - assert(tr.check_valid(1)) # make sure checking validity is good - assert(not tr.check_valid(3)) + assert tr.check_valid(1) # make sure checking validity is good + assert not tr.check_valid(3) with pytest.raises(ValueError, match="with reversals attempting to exceed x_min"): tr.threshold(1) @@ -157,8 +157,8 @@ def test_tracker_binom(hide_window): tr = TrackerBinom(None, 0.05, 0.5, 2, stop_early=False) while not tr.stopped: tr.respond(False) - assert(tr.n_trials == 2) - assert(not tr.success) + assert tr.n_trials == 2 + assert not tr.success tr = TrackerBinom(None, 0.05, 0.5, 1000) while not tr.stopped: @@ -167,7 +167,7 @@ def test_tracker_binom(hide_window): tr = TrackerBinom(None, 0.05, 0.5, 1000, 100) while not tr.stopped: tr.respond(True) - assert(tr.n_trials == 100) + assert tr.n_trials == 100 tr.alpha tr.chance @@ -212,8 +212,8 @@ def test_tracker_dealer(): rand = np.random.RandomState(0) for sub, x_current in dealer_ud: dealer_ud.respond(rand.rand() < x_current) - assert(np.abs(dealer_ud.trackers[0, 0].n_reversals - - dealer_ud.trackers[1, 0].n_reversals) <= 1) + assert np.abs(dealer_ud.trackers[0, 0].n_reversals - + dealer_ud.trackers[1, 0].n_reversals) <= 1 # test array-like indexing dealer_ud.trackers[0] @@ -268,7 +268,7 @@ def test_tracker_mhw(hide_window): rand = np.random.RandomState(0) while not tr.stopped: tr.respond(int(rand.rand() * 100) < tr.x_current) - assert(tr.check_valid(1)) # make sure checking validity is good + assert tr.check_valid(1) # make sure checking validity is good # test responding after stopped with pytest.raises(RuntimeError, match="Tracker is stopped."): tr.respond(0) @@ -324,7 +324,7 @@ def test_tracker_mhw(hide_window): with pytest.warns(UserWarning, match='exceeded x_min or x_max bounds'): for r in responses: tr.respond(r) - assert(not tr.check_valid(3)) + assert not tr.check_valid(3) tr = TrackerMHW(None, 0, 120, 5, 2, 4, 40, 2) responses = [False, False, False, False, True, False, False, True] diff --git a/expyfun/tests/test_experiment_controller.py b/expyfun/tests/test_experiment_controller.py index 22ef01d2..957d388e 100644 --- a/expyfun/tests/test_experiment_controller.py +++ b/expyfun/tests/test_experiment_controller.py @@ -22,6 +22,7 @@ std_kwargs = dict(output_dir=None, full_screen=False, window_size=(8, 8), participant='foo', session='01', stim_db=0.0, noise_db=0.0, verbose=True, version='dev') +SAFE_DELAY = 0.5 if sys.platform.startswith('win') else 0.2 def dummy_print(string): @@ -179,7 +180,7 @@ def test_degenerate(): **std_kwargs) -@pytest.mark.timeout(20) +@pytest.mark.timeout(60) def test_ec(ac, hide_window, monkeypatch): """Test EC methods.""" if ac == 'tdt': @@ -200,7 +201,6 @@ def test_ec(ac, hide_window, monkeypatch): pass w = [ww for ww in w if 'TDT is in dummy mode' in str(ww.message)] assert len(w) == (1 if ac == 'tdt' else 0) - SAFE_DELAY = 0.2 with ExperimentController( *std_args, audio_controller=ac, response_device=rd, trigger_controller=tc, stim_fs=fs, **std_kwargs) as ec: @@ -238,13 +238,16 @@ def test_ec(ac, hide_window, monkeypatch): # test buffer data handling ec.set_rms_checking(None) ec.load_buffer([0, 0, 0, 0, 0, 0]) + ec.wait_secs(SAFE_DELAY) ec.load_buffer([]) + ec.wait_secs(SAFE_DELAY) pytest.raises(ValueError, ec.load_buffer, [0, 2, 0, 0, 0, 0]) ec.load_buffer(np.zeros((100,))) with pytest.raises(ValueError, match='100 did not match .* count 2'): ec.load_buffer(np.zeros((100, 1))) with pytest.raises(ValueError, match='100 did not match .* count 2'): ec.load_buffer(np.zeros((100, 2))) + ec.wait_secs(SAFE_DELAY) ec.load_buffer(np.zeros((1, 100))) ec.load_buffer(np.zeros((2, 100))) data = np.zeros(int(5e6), np.float32) # too long for TDT @@ -304,6 +307,7 @@ def test_ec(ac, hide_window, monkeypatch): ec.set_visible(False) ec.call_on_every_flip(partial(dummy_print, 'called start stimuli')) ec.wait_secs(SAFE_DELAY) + ec._ac_flush() # Note: we put some wait_secs in here because otherwise the delay in # play start (e.g. for trigdel and onsetdel) can diff --git a/expyfun/tests/test_parallel.py b/expyfun/tests/test_parallel.py index 05c041fa..fbae0f83 100644 --- a/expyfun/tests/test_parallel.py +++ b/expyfun/tests/test_parallel.py @@ -12,6 +12,7 @@ def _identity(x): return x +@pytest.mark.timeout(15) @requires_lib('joblib') def test_parallel(): """Test parallel support.""" diff --git a/git_flow.rst b/git_flow.rst index eb64d86d..fe35dd29 100644 --- a/git_flow.rst +++ b/git_flow.rst @@ -25,7 +25,7 @@ Users will want to take the "official" version of the software, make a copy of it on their own computer, and run the code from there. Using ``expyfun`` software as an example, this is done on the command line like this:: - $ git clone git://github.com/LABSN/expyfun.git + $ git clone https://github.com/LABSN/expyfun.git $ cd expyfun $ python setup.py install @@ -48,8 +48,8 @@ command sets up a relationship between that folder on your computer and the "origin" of the code. You can see this by typing:: $ git remote -v - origin git://github.com/LABSN/expyfun.git (fetch) - origin git://github.com/LABSN/expyfun.git (push) + origin https://github.com/LABSN/expyfun.git (fetch) + origin https://github.com/LABSN/expyfun.git (push) This tells you that :bash:`git` knows about two "remote" addresses of ``expyfun``: one to ``fetch`` new changes from (if the source code gets updated @@ -117,7 +117,7 @@ connect to the official remote repo with the name ``upstream``. So after forking $ git clone git@github.com:/rkmaddox/expyfun.git $ cd expyfun - $ git remote add upstream git://github.com/LABSN/expyfun.git + $ git remote add upstream https://github.com/LABSN/expyfun.git Now this user has the standard ``origin``/``upstream`` configuration, as seen below. Note the difference in the URIs between ``origin`` and ``upstream``:: @@ -125,12 +125,12 @@ below. Note the difference in the URIs between ``origin`` and ``upstream``:: $ git remote -v origin git@github.com:/rkmaddox/expyfun.git (fetch) origin git@github.com:/rkmaddox/expyfun.git (push) - upstream git://github.com/LABSN/expyfun.git (fetch) - upstream git://github.com/LABSN/expyfun.git (push) + upstream https://github.com/LABSN/expyfun.git (fetch) + upstream https://github.com/LABSN/expyfun.git (push) $ git branch * master -URIs beginning with ``git://`` are read-only connections, so ``rkmaddox`` can +URIs beginning with ``https://`` are read-only connections, so ``rkmaddox`` can pull down new changes from ``upstream``, but won't be able to directly push his local changes to upstream. Instead, he would have to push to his fork (``origin``) first, and create a @@ -244,7 +244,7 @@ Maintainers ^^^^^^^^^^^ Maintainers start out with a similar set up as Developers_. However, they might want to be able to push directly to the ``upstream`` repo as well as pushing to -their fork. Having a repo set up with :bash:`git://` access instead of +their fork. Having a repo set up with :bash:`https://` access instead of :bash:`git@github.com` or :bash:`https://` access will not allow pushing. So starting from scratch, a maintainer ``Eric89GXL`` might fork the upstream repo and then do:: @@ -252,7 +252,7 @@ and then do:: $ git clone git@github.com:/Eric89GXL/expyfun.git $ cd expyfun $ git remote add upstream git@github.com:/LABSN/expyfun.git - $ git remote add ross git://github.com/rkmaddox/expyfun.git + $ git remote add ross https://github.com/rkmaddox/expyfun.git Now the maintainer's local repository has push/pull access to their own personal development fork and the upstream repo, and has read-only access to @@ -261,8 +261,8 @@ development fork and the upstream repo, and has read-only access to $ git remote -v origin git@github.com:/Eric89GXL/expyfun.git (fetch) origin git@github.com:/Eric89GXL/expyfun.git (push) - ross git://github.com/rkmaddox/expyfun.git (fetch) - ross git://github.com/rkmaddox/expyfun.git (push) + ross https://github.com/rkmaddox/expyfun.git (fetch) + ross https://github.com/rkmaddox/expyfun.git (push) upstream git@github.com:/LABSN/expyfun.git (fetch) upstream git@github.com:/LABSN/expyfun.git (push) diff --git a/ignore_words.txt b/ignore_words.txt index 26ce2021..2f138a8c 100644 --- a/ignore_words.txt +++ b/ignore_words.txt @@ -5,3 +5,4 @@ ang sinc fied hist +bu From 177e29b83786655dd04658a7ea6c2d9ff2e0b21e Mon Sep 17 00:00:00 2001 From: tomstoll <51489343+tomstoll@users.noreply.github.com> Date: Tue, 1 Nov 2022 12:36:45 -0400 Subject: [PATCH 06/11] ec.refocus sets window topmost (#447) Co-authored-by: Eric Larson --- .circleci/config.yml | 52 ++++++++++++++------- .github/workflows/compat_old.yml | 6 +-- azure-pipelines.yml | 4 +- expyfun/_experiment_controller.py | 6 +-- expyfun/_sound_controllers/_pyglet.py | 6 ++- expyfun/io/tests/test_parse.py | 2 +- expyfun/tests/test_experiment_controller.py | 2 +- 7 files changed, 48 insertions(+), 30 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index befa1f3d..6e641117 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,30 +2,46 @@ version: 2 jobs: build_docs: docker: - - image: circleci/python:3.8.5-buster + - image: cimg/base:current-22.04 steps: # Get our data and merge with upstream - checkout - - run: echo $(git log -1 --pretty=%B) | tee gitlog.txt - - run: echo ${CI_PULL_REQUEST//*pull\//} | tee merge.txt - - run: sudo apt update - - run: sudo apt install libglu1-mesa ffmpeg - run: - command: | - if [[ $(cat merge.txt) != "" ]]; then - echo "Merging $(cat merge.txt)"; - git pull --ff-only origin "refs/pull/$(cat merge.txt)/merge"; - fi - - run: echo "export DISPLAY=:99" >> $BASH_ENV - - run: echo "export _EXPYFUN_SILENT=true" >> $BASH_ENV - - run: echo "export PATH=~/.local/bin:$PATH" >> $BASH_ENV - - run: echo "export SOUND_CARD_BACKEND=pyglet >> $BASH_ENV" # rtmixer needs pulse, which is a huge pain to get running on CircleCI - - run: /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1400x900x24 -ac +extension GLX +render -noreset; - - 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 + name: Merge + command: | + set -eo pipefail + echo $(git log -1 --pretty=%B) | tee gitlog.txt + echo ${CI_PULL_REQUEST//*pull\//} | tee merge.txt + if [[ $(cat merge.txt) != "" ]]; then + echo "Merging $(cat merge.txt)"; + git pull --ff-only origin "refs/pull/$(cat merge.txt)/merge"; + fi + - run: + name: Prep env + command: | + set -eo pipefail + echo "set -eo pipefail" >> $BASH_ENV + sudo apt update + sudo apt install libglu1-mesa python3.10-venv python3-venv libxft2 ffmpeg ffmpeg xvfb + /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1400x900x24 -ac +extension GLX +render -noreset + python3.10 -m venv ~/python_env + echo "export PATH=~/.local/bin:$PATH" >> $BASH_ENV + echo "export SOUND_CARD_BACKEND=pyglet >> $BASH_ENV" # rtmixer needs pulse, which is a huge pain to get running on CircleCI + echo "export OPENBLAS_NUM_THREADS=4" >> $BASH_ENV + echo "export XDG_RUNTIME_DIR=/tmp/runtime-circleci" >> $BASH_ENV + echo "export PATH=~/.local/bin/:$PATH" >> $BASH_ENV + echo "export DISPLAY=:99" >> $BASH_ENV + echo "export _EXPYFUN_SILENT=true" >> $BASH_ENV + echo "source ~/python_env/bin/activate" >> $BASH_ENV + mkdir -p ~/.local/bin + ln -s ~/python_env/bin/python ~/.local/bin/python + echo "BASH_ENV:" + cat $BASH_ENV + - run: pip install --quiet --upgrade pip setuptools wheel + - run: pip install --quiet --upgrade numpy scipy matplotlib sphinx pillow pandas h5py mne "pyglet<2.0" 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: python setup.py develop - run: cd doc && make html - store_artifacts: diff --git a/.github/workflows/compat_old.yml b/.github/workflows/compat_old.yml index ba2a6360..853c5f72 100644 --- a/.github/workflows/compat_old.yml +++ b/.github/workflows/compat_old.yml @@ -19,7 +19,7 @@ jobs: shell: bash -el {0} strategy: matrix: - python: ['3.7'] + python: ['3.8'] env: DISPLAY: ':99.0' steps: @@ -27,7 +27,7 @@ jobs: name: Checkout - run: /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1400x900x24 -ac +extension GLX +render -noreset name: Start Xvfb - - run: sudo apt update -q && sudo apt install -q libglu1-mesa + - run: sudo apt update -q && sudo apt install -q libglu1-mesa gstreamer1.0-alsa name: Install system dependencies - uses: conda-incubator/setup-miniconda@v2 with: @@ -37,7 +37,7 @@ jobs: name: 'Setup conda' - run: | set -e - conda remove -n test pandas h5py + conda remove -n test pandas h5py alsa-lib pip install sounddevice rtmixer "pyglet<1.4" name: Dependencies - run: git clone --depth=1 https://github.com/LABSN/sound-ci-helpers.git && sound-ci-helpers/auto.sh diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 3e8d635b..6b349445 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -46,7 +46,7 @@ stages: maxParallel: 4 matrix: Python37: - PYTHON_VERSION: '3.7' + PYTHON_VERSION: '3.8' Python39: PYTHON_VERSION: '3.10' steps: @@ -66,7 +66,7 @@ stages: # Until https://github.com/pyglet/pyglet/pull/516 is reverted or fixed, we need to use an older one pip install --user --upgrade https://github.com/pyglet/pyglet/zipball/pyglet-1.5-maintenance else - pip install --user --upgrade "pyglet!=1.5.16" + pip install --user --upgrade "pyglet<2.0" fi pip install --user -q coverage setuptools h5py pandas pytest pytest-cov pytest-timeout codecov pyglet-ffmpeg mne tdtpy joblib numpydoc pillow python -c "import mne; mne.sys_info()" diff --git a/expyfun/_experiment_controller.py b/expyfun/_experiment_controller.py index d828b342..de851619 100644 --- a/expyfun/_experiment_controller.py +++ b/expyfun/_experiment_controller.py @@ -2182,13 +2182,11 @@ def refocus(self): m_hWnd = self._win._hwnd hCurWnd = _user32.GetForegroundWindow() if hCurWnd != m_hWnd: + # m_hWnd, HWND_TOPMOST, ..., SWP_NOSIZE | SWP_NOMOVE + _user32.SetWindowPos(m_hWnd, -1, 0, 0, 0, 0, 0x0001 | 0x0002) dwMyID = _user32.GetWindowThreadProcessId(m_hWnd, 0) dwCurID = _user32.GetWindowThreadProcessId(hCurWnd, 0) _user32.AttachThreadInput(dwCurID, dwMyID, True) - # _user32.SetWindowPos(m_hWnd, HWND_TOPMOST, 0, 0, 0, 0, - # SWP_NOSIZE | SWP_NOMOVE) - # _user32.SetWindowPos(m_hWnd, HWND_NOTOPMOST, 0, 0, 0, 0, - # SWP_NOSIZE | SWP_NOMOVE) self._win.activate() # _user32.SetForegroundWindow(m_hWnd) _user32.AttachThreadInput(dwCurID, dwMyID, False) _user32.SetFocus(m_hWnd) diff --git a/expyfun/_sound_controllers/_pyglet.py b/expyfun/_sound_controllers/_pyglet.py index e4e8a1f8..2c594aa3 100644 --- a/expyfun/_sound_controllers/_pyglet.py +++ b/expyfun/_sound_controllers/_pyglet.py @@ -81,7 +81,11 @@ def __init__(self, data, fs=None, loop=False, api=None, name=None, def stop(self, wait=True, extra_delay=0.): """Stop.""" - self.pause() + try: + self.pause() + # assert timestamp >= 0, 'Timestamp beyond dequeued source memory' + except AssertionError: + pass self.seek(0.) @property diff --git a/expyfun/io/tests/test_parse.py b/expyfun/io/tests/test_parse.py index 6e9d519f..93a2cc7f 100644 --- a/expyfun/io/tests/test_parse.py +++ b/expyfun/io/tests/test_parse.py @@ -14,7 +14,7 @@ verbose=True, version='dev') -@pytest.mark.timeout(10) +@pytest.mark.timeout(20) def test_parse_basic(hide_window, tmpdir): """Test .tab parsing.""" with ExperimentController(*std_args, **std_kwargs) as ec: diff --git a/expyfun/tests/test_experiment_controller.py b/expyfun/tests/test_experiment_controller.py index 957d388e..5f7eb967 100644 --- a/expyfun/tests/test_experiment_controller.py +++ b/expyfun/tests/test_experiment_controller.py @@ -180,7 +180,7 @@ def test_degenerate(): **std_kwargs) -@pytest.mark.timeout(60) +@pytest.mark.timeout(120) def test_ec(ac, hide_window, monkeypatch): """Test EC methods.""" if ac == 'tdt': From 72da9e688d797d925ca0abb5367314c4e2fc88a6 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 9 Jan 2023 11:42:02 -0500 Subject: [PATCH 07/11] MAINT: Rotate CircleCI key (#449) --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 6e641117..4221886a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -60,7 +60,7 @@ jobs: steps: - add_ssh_keys: fingerprints: - - d4:4f:25:af:ed:5f:61:01:dc:b6:3a:9e:b5:d6:8d:d1 + - "25:b7:f2:bf:d7:38:6d:b6:c7:78:41:05:01:f8:41:7b" - attach_workspace: at: /tmp/_build - run: From f324eb8c65afa9530698f15ca058700518355a8f Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 25 Jul 2023 15:07:03 -0400 Subject: [PATCH 08/11] MAINT: Consolidate CIs (#450) --- .circleci/config.yml | 4 +- .github/workflows/circle_artifacts.yml | 1 + .github/workflows/compat_old.yml | 57 ---------- .github/workflows/linux.yml | 58 ---------- .github/workflows/macos_conda.yml | 42 ------- .github/workflows/tests.yml | 96 ++++++++++++++++ azure-pipelines.yml | 115 -------------------- doc/getting_started.rst | 2 +- environment_test.yml | 2 +- expyfun/_git.py | 10 +- expyfun/_utils.py | 11 +- expyfun/analyze/_viz.py | 2 +- expyfun/stimuli/_tracker.py | 3 +- expyfun/stimuli/tests/test_stimuli.py | 2 +- expyfun/tests/test_experiment_controller.py | 54 +++------ expyfun/visual/_visual.py | 2 +- setup.cfg | 2 + setup.py | 6 +- 18 files changed, 141 insertions(+), 328 deletions(-) delete mode 100644 .github/workflows/compat_old.yml delete mode 100644 .github/workflows/linux.yml delete mode 100644 .github/workflows/macos_conda.yml create mode 100644 .github/workflows/tests.yml delete mode 100644 azure-pipelines.yml diff --git a/.circleci/config.yml b/.circleci/config.yml index 4221886a..a468012f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -38,10 +38,10 @@ jobs: echo "BASH_ENV:" cat $BASH_ENV - run: pip install --quiet --upgrade pip setuptools wheel - - run: pip install --quiet --upgrade numpy scipy matplotlib sphinx pillow pandas h5py mne "pyglet<2.0" psutil sphinx_bootstrap_theme sphinx_fontawesome numpydoc https://api.github.com/repos/sphinx-gallery/sphinx-gallery/zipball/master + - run: pip install --quiet --upgrade numpy scipy matplotlib sphinx pandas h5py mne "pyglet<2.0" psutil sphinx_bootstrap_theme sphinx_fontawesome numpydoc git+https://github.com/sphinx-gallery/sphinx-gallery + - run: python -m pip install -ve . - run: python -c "import mne; mne.sys_info()" - run: python -c "import pyglet; print(pyglet.version)" - - run: python setup.py develop - run: cd doc && make html - store_artifacts: diff --git a/.github/workflows/circle_artifacts.yml b/.github/workflows/circle_artifacts.yml index e0294320..381ba4a6 100644 --- a/.github/workflows/circle_artifacts.yml +++ b/.github/workflows/circle_artifacts.yml @@ -8,5 +8,6 @@ jobs: uses: larsoner/circleci-artifacts-redirector-action@master with: repo-token: ${{ secrets.GITHUB_TOKEN }} + api-token: ${{ secrets.CIRCLECI_TOKEN }} artifact-path: 0/html/index.html circleci-jobs: build_docs diff --git a/.github/workflows/compat_old.yml b/.github/workflows/compat_old.yml deleted file mode 100644 index 853c5f72..00000000 --- a/.github/workflows/compat_old.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: 'compat' -concurrency: - group: ${{ github.workflow }}-${{ github.event.number }}-${{ github.event.ref }} - cancel-in-progress: true -on: - push: - branches: - - '*' - pull_request: - branches: - - '*' - -jobs: - job: - name: conda ${{ matrix.python }} - runs-on: ubuntu-20.04 - defaults: - run: - shell: bash -el {0} - strategy: - matrix: - python: ['3.8'] - env: - DISPLAY: ':99.0' - steps: - - uses: actions/checkout@v2 - name: Checkout - - run: /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1400x900x24 -ac +extension GLX +render -noreset - name: Start Xvfb - - run: sudo apt update -q && sudo apt install -q libglu1-mesa gstreamer1.0-alsa - name: Install system dependencies - - uses: conda-incubator/setup-miniconda@v2 - with: - activate-environment: 'test' - python-version: ${{ matrix.python }} - environment-file: 'environment_test.yml' - name: 'Setup conda' - - run: | - set -e - conda remove -n test pandas h5py alsa-lib - pip install sounddevice rtmixer "pyglet<1.4" - name: Dependencies - - run: git clone --depth=1 https://github.com/LABSN/sound-ci-helpers.git && sound-ci-helpers/auto.sh - name: Get sound working - - run: python -m sounddevice - name: List sound devices - - run: python -c "import pyglet; print(pyglet.version)" - name: Print Pyglet version - - run: python -c "import matplotlib.pyplot as plt" - name: Make sure matplotlib works - - run: python setup.py develop - name: Install - - run: pytest --tb=short --cov=expyfun --cov-report=xml expyfun - name: Pytest - - uses: codecov/codecov-action@v1 - if: success() - name: 'Codecov' diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml deleted file mode 100644 index 9c71e81d..00000000 --- a/.github/workflows/linux.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: 'linux' -concurrency: - group: ${{ github.workflow }}-${{ github.event.number }}-${{ github.event.ref }} - cancel-in-progress: true -on: - push: - branches: - - '*' - pull_request: - branches: - - '*' - -jobs: - job: - name: pip ${{ matrix.python }} - runs-on: ubuntu-20.04 - defaults: - run: - shell: bash -el {0} - strategy: - matrix: - python: ['3.10'] - env: - DISPLAY: ':99.0' - _EXPYFUN_SILENT: 'true' - SOUND_CARD_BACKEND: 'pyglet' - steps: - - uses: actions/checkout@v2 - name: Checkout - - run: /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1400x900x24 -ac +extension GLX +render -noreset - name: Start Xvfb - - run: sudo apt update -q && sudo apt install -q libavutil56 libavcodec58 libavformat58 libswscale5 libglu1-mesa gstreamer1.0-alsa gstreamer1.0-libav python3-gst-1.0 - name: Install system dependencies - - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.PYTHON_VERSION }} - name: 'Setup python' - - run: pip install --upgrade pip setuptools wheel - name: Upgrade pip - - run: pip install --upgrade sounddevice rtmixer "pyglet<1.6" pyglet_ffmpeg scipy matplotlib pandas h5py coverage mne numpydoc pytest pytest-cov pytest-timeout pillow joblib codecov - name: Dependencies - - run: git clone --depth=1 https://github.com/LABSN/sound-ci-helpers.git && sound-ci-helpers/auto.sh - name: Get sound working - - run: python -m sounddevice - name: List sound devices - - run: python -c "import pyglet; print(pyglet.version)" - name: Print Pyglet version - - run: python -c "import matplotlib.pyplot as plt" - name: Make sure matplotlib works - - run: pip install -ve . - name: Install - - run: python -c "import expyfun; expyfun._utils._has_video(raise_error=True)" - name: Check video - - run: pytest --tb=short --cov=expyfun --cov-report=xml expyfun - name: Pytest - - uses: codecov/codecov-action@v1 - if: success() - name: Codecov diff --git a/.github/workflows/macos_conda.yml b/.github/workflows/macos_conda.yml deleted file mode 100644 index f43fb45a..00000000 --- a/.github/workflows/macos_conda.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: 'macos' -concurrency: - group: ${{ github.workflow }}-${{ github.event.number }}-${{ github.event.ref }} - cancel-in-progress: true -on: - push: - branches: - - '*' - pull_request: - branches: - - '*' - -jobs: - job: - name: conda ${{ matrix.python }} - runs-on: macos-latest - strategy: - matrix: - python: ['3.10'] - defaults: - run: - shell: bash -el {0} - steps: - - uses: actions/checkout@v2 - - uses: conda-incubator/setup-miniconda@v2 - with: - activate-environment: 'test' - python-version: ${{ matrix.python }} - environment-file: 'environment_test.yml' - name: 'Setup conda' - - run: pip install sounddevice rtmixer "pyglet<1.6" - - run: git clone --depth=1 https://github.com/LABSN/sound-ci-helpers.git && sound-ci-helpers/auto.sh - name: Get sound working - - run: python -m sounddevice - - run: python -c "import pyglet; print(pyglet.version)" - - run: python -c "import matplotlib.pyplot as plt" - - run: pip install -ve . - - run: python -c "import expyfun; expyfun._utils._has_video(raise_error=True)" - - run: pytest --tb=short --cov=expyfun --cov-report=xml expyfun - - uses: codecov/codecov-action@v1 - if: success() - name: 'Codecov' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..cc6585a3 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,96 @@ +name: 'tests' +concurrency: + group: ${{ github.workflow }}-${{ github.event.number }}-${{ github.event.ref }} + cancel-in-progress: true +on: + push: + branches: + - '*' + pull_request: + branches: + - '*' + +jobs: + job: + name: ${{ matrix.os }} ${{ matrix.kind }} + continue-on-error: true + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash -el {0} + strategy: + matrix: + os: ['ubuntu-20.04', 'windows-2022'] + kind: ['pip'] + python: ['3.10'] + include: + - os: 'macos-latest' + kind: 'conda' + python: '3.10' + - os: 'ubuntu-20.04' + kind: 'old' + python: '3.8' + steps: + - uses: actions/checkout@v3 + name: Checkout + - uses: LABSN/sound-ci-helpers@v1 + - uses: pyvista/setup-headless-display-action@main + with: + qt: true + pyvista: false + - run: sudo apt install -q libavutil56 libavcodec58 libavformat58 libswscale5 libglu1-mesa gstreamer1.0-alsa gstreamer1.0-libav python3-gst-1.0 + name: Install Linux video dependencies + if: ${{ startsWith(matrix.os, 'ubuntu') }} + - run: powershell make/get_video.ps1 + name: Install Windows video dependencies + if: ${{ startsWith(matrix.os, 'windows') }} + - run: | + if [[ "${{ matrix.os }}" == "windows"* ]]; then + echo "Setting env vars for Windows" + echo "AZURE_CI_WINDOWS=true" >> $GITHUB_ENV + echo "SOUND_CARD_BACKEND=rtmixer" >> $GITHUB_ENV + echo "SOUND_CARD_NAME=Speakers" >> $GITHUB_ENV + echo "SOUND_CARD_FS=48000" >> $GITHUB_ENV + echo "SOUND_CARD_API=Windows WDM-KS" >> $GITHUB_ENV + elif [[ "${{ matrix.os }}" == "ubuntu"* ]]; then + echo "Setting env vars for Linux" + echo "_EXPYFUN_SILENT=true" >> $GITHUB_ENV + echo "SOUND_CARD_BACKEND=pyglet" >> $GITHUB_ENV + elif [[ "${{ matrix.os }}" == "macos"* ]]; then + echo "Setting env vars for macOS" + fi + name: Set env vars + - uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python }} + if: ${{ matrix.kind != 'conda' }} + - uses: mamba-org/setup-micromamba@v1 + with: + environment-file: 'environment_test.yml' + create-args: python=${{ matrix.python }} + init-shell: bash + name: 'Setup conda' + if: ${{ matrix.kind == 'conda' }} + - run: python -m pip install --upgrade pip setuptools wheel sounddevice + if: ${{ matrix.kind != 'conda' }} + - run: python -m pip install --upgrade sounddevice rtmixer "pyglet<1.6" pyglet-ffmpeg scipy matplotlib pandas h5py mne numpydoc pillow joblib + if: ${{ matrix.kind == 'pip' }} + - run: python -m pip install sounddevice rtmixer "pyglet<1.6" + if: ${{ matrix.kind == 'conda' }} + - run: python -m pip install sounddevice rtmixer "pyglet<1.4" numpy scipy matplotlib "pillow<8" codecov + if: ${{ matrix.kind == 'old' }} + - run: python -m pip install tdtpy + if: ${{ startsWith(matrix.os, 'windows') }} + - run: python -m sounddevice + - run: | + set -o pipefail + python -m sounddevice | grep "[82] out" + name: Check that there is some output device + - run: python -c "import pyglet; print(pyglet.version)" + - run: python -c "import matplotlib.pyplot as plt" + - run: pip install -ve .[test] + - run: python -c "import expyfun; expyfun._utils._has_video(raise_error=True)" + if: ${{ matrix.kind != 'old' }} + - run: pytest --tb=short --cov=expyfun --cov-report=xml expyfun + - uses: codecov/codecov-action@v1 + if: success() diff --git a/azure-pipelines.yml b/azure-pipelines.yml deleted file mode 100644 index 6b349445..00000000 --- a/azure-pipelines.yml +++ /dev/null @@ -1,115 +0,0 @@ -trigger: - # start a new build for every push - batch: False - branches: - include: - - main - -stages: - -- stage: Check - jobs: - - job: Skip - pool: - vmImage: 'ubuntu-18.04' - variables: - DECODE_PERCENTS: 'false' - RET: 'true' - steps: - - bash: | - git_log=`git log --max-count=1 --skip=1 --pretty=format:"%s"` - echo "##vso[task.setvariable variable=log]$git_log" - - bash: echo "##vso[task.setvariable variable=RET]false" - condition: or(contains(variables.log, '[skip azp]'), contains(variables.log, '[azp skip]'), contains(variables.log, '[skip ci]'), contains(variables.log, '[ci skip]')) - - bash: echo "##vso[task.setvariable variable=start_main;isOutput=true]$RET" - name: result - -- stage: Main - condition: and(succeeded(), eq(dependencies.Check.outputs['Skip.result.start_main'], 'true')) - dependsOn: Check - variables: - AZURE_CI: 'true' - jobs: - - job: Windows - pool: - vmIMage: 'windows-latest' - variables: - MNE_LOGGING_LEVEL: 'warning' - MNE_FORCE_SERIAL: 'true' - PIP_DEPENDENCIES: 'codecov' - OPENBLAS_NUM_THREADS: 1 - AZURE_CI_WINDOWS: 'true' - SOUND_CARD_NAME: 'Speakers' - SOUND_CARD_FS: '48000' - SOUND_CARD_API: 'Windows WDM-KS' - strategy: - maxParallel: 4 - matrix: - Python37: - PYTHON_VERSION: '3.8' - Python39: - PYTHON_VERSION: '3.10' - steps: - - task: UsePythonVersion@0 - inputs: - versionSpec: $(PYTHON_VERSION) - architecture: 'x64' - addToPath: true - - script: echo "##vso[task.prependpath]C:\Users\VssAdministrator\AppData\Roaming\Python\Python39\site-packages\pywin32_system32;" - displayName: Add local bin to PATH - condition: in(variables['PYTHON_VERSION'], '3.10') - - bash: | - set -e - pip install --user --upgrade pip setuptools wheel - pip install --user --upgrade numpy scipy matplotlib - if [[ "$PYTHON_VERSION" == "3.10" ]]; then - # Until https://github.com/pyglet/pyglet/pull/516 is reverted or fixed, we need to use an older one - pip install --user --upgrade https://github.com/pyglet/pyglet/zipball/pyglet-1.5-maintenance - else - pip install --user --upgrade "pyglet<2.0" - fi - pip install --user -q coverage setuptools h5py pandas pytest pytest-cov pytest-timeout codecov 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)" - python -c "import tdt; print(tdt.__version__)" - displayName: 'Install pip dependencies' - - bash: | - set -e - git clone --depth 1 https://github.com/LABSN/sound-ci-helpers.git - sound-ci-helpers/auto.sh - pip install -q --user rtmixer - python -m sounddevice - displayName: 'Install rtmixer' - - bash: | - set -e - git clone --depth 1 https://github.com/pyvista/gl-ci-helpers.git - powershell gl-ci-helpers/appveyor/install_opengl.ps1 - python -c "import pyglet; r = pyglet.gl.gl_info.get_renderer(); print(r); assert 'gallium' in r.lower()" - displayName: 'Get OpenGL' - - powershell: | - powershell make/get_video.ps1 - displayName: 'Get video support' - - powershell: | - python -c "from ctypes import cdll; print(cdll.LoadLibrary('avcodec-58'))" - displayName: 'Check avcodec' - - powershell: | - python -c "import expyfun; expyfun._utils._has_video(raise_error=True)" - displayName: 'Check video support' - - bash: | - python setup.py develop - displayName: 'Install' - - bash: | - pytest --tb=short --cov=expyfun expyfun -x - displayName: 'Run tests' - - bash: | - codecov --root %BUILD_REPOSITORY_LOCALPATH% -t %CODECOV_TOKEN% - displayName: 'Codecov' - env: - CODECOV_TOKEN: $(CODECOV_TOKEN) - condition: always() - - task: PublishTestResults@2 - inputs: - testResultsFiles: 'junit-*.xml' - testRunTitle: 'Publish test results for Python $(python.version)' - condition: always() diff --git a/doc/getting_started.rst b/doc/getting_started.rst index 1f5d1a19..430384cb 100644 --- a/doc/getting_started.rst +++ b/doc/getting_started.rst @@ -14,7 +14,7 @@ Installing expyfun Python ^^^^^^ -The first step is to install a Python 3.7+ distribution. See tutorials on other +The first step is to install a Python 3.8+ distribution. See tutorials on other sites for how to do this. Dependencies diff --git a/environment_test.yml b/environment_test.yml index 8ce8f254..46337dc7 100644 --- a/environment_test.yml +++ b/environment_test.yml @@ -15,6 +15,6 @@ dependencies: - pytest-timeout - pillow - joblib -- ffmpeg +- ffmpeg<6 - codecov # Do pip separately diff --git a/expyfun/_git.py b/expyfun/_git.py index 16257b90..06e78d68 100644 --- a/expyfun/_git.py +++ b/expyfun/_git.py @@ -67,10 +67,16 @@ def download_version(version='current', dest_dir=None): tempdir = _TempDir() expyfun_dir = op.join(tempdir, 'expyfun') # git will auto-create this dir repo_url = 'https://github.com/LABSN/expyfun.git' - run_subprocess(['git', 'clone', repo_url, expyfun_dir]) + env = os.environ.copy() + env["GIT_TERMINAL_PROMPT"] = "0" # do not prompt for credentials + run_subprocess( + ['git', 'clone', repo_url, expyfun_dir, + "--single-branch", "--branch", "main"], + env=env, + ) version = _active_version(expyfun_dir) if version == 'current' else version try: - run_subprocess(['git', 'checkout', version], cwd=expyfun_dir) + run_subprocess(['git', 'checkout', version], cwd=expyfun_dir, env=env) except Exception as exp: raise RuntimeError('Could not check out version {0}: {1}' ''.format(version, str(exp))) diff --git a/expyfun/_utils.py b/expyfun/_utils.py index ffd105f3..f31c51b7 100644 --- a/expyfun/_utils.py +++ b/expyfun/_utils.py @@ -192,7 +192,10 @@ def run_subprocess(command, **kwargs): p = subprocess.Popen(command, **kw) stdout_, stderr = p.communicate() - output = (stdout_.decode(), stderr.decode()) + output = ( + stdout_.decode() if stdout_ else "", + stderr.decode() if stderr else "", + ) if p.returncode: err_fun = subprocess.CalledProcessError.__init__ if 'output' in _get_args(err_fun): @@ -952,9 +955,5 @@ def _get_display(): # Adapted from MNE-Python def _compare_version(version_a, operator, version_b): - try: - from pkg_resources import parse_version as parse # noqa - except ImportError: - from distutils.version import LooseVersion as parse # noqa - + from packaging.version import parse # noqa return eval(f'parse("{version_a}") {operator} parse("{version_b}")') diff --git a/expyfun/analyze/_viz.py b/expyfun/analyze/_viz.py index 362a448b..ab68f979 100644 --- a/expyfun/analyze/_viz.py +++ b/expyfun/analyze/_viz.py @@ -397,7 +397,7 @@ def barplot(h, axis=-1, ylim=None, err_bars=None, lines=False, for ((_l, _r), (_bl, _br), _t, _c, _s) in zip(brk_lr, brk_b, brk_t, brk_c, bracket_text): # bracket text - _t = float(_t) # on newer Pandas it can be shape (1,) + _t = np.array(_t).item() # on newer Pandas it can be shape (1,) defaults = dict(ha='center', annotation_clip=False, textcoords='offset points') for k, v in defaults.items(): diff --git a/expyfun/stimuli/_tracker.py b/expyfun/stimuli/_tracker.py index 3371d6e6..8b1583d9 100644 --- a/expyfun/stimuli/_tracker.py +++ b/expyfun/stimuli/_tracker.py @@ -1242,8 +1242,7 @@ def _stop_here(self): self._threshold = self._x_min elif self._threshold_reached.count(True) == 1: self._n_stop = True - self._threshold = int(self._levels[ - [i for i, tr in enumerate(self._threshold_reached) if tr]]) + self._threshold = self._levels[self._threshold_reached].item() else: self._n_stop = False if self._n_stop and self._limit_count > 0: diff --git a/expyfun/stimuli/tests/test_stimuli.py b/expyfun/stimuli/tests/test_stimuli.py index f0939404..0fbb0c77 100644 --- a/expyfun/stimuli/tests/test_stimuli.py +++ b/expyfun/stimuli/tests/test_stimuli.py @@ -153,7 +153,7 @@ def test_rms(): assert_array_almost_equal(rms(np.ones((100, 2)) * 2, 0), [2, 2]) -@pytest.mark.timeout(60) # can be slow to load on CIs +@pytest.mark.timeout(120) # can be slow to load on CIs @requires_lib('mne') def test_crm(tmpdir): """Test CRM Corpus functions.""" diff --git a/expyfun/tests/test_experiment_controller.py b/expyfun/tests/test_experiment_controller.py index 5f7eb967..556500de 100644 --- a/expyfun/tests/test_experiment_controller.py +++ b/expyfun/tests/test_experiment_controller.py @@ -610,31 +610,25 @@ def test_tdt_delay(hide_window): def test_sound_card_triggering(hide_window): """Test using the sound card as a trigger controller.""" audio_controller = dict(TYPE='sound_card', SOUND_CARD_TRIGGER_CHANNELS='0') + kwargs = std_kwargs.copy() + kwargs.update( + stim_fs=44100, + suppress_resamp=True, + audio_controller=audio_controller, + trigger_controller='sound_card', + ) with pytest.raises(ValueError, match='SOUND_CARD_TRIGGER_CHANNELS is zer'): - ExperimentController(*std_args, - audio_controller=audio_controller, - trigger_controller='sound_card', - suppress_resamp=True, - **std_kwargs) + ExperimentController(*std_args, **kwargs) audio_controller.update(SOUND_CARD_TRIGGER_CHANNELS='1') # Use 1 trigger ch and 1 output ch because this should work on all systems - with ExperimentController(*std_args, - audio_controller=audio_controller, - trigger_controller='sound_card', - n_channels=1, - suppress_resamp=True, - **std_kwargs) as ec: + with ExperimentController(*std_args, n_channels=1, **kwargs) as ec: ec.identify_trial(ttl_id=[1, 0], ec_id='') ec.load_buffer([1e-2]) ec.start_stimulus() ec.stop() # Test the drift triggers audio_controller.update(SOUND_CARD_DRIFT_TRIGGER=0.001) - with ExperimentController(*std_args, - audio_controller=audio_controller, - trigger_controller='sound_card', - n_channels=1, - **std_kwargs) as ec: + with ExperimentController(*std_args, n_channels=1, **kwargs) as ec: ec.identify_trial(ttl_id=[1, 0], ec_id='') with pytest.warns(UserWarning, match='Drift triggers overlap with ' 'onset triggers.'): @@ -643,11 +637,7 @@ def test_sound_card_triggering(hide_window): ec.stop() audio_controller.update(SOUND_CARD_DRIFT_TRIGGER=[1.1, 0.3, -0.3, 'end']) - with ExperimentController(*std_args, - audio_controller=audio_controller, - trigger_controller='sound_card', - n_channels=1, - **std_kwargs) as ec: + with ExperimentController(*std_args, n_channels=1, **kwargs) as ec: ec.identify_trial(ttl_id=[1, 0], ec_id='') with pytest.warns(UserWarning, match='Drift trigger at 1.1 seconds ' 'occurs outside stimulus window, not stamping ' @@ -655,35 +645,23 @@ def test_sound_card_triggering(hide_window): ec.load_buffer(np.zeros(ec.stim_fs)) ec.start_stimulus() ec.stop() - audio_controller.update(SOUND_CARD_DRIFT_TRIGGER=[0.5, 0.501]) - with ExperimentController(*std_args, - audio_controller=audio_controller, - trigger_controller='sound_card', - n_channels=1, - **std_kwargs) as ec: + audio_controller.update(SOUND_CARD_DRIFT_TRIGGER=[0.5, 0.505]) + with ExperimentController(*std_args, n_channels=1, **kwargs) as ec: ec.identify_trial(ttl_id=[1, 0], ec_id='') with pytest.warns(UserWarning, match='Some 2-triggers overlap.*'): ec.load_buffer(np.zeros(ec.stim_fs)) ec.start_stimulus() ec.stop() audio_controller.update(SOUND_CARD_DRIFT_TRIGGER=[]) - with ExperimentController(*std_args, - audio_controller=audio_controller, - trigger_controller='sound_card', - n_channels=1, - **std_kwargs) as ec: + with ExperimentController(*std_args, n_channels=1, **kwargs) as ec: ec.identify_trial(ttl_id=[1, 0], ec_id='') ec.load_buffer(np.zeros(ec.stim_fs)) ec.start_stimulus() ec.stop() audio_controller.update(SOUND_CARD_DRIFT_TRIGGER=[0.2, 0.5, -0.3]) - with ExperimentController(*std_args, - audio_controller=audio_controller, - trigger_controller='sound_card', - n_channels=1, - **std_kwargs) as ec: + with ExperimentController(*std_args, n_channels=1, **kwargs) as ec: ec.identify_trial(ttl_id=[1, 0], ec_id='') - ec.load_buffer(np.zeros(ec.stim_fs)) + ec.load_buffer(np.zeros(ec.stim_fs * 2)) ec.start_stimulus() ec.stop() diff --git a/expyfun/visual/_visual.py b/expyfun/visual/_visual.py index f40203b9..a317421e 100644 --- a/expyfun/visual/_visual.py +++ b/expyfun/visual/_visual.py @@ -99,7 +99,7 @@ def __init__(self, ec, text, pos=(0, 0), color='white', units='norm', wrap=False, attr=True): import pyglet pos = np.array(pos)[:, np.newaxis] - pos = ec._convert_units(pos, units, 'pix') + pos = ec._convert_units(pos, units, 'pix')[:, 0] if width == 'auto': width = float(ec.window_size_pix[0]) * 0.8 elif isinstance(width, string_types): diff --git a/setup.cfg b/setup.cfg index 5a1b0467..97ef076c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -50,6 +50,8 @@ filterwarnings = ignore:.*distutils Version classes are deprecated.*:DeprecationWarning ignore:.*distutils\.sysconfig module is deprecated.*:DeprecationWarning ignore:.*isSet\(\) is deprecated.*:DeprecationWarning + ignore:`product` is deprecated as of NumPy.*:DeprecationWarning + ignore:Invalid dash-separated options.*: always:.*Exception ignored in.*__del__.*: [flake8] diff --git a/setup.py b/setup.py index a4e2022a..a60b8d45 100644 --- a/setup.py +++ b/setup.py @@ -79,7 +79,11 @@ def setup_package(script_args=None): version=FULL_VERSION, download_url=DOWNLOAD_URL, long_description=long_description, - python_requires=">=3.7", + python_requires=">=3.8", + install_requires=["packaging", "numpy", "scipy", "matplotlib", "pillow"], # noqa + extras_require={ + "test": ["pytest", "pytest-cov", "pytest-timeout"], + }, zip_safe=False, # the package can run out of an .egg file classifiers=['Intended Audience :: Science/Research', 'Intended Audience :: Developers', From c7c1b18440968e2def388dff25118e13fe3c3b9a Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 24 Jun 2024 15:47:31 -0400 Subject: [PATCH 09/11] MAINT: Make sure CIs are still green (#451) --- .circleci/config.yml | 2 +- .coveragerc | 1 - .github/dependabot.yml | 10 + .github/release.yml | 5 + .github/workflows/circle_artifacts.yml | 6 +- .github/workflows/codespell_and_flake.yml | 41 - .github/workflows/tests.yml | 71 +- .gitignore | 1 + .pre-commit-config.yaml | 43 + .yamllint.yml | 8 + MANIFEST.in | 4 +- codecov.yml | 2 +- doc/_static/font-awesome.css | 2337 ----------------- doc/_static/fontawesome-webfont.eot | Bin 165742 -> 0 bytes doc/_static/fontawesome-webfont.ttf | Bin 165548 -> 0 bytes doc/_static/fontawesome-webfont.woff | Bin 98024 -> 0 bytes doc/_static/fontawesome-webfont.woff2 | Bin 77160 -> 0 bytes doc/_templates/autosummary/class.rst | 12 +- doc/_templates/autosummary/function.rst | 10 +- doc/conf.py | 237 +- doc/getting_started.rst | 10 +- doc/git_diagram.py | 121 +- doc/index.rst | 25 +- doc/parallel_installation.rst | 4 +- environment_test.yml | 31 +- examples/analysis/analysis_demo.py | 82 +- examples/analysis/parse_demo.py | 17 +- examples/basic_experiment.py | 18 +- examples/experiments/drawing_methods.py | 46 +- .../experiments/eyetracking_experiment_.py | 61 +- examples/experiments/formatted_text.py | 46 +- examples/experiments/joystick_experiment.py | 45 +- examples/experiments/keypress.py | 150 +- examples/experiments/keyrelease.py | 44 +- examples/experiments/level_test.py | 42 +- examples/experiments/mouse.py | 69 +- examples/experiments/progress_bar.py | 40 +- .../experiments/pupillometry_experiment_.py | 41 +- examples/experiments/tracker_dealer.py | 61 +- .../experiments/tracker_dealer_doublesided.py | 45 +- examples/experiments/version_checking_.py | 8 +- examples/generate_simple_stimuli.py | 53 +- examples/simple_experiment.py | 132 +- examples/stimuli/advanced_stimuli.py | 14 +- examples/stimuli/advanced_video.py | 50 +- examples/stimuli/crm_stimuli.py | 59 +- examples/stimuli/simple_video.py | 25 +- examples/stimuli/stimulus_power.py | 47 +- examples/stimuli/texture_stimuli.py | 19 +- examples/stimuli/tracker_staircase.py | 13 +- examples/stimuli/tracker_staircase_MHW.py | 13 +- examples/stimuli/vocoded_stimuli.py | 33 +- examples/sync/sample_rate_test.py | 27 +- examples/sync/sync_test.py | 32 +- expyfun/__init__.py | 24 +- expyfun/_experiment_controller.py | 1078 ++++---- expyfun/_externals/__init__.py | 4 - expyfun/_externals/_h5io.py | 425 --- expyfun/_externals/decorator.py | 254 -- expyfun/_eyelink_controller.py | 510 ++-- expyfun/_git.py | 90 +- expyfun/_input_controllers.py | 289 +- expyfun/_parallel.py | 7 +- expyfun/_sound_controllers/__init__.py | 11 +- expyfun/_sound_controllers/_pyglet.py | 62 +- expyfun/_sound_controllers/_rtmixer.py | 137 +- .../_sound_controllers/_sound_controller.py | 215 +- expyfun/_tdt_controller.py | 264 +- expyfun/_trigger_controllers.py | 95 +- expyfun/_utils.py | 412 +-- expyfun/_version.py | 2 +- expyfun/analyze/__init__.py | 3 +- expyfun/analyze/_analyze.py | 92 +- expyfun/analyze/_recon.py | 11 +- expyfun/analyze/_viz.py | 297 ++- .../analyze/tests/test_analyze_functions.py | 74 +- expyfun/analyze/tests/test_recon.py | 7 +- expyfun/analyze/tests/test_viz.py | 142 +- expyfun/codeblocks/__init__.py | 3 +- expyfun/codeblocks/_pupillometry.py | 101 +- expyfun/conftest.py | 36 +- expyfun/io/__init__.py | 12 +- expyfun/io/_parse.py | 103 +- expyfun/io/_wav.py | 62 +- expyfun/io/tests/test_parse.py | 97 +- expyfun/io/tests/test_wav.py | 23 +- expyfun/stimuli/__init__.py | 9 +- expyfun/stimuli/_crm.py | 435 +-- expyfun/stimuli/_hrtf.py | 63 +- expyfun/stimuli/_mls.py | 72 +- expyfun/stimuli/_stimuli.py | 61 +- expyfun/stimuli/_texture.py | 87 +- expyfun/stimuli/_tracker.py | 618 +++-- expyfun/stimuli/_vocoder.py | 105 +- expyfun/stimuli/tests/test_mls.py | 27 +- expyfun/stimuli/tests/test_stimuli.py | 215 +- expyfun/stimuli/tests/test_tracker.py | 229 +- expyfun/tests/test_docstring_parameters.py | 162 +- expyfun/tests/test_experiment_controller.py | 677 +++-- expyfun/tests/test_eyelink_controller.py | 69 +- expyfun/tests/test_logging.py | 59 +- expyfun/tests/test_parallel.py | 8 +- expyfun/tests/test_trigger_conversion.py | 40 +- expyfun/tests/test_utils.py | 39 +- expyfun/tests/test_version.py | 103 +- expyfun/visual/__init__.py | 18 +- expyfun/visual/_visual.py | 579 ++-- expyfun/visual/tests/test_visuals.py | 90 +- ignore_words.txt | 2 + pyproject.toml | 124 + setup.cfg | 67 - setup.py | 12 +- {make => tools}/get_video.ps1 | 0 113 files changed, 5836 insertions(+), 7389 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/release.yml delete mode 100644 .github/workflows/codespell_and_flake.yml create mode 100644 .pre-commit-config.yaml create mode 100644 .yamllint.yml delete mode 100644 doc/_static/font-awesome.css delete mode 100644 doc/_static/fontawesome-webfont.eot delete mode 100644 doc/_static/fontawesome-webfont.ttf delete mode 100644 doc/_static/fontawesome-webfont.woff delete mode 100644 doc/_static/fontawesome-webfont.woff2 delete mode 100644 expyfun/_externals/__init__.py delete mode 100644 expyfun/_externals/_h5io.py delete mode 100644 expyfun/_externals/decorator.py create mode 100644 pyproject.toml delete mode 100644 setup.cfg rename {make => tools}/get_video.ps1 (100%) diff --git a/.circleci/config.yml b/.circleci/config.yml index a468012f..ade95aa4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -38,7 +38,7 @@ jobs: echo "BASH_ENV:" cat $BASH_ENV - run: pip install --quiet --upgrade pip setuptools wheel - - run: pip install --quiet --upgrade numpy scipy matplotlib sphinx pandas h5py mne "pyglet<2.0" psutil sphinx_bootstrap_theme sphinx_fontawesome numpydoc git+https://github.com/sphinx-gallery/sphinx-gallery + - run: pip install --quiet --upgrade numpy scipy matplotlib sphinx pandas h5py mne "pyglet<2.0" psutil pydata-sphinx-theme numpydoc git+https://github.com/sphinx-gallery/sphinx-gallery - run: python -m pip install -ve . - run: python -c "import mne; mne.sys_info()" - run: python -c "import pyglet; print(pyglet.version)" diff --git a/.coveragerc b/.coveragerc index 7978105a..9a1bf336 100644 --- a/.coveragerc +++ b/.coveragerc @@ -5,4 +5,3 @@ include = */expyfun/* omit = */setup.py */expyfun/codeblocks/* - */expyfun/_externals/* diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..d57929b9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + groups: + actions: + patterns: + - "*" diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..9d1e0987 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,5 @@ +changelog: + exclude: + authors: + - dependabot + - pre-commit-ci diff --git a/.github/workflows/circle_artifacts.yml b/.github/workflows/circle_artifacts.yml index 381ba4a6..1026bc29 100644 --- a/.github/workflows/circle_artifacts.yml +++ b/.github/workflows/circle_artifacts.yml @@ -1,13 +1,15 @@ -on: [status] +on: [status] # yamllint disable-line rule:truthy jobs: circleci_artifacts_redirector_job: + if: "${{ startsWith(github.event.context, 'ci/circleci: build_docs') }}" runs-on: ubuntu-20.04 name: Run CircleCI artifacts redirector steps: - name: GitHub Action step - uses: larsoner/circleci-artifacts-redirector-action@master + uses: scientific-python/circleci-artifacts-redirector-action@master with: repo-token: ${{ secrets.GITHUB_TOKEN }} api-token: ${{ secrets.CIRCLECI_TOKEN }} artifact-path: 0/html/index.html circleci-jobs: build_docs + job-title: Check the rendered docs here! diff --git a/.github/workflows/codespell_and_flake.yml b/.github/workflows/codespell_and_flake.yml deleted file mode 100644 index a96ab6a0..00000000 --- a/.github/workflows/codespell_and_flake.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: 'codespell_and_flake' -concurrency: - group: ${{ github.workflow }}-${{ github.event.number }}-${{ github.event.ref }} - cancel-in-progress: true -on: - push: - branches: - - '*' - pull_request: - branches: - - '*' - -jobs: - style: - runs-on: ubuntu-20.04 - env: - CODESPELL_DIRS: 'expyfun/ doc/ examples/' - CODESPELL_SKIPS: '*.log,*.doctree,*.pickle,*.png,*.js,*.html,*.orig' - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - with: - python-version: '3.9' - architecture: 'x64' - - run: | - python -m pip install --upgrade pip setuptools wheel - python -m pip install flake8 pydocstyle check-manifest numpy - name: 'Install dependencies' - - uses: rbialon/flake8-annotations@v1 - name: 'Setup flake8 annotations' - - run: make flake - - run: make docstyle - - run: make check-manifest - - uses: GuillaumeFavelier/actions-codespell@feat/quiet_level - with: - path: ${{ env.CODESPELL_DIRS }} - skip: ${{ env.CODESPELL_SKIPS }} - quiet_level: '3' - builtin: 'clear,rare,informal,names' - ignore_words_file: 'ignore_words.txt' - name: 'make codespell-error' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cc6585a3..0a07f692 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,7 +2,7 @@ name: 'tests' concurrency: group: ${{ github.workflow }}-${{ github.event.number }}-${{ github.event.ref }} cancel-in-progress: true -on: +on: # yamllint disable-line rule:truthy push: branches: - '*' @@ -20,67 +20,75 @@ jobs: shell: bash -el {0} strategy: matrix: - os: ['ubuntu-20.04', 'windows-2022'] - kind: ['pip'] - python: ['3.10'] include: - - os: 'macos-latest' + # 24.04 works except for the video test, even though it works locally on 24.10 + - os: ubuntu-22.04 + kind: pip + python: '3.12' + # ARM64 will probably need to wait until + # - os: 'macos-latest' # arm64 + # kind: 'conda' + # python: '3.12' + - os: 'macos-13' # intel kind: 'conda' - python: '3.10' + python: '3.12' + # TODO: There is a bug on Python 3.12 on Windows :( + - os: 'windows-latest' + kind: 'pip' + python: '3.11' - os: 'ubuntu-20.04' kind: 'old' python: '3.8' steps: - uses: actions/checkout@v3 - name: Checkout - uses: LABSN/sound-ci-helpers@v1 - uses: pyvista/setup-headless-display-action@main with: qt: true pyvista: false - - run: sudo apt install -q libavutil56 libavcodec58 libavformat58 libswscale5 libglu1-mesa gstreamer1.0-alsa gstreamer1.0-libav python3-gst-1.0 - name: Install Linux video dependencies + # Use -dev here just to get whichever version is right (e.g., 22.04 has a different version from 24.04) + - run: sudo apt install -q libavutil-dev libavcodec-dev libavformat-dev libswscale-dev libglu1-mesa gstreamer1.0-alsa gstreamer1.0-libav if: ${{ startsWith(matrix.os, 'ubuntu') }} - - run: powershell make/get_video.ps1 - name: Install Windows video dependencies + - run: powershell tools/get_video.ps1 if: ${{ startsWith(matrix.os, 'windows') }} - run: | - if [[ "${{ matrix.os }}" == "windows"* ]]; then + set -xeo pipefail + if [[ "${{ runner.os }}" == "Windows" ]]; then echo "Setting env vars for Windows" echo "AZURE_CI_WINDOWS=true" >> $GITHUB_ENV echo "SOUND_CARD_BACKEND=rtmixer" >> $GITHUB_ENV echo "SOUND_CARD_NAME=Speakers" >> $GITHUB_ENV echo "SOUND_CARD_FS=48000" >> $GITHUB_ENV echo "SOUND_CARD_API=Windows WDM-KS" >> $GITHUB_ENV - elif [[ "${{ matrix.os }}" == "ubuntu"* ]]; then + elif [[ "${{ runner.os }}" == "Linux" ]]; then echo "Setting env vars for Linux" echo "_EXPYFUN_SILENT=true" >> $GITHUB_ENV echo "SOUND_CARD_BACKEND=pyglet" >> $GITHUB_ENV - elif [[ "${{ matrix.os }}" == "macos"* ]]; then + elif [[ "${{ runner.os }}" == "macOS" ]]; then echo "Setting env vars for macOS" fi name: Set env vars - uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} - if: ${{ matrix.kind != 'conda' }} + if: matrix.kind != 'conda' - uses: mamba-org/setup-micromamba@v1 with: environment-file: 'environment_test.yml' create-args: python=${{ matrix.python }} init-shell: bash - name: 'Setup conda' - if: ${{ matrix.kind == 'conda' }} - - run: python -m pip install --upgrade pip setuptools wheel sounddevice - if: ${{ matrix.kind != 'conda' }} - - run: python -m pip install --upgrade sounddevice rtmixer "pyglet<1.6" pyglet-ffmpeg scipy matplotlib pandas h5py mne numpydoc pillow joblib - if: ${{ matrix.kind == 'pip' }} - - run: python -m pip install sounddevice rtmixer "pyglet<1.6" - if: ${{ matrix.kind == 'conda' }} - - run: python -m pip install sounddevice rtmixer "pyglet<1.4" numpy scipy matplotlib "pillow<8" codecov - if: ${{ matrix.kind == 'old' }} + if: matrix.kind == 'conda' + # Pyglet pin: https://github.com/pyglet/pyglet/issues/1089 (and need OpenGL2 compat for Pyglet>=2, too) + - run: python -m pip install --upgrade pip setuptools wheel sounddevice "pyglet<1.5.28" + - run: python -m pip install --upgrade --only-binary="rtmixer,scipy,matplotlib,pandas,numpy" rtmixer pyglet-ffmpeg scipy matplotlib pandas h5py mne numpydoc pillow joblib + if: matrix.kind == 'pip' + # arm64 has issues with rtmixer / PortAudio + - run: python -m pip install --only-binary="rtmixer" rtmixer + if: matrix.kind == 'conda' && matrix.os != 'macos-latest' + - run: python -m pip install --only-binary="rtmixer,numpy,scipy,matplotlib" rtmixer "pyglet<1.4" numpy scipy matplotlib "pillow<8" + if: matrix.kind == 'old' - run: python -m pip install tdtpy - if: ${{ startsWith(matrix.os, 'windows') }} + if: startsWith(matrix.os, 'windows') - run: python -m sounddevice - run: | set -o pipefail @@ -89,8 +97,11 @@ jobs: - run: python -c "import pyglet; print(pyglet.version)" - run: python -c "import matplotlib.pyplot as plt" - run: pip install -ve .[test] + # Video hangs on macOS arm64, not sure why - run: python -c "import expyfun; expyfun._utils._has_video(raise_error=True)" - if: ${{ matrix.kind != 'old' }} - - run: pytest --tb=short --cov=expyfun --cov-report=xml expyfun - - uses: codecov/codecov-action@v1 - if: success() + if: matrix.kind != 'old' && matrix.os != 'macos-latest' + - run: pytest expyfun --cov-report=xml --cov=expyfun + - uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + if: always() diff --git a/.gitignore b/.gitignore index 128a90ef..0961ef97 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ *.orig .vscode doc/generated +doc/sg_execution_times.rst .DS_Store # C extensions diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..3c5c3232 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,43 @@ +repos: + # Ruff mne + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.3.7 + hooks: + - id: ruff + name: ruff lint expyfun + args: ["--fix"] + files: ^expyfun/ + - id: ruff + name: ruff lint doc and examples + # D103: missing docstring in public function + # D400: docstring first line must end with period + args: ["--ignore=D103,D400", "--fix"] + files: ^doc/|^examples/ + - id: ruff-format + files: ^expyfun/|^doc/|^examples/ + + # Codespell + - repo: https://github.com/codespell-project/codespell + rev: v2.2.6 + hooks: + - id: codespell + additional_dependencies: + - tomli + files: ^expyfun/|^doc/|^examples/ + types_or: [python, bib, rst, inc] + + # yamllint + - repo: https://github.com/adrienverge/yamllint.git + rev: v1.35.1 + hooks: + - id: yamllint + args: [--strict, -c, .yamllint.yml] + + # rstcheck + - repo: https://github.com/rstcheck/rstcheck.git + rev: v6.2.0 + hooks: + - id: rstcheck + additional_dependencies: + - tomli + files: ^doc/.*\.(rst|inc)$ diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 00000000..f54915d4 --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,8 @@ +extends: default + +ignore: | + .github/workflows/codeql-analysis.yml + +rules: + line-length: disable + document-start: disable diff --git a/MANIFEST.in b/MANIFEST.in index e0e8ff96..7f7f4c2f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -10,7 +10,7 @@ recursive-include expyfun/data * ### Exclude -exclude make +exclude tools exclude doc exclude .circleci exclude Makefile @@ -21,6 +21,6 @@ exclude .mailmap recursive-exclude expyfun *.pyc recursive-exclude doc * -recursive-exclude make * +recursive-exclude tools * recursive-exclude examples *.tab recursive-exclude .circleci * diff --git a/codecov.yml b/codecov.yml index a011f80f..408f379c 100644 --- a/codecov.yml +++ b/codecov.yml @@ -4,7 +4,7 @@ github_checks: # too noisy, even though "a" interactively disables them codecov: notify: - require_ci_to_pass: no + require_ci_to_pass: false coverage: status: diff --git a/doc/_static/font-awesome.css b/doc/_static/font-awesome.css deleted file mode 100644 index c1ecf734..00000000 --- a/doc/_static/font-awesome.css +++ /dev/null @@ -1,2337 +0,0 @@ -/*! - * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome - * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) - */ -/* FONT PATH - * -------------------------- */ -@font-face { - font-family: 'FontAwesome'; - src: url('./fontawesome-webfont.eot?v=4.7.0'); - src: url('./fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'), url('./fontawesome-webfont.woff2?v=4.7.0') format('woff2'), url('./fontawesome-webfont.woff?v=4.7.0') format('woff'), url('./fontawesome-webfont.ttf?v=4.7.0') format('truetype'); - font-weight: normal; - font-style: normal; -} -.fa { - display: inline-block; - font: normal normal normal 14px/1 FontAwesome; - font-size: inherit; - text-rendering: auto; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} -/* makes the font 33% larger relative to the icon container */ -.fa-lg { - font-size: 1.33333333em; - line-height: 0.75em; - vertical-align: -15%; -} -.fa-2x { - font-size: 2em; -} -.fa-3x { - font-size: 3em; -} -.fa-4x { - font-size: 4em; -} -.fa-5x { - font-size: 5em; -} -.fa-fw { - width: 1.28571429em; - text-align: center; -} -.fa-ul { - padding-left: 0; - margin-left: 2.14285714em; - list-style-type: none; -} -.fa-ul > li { - position: relative; -} -.fa-li { - position: absolute; - left: -2.14285714em; - width: 2.14285714em; - top: 0.14285714em; - text-align: center; -} -.fa-li.fa-lg { - left: -1.85714286em; -} -.fa-border { - padding: .2em .25em .15em; - border: solid 0.08em #eeeeee; - border-radius: .1em; -} -.fa-pull-left { - float: left; -} -.fa-pull-right { - float: right; -} -.fa.fa-pull-left { - margin-right: .3em; -} -.fa.fa-pull-right { - margin-left: .3em; -} -/* Deprecated as of 4.4.0 */ -.pull-right { - float: right; -} -.pull-left { - float: left; -} -.fa.pull-left { - margin-right: .3em; -} -.fa.pull-right { - margin-left: .3em; -} -.fa-spin { - -webkit-animation: fa-spin 2s infinite linear; - animation: fa-spin 2s infinite linear; -} -.fa-pulse { - -webkit-animation: fa-spin 1s infinite steps(8); - animation: fa-spin 1s infinite steps(8); -} -@-webkit-keyframes fa-spin { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(359deg); - transform: rotate(359deg); - } -} -@keyframes fa-spin { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(359deg); - transform: rotate(359deg); - } -} -.fa-rotate-90 { - -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=1)"; - -webkit-transform: rotate(90deg); - -ms-transform: rotate(90deg); - transform: rotate(90deg); -} -.fa-rotate-180 { - -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2)"; - -webkit-transform: rotate(180deg); - -ms-transform: rotate(180deg); - transform: rotate(180deg); -} -.fa-rotate-270 { - -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=3)"; - -webkit-transform: rotate(270deg); - -ms-transform: rotate(270deg); - transform: rotate(270deg); -} -.fa-flip-horizontal { - -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)"; - -webkit-transform: scale(-1, 1); - -ms-transform: scale(-1, 1); - transform: scale(-1, 1); -} -.fa-flip-vertical { - -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"; - -webkit-transform: scale(1, -1); - -ms-transform: scale(1, -1); - transform: scale(1, -1); -} -:root .fa-rotate-90, -:root .fa-rotate-180, -:root .fa-rotate-270, -:root .fa-flip-horizontal, -:root .fa-flip-vertical { - filter: none; -} -.fa-stack { - position: relative; - display: inline-block; - width: 2em; - height: 2em; - line-height: 2em; - vertical-align: middle; -} -.fa-stack-1x, -.fa-stack-2x { - position: absolute; - left: 0; - width: 100%; - text-align: center; -} -.fa-stack-1x { - line-height: inherit; -} -.fa-stack-2x { - font-size: 2em; -} -.fa-inverse { - color: #ffffff; -} -/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen - readers do not read off random characters that represent icons */ -.fa-glass:before { - content: "\f000"; -} -.fa-music:before { - content: "\f001"; -} -.fa-search:before { - content: "\f002"; -} -.fa-envelope-o:before { - content: "\f003"; -} -.fa-heart:before { - content: "\f004"; -} -.fa-star:before { - content: "\f005"; -} -.fa-star-o:before { - content: "\f006"; -} -.fa-user:before { - content: "\f007"; -} -.fa-film:before { - content: "\f008"; -} -.fa-th-large:before { - content: "\f009"; -} -.fa-th:before { - content: "\f00a"; -} -.fa-th-list:before { - content: "\f00b"; -} -.fa-check:before { - content: "\f00c"; -} -.fa-remove:before, -.fa-close:before, -.fa-times:before { - content: "\f00d"; -} -.fa-search-plus:before { - content: "\f00e"; -} -.fa-search-minus:before { - content: "\f010"; -} -.fa-power-off:before { - content: "\f011"; -} -.fa-signal:before { - content: "\f012"; -} -.fa-gear:before, -.fa-cog:before { - content: "\f013"; -} -.fa-trash-o:before { - content: "\f014"; -} -.fa-home:before { - content: "\f015"; -} -.fa-file-o:before { - content: "\f016"; -} -.fa-clock-o:before { - content: "\f017"; -} -.fa-road:before { - content: "\f018"; -} -.fa-download:before { - content: "\f019"; -} -.fa-arrow-circle-o-down:before { - content: "\f01a"; -} -.fa-arrow-circle-o-up:before { - content: "\f01b"; -} -.fa-inbox:before { - content: "\f01c"; -} -.fa-play-circle-o:before { - content: "\f01d"; -} -.fa-rotate-right:before, -.fa-repeat:before { - content: "\f01e"; -} -.fa-refresh:before { - content: "\f021"; -} -.fa-list-alt:before { - content: "\f022"; -} -.fa-lock:before { - content: "\f023"; -} -.fa-flag:before { - content: "\f024"; -} -.fa-headphones:before { - content: "\f025"; -} -.fa-volume-off:before { - content: "\f026"; -} -.fa-volume-down:before { - content: "\f027"; -} -.fa-volume-up:before { - content: "\f028"; -} -.fa-qrcode:before { - content: "\f029"; -} -.fa-barcode:before { - content: "\f02a"; -} -.fa-tag:before { - content: "\f02b"; -} -.fa-tags:before { - content: "\f02c"; -} -.fa-book:before { - content: "\f02d"; -} -.fa-bookmark:before { - content: "\f02e"; -} -.fa-print:before { - content: "\f02f"; -} -.fa-camera:before { - content: "\f030"; -} -.fa-font:before { - content: "\f031"; -} -.fa-bold:before { - content: "\f032"; -} -.fa-italic:before { - content: "\f033"; -} -.fa-text-height:before { - content: "\f034"; -} -.fa-text-width:before { - content: "\f035"; -} -.fa-align-left:before { - content: "\f036"; -} -.fa-align-center:before { - content: "\f037"; -} -.fa-align-right:before { - content: "\f038"; -} -.fa-align-justify:before { - content: "\f039"; -} -.fa-list:before { - content: "\f03a"; -} -.fa-dedent:before, -.fa-outdent:before { - content: "\f03b"; -} -.fa-indent:before { - content: "\f03c"; -} -.fa-video-camera:before { - content: "\f03d"; -} -.fa-photo:before, -.fa-image:before, -.fa-picture-o:before { - content: "\f03e"; -} -.fa-pencil:before { - content: "\f040"; -} -.fa-map-marker:before { - content: "\f041"; -} -.fa-adjust:before { - content: "\f042"; -} -.fa-tint:before { - content: "\f043"; -} -.fa-edit:before, -.fa-pencil-square-o:before { - content: "\f044"; -} -.fa-share-square-o:before { - content: "\f045"; -} -.fa-check-square-o:before { - content: "\f046"; -} -.fa-arrows:before { - content: "\f047"; -} -.fa-step-backward:before { - content: "\f048"; -} -.fa-fast-backward:before { - content: "\f049"; -} -.fa-backward:before { - content: "\f04a"; -} -.fa-play:before { - content: "\f04b"; -} -.fa-pause:before { - content: "\f04c"; -} -.fa-stop:before { - content: "\f04d"; -} -.fa-forward:before { - content: "\f04e"; -} -.fa-fast-forward:before { - content: "\f050"; -} -.fa-step-forward:before { - content: "\f051"; -} -.fa-eject:before { - content: "\f052"; -} -.fa-chevron-left:before { - content: "\f053"; -} -.fa-chevron-right:before { - content: "\f054"; -} -.fa-plus-circle:before { - content: "\f055"; -} -.fa-minus-circle:before { - content: "\f056"; -} -.fa-times-circle:before { - content: "\f057"; -} -.fa-check-circle:before { - content: "\f058"; -} -.fa-question-circle:before { - content: "\f059"; -} -.fa-info-circle:before { - content: "\f05a"; -} -.fa-crosshairs:before { - content: "\f05b"; -} -.fa-times-circle-o:before { - content: "\f05c"; -} -.fa-check-circle-o:before { - content: "\f05d"; -} -.fa-ban:before { - content: "\f05e"; -} -.fa-arrow-left:before { - content: "\f060"; -} -.fa-arrow-right:before { - content: "\f061"; -} -.fa-arrow-up:before { - content: "\f062"; -} -.fa-arrow-down:before { - content: "\f063"; -} -.fa-mail-forward:before, -.fa-share:before { - content: "\f064"; -} -.fa-expand:before { - content: "\f065"; -} -.fa-compress:before { - content: "\f066"; -} -.fa-plus:before { - content: "\f067"; -} -.fa-minus:before { - content: "\f068"; -} -.fa-asterisk:before { - content: "\f069"; -} -.fa-exclamation-circle:before { - content: "\f06a"; -} -.fa-gift:before { - content: "\f06b"; -} -.fa-leaf:before { - content: "\f06c"; -} -.fa-fire:before { - content: "\f06d"; -} -.fa-eye:before { - content: "\f06e"; -} -.fa-eye-slash:before { - content: "\f070"; -} -.fa-warning:before, -.fa-exclamation-triangle:before { - content: "\f071"; -} -.fa-plane:before { - content: "\f072"; -} -.fa-calendar:before { - content: "\f073"; -} -.fa-random:before { - content: "\f074"; -} -.fa-comment:before { - content: "\f075"; -} -.fa-magnet:before { - content: "\f076"; -} -.fa-chevron-up:before { - content: "\f077"; -} -.fa-chevron-down:before { - content: "\f078"; -} -.fa-retweet:before { - content: "\f079"; -} -.fa-shopping-cart:before { - content: "\f07a"; -} -.fa-folder:before { - content: "\f07b"; -} -.fa-folder-open:before { - content: "\f07c"; -} -.fa-arrows-v:before { - content: "\f07d"; -} -.fa-arrows-h:before { - content: "\f07e"; -} -.fa-bar-chart-o:before, -.fa-bar-chart:before { - content: "\f080"; -} -.fa-twitter-square:before { - content: "\f081"; -} -.fa-facebook-square:before { - content: "\f082"; -} -.fa-camera-retro:before { - content: "\f083"; -} -.fa-key:before { - content: "\f084"; -} -.fa-gears:before, -.fa-cogs:before { - content: "\f085"; -} -.fa-comments:before { - content: "\f086"; -} -.fa-thumbs-o-up:before { - content: "\f087"; -} -.fa-thumbs-o-down:before { - content: "\f088"; -} -.fa-star-half:before { - content: "\f089"; -} -.fa-heart-o:before { - content: "\f08a"; -} -.fa-sign-out:before { - content: "\f08b"; -} -.fa-linkedin-square:before { - content: "\f08c"; -} -.fa-thumb-tack:before { - content: "\f08d"; -} -.fa-external-link:before { - content: "\f08e"; -} -.fa-sign-in:before { - content: "\f090"; -} -.fa-trophy:before { - content: "\f091"; -} -.fa-github-square:before { - content: "\f092"; -} -.fa-upload:before { - content: "\f093"; -} -.fa-lemon-o:before { - content: "\f094"; -} -.fa-phone:before { - content: "\f095"; -} -.fa-square-o:before { - content: "\f096"; -} -.fa-bookmark-o:before { - content: "\f097"; -} -.fa-phone-square:before { - content: "\f098"; -} -.fa-twitter:before { - content: "\f099"; -} -.fa-facebook-f:before, -.fa-facebook:before { - content: "\f09a"; -} -.fa-github:before { - content: "\f09b"; -} -.fa-unlock:before { - content: "\f09c"; -} -.fa-credit-card:before { - content: "\f09d"; -} -.fa-feed:before, -.fa-rss:before { - content: "\f09e"; -} -.fa-hdd-o:before { - content: "\f0a0"; -} -.fa-bullhorn:before { - content: "\f0a1"; -} -.fa-bell:before { - content: "\f0f3"; -} -.fa-certificate:before { - content: "\f0a3"; -} -.fa-hand-o-right:before { - content: "\f0a4"; -} -.fa-hand-o-left:before { - content: "\f0a5"; -} -.fa-hand-o-up:before { - content: "\f0a6"; -} -.fa-hand-o-down:before { - content: "\f0a7"; -} -.fa-arrow-circle-left:before { - content: "\f0a8"; -} -.fa-arrow-circle-right:before { - content: "\f0a9"; -} -.fa-arrow-circle-up:before { - content: "\f0aa"; -} -.fa-arrow-circle-down:before { - content: "\f0ab"; -} -.fa-globe:before { - content: "\f0ac"; -} -.fa-wrench:before { - content: "\f0ad"; -} -.fa-tasks:before { - content: "\f0ae"; -} -.fa-filter:before { - content: "\f0b0"; -} -.fa-briefcase:before { - content: "\f0b1"; -} -.fa-arrows-alt:before { - content: "\f0b2"; -} -.fa-group:before, -.fa-users:before { - content: "\f0c0"; -} -.fa-chain:before, -.fa-link:before { - content: "\f0c1"; -} -.fa-cloud:before { - content: "\f0c2"; -} -.fa-flask:before { - content: "\f0c3"; -} -.fa-cut:before, -.fa-scissors:before { - content: "\f0c4"; -} -.fa-copy:before, -.fa-files-o:before { - content: "\f0c5"; -} -.fa-paperclip:before { - content: "\f0c6"; -} -.fa-save:before, -.fa-floppy-o:before { - content: "\f0c7"; -} -.fa-square:before { - content: "\f0c8"; -} -.fa-navicon:before, -.fa-reorder:before, -.fa-bars:before { - content: "\f0c9"; -} -.fa-list-ul:before { - content: "\f0ca"; -} -.fa-list-ol:before { - content: "\f0cb"; -} -.fa-strikethrough:before { - content: "\f0cc"; -} -.fa-underline:before { - content: "\f0cd"; -} -.fa-table:before { - content: "\f0ce"; -} -.fa-magic:before { - content: "\f0d0"; -} -.fa-truck:before { - content: "\f0d1"; -} -.fa-pinterest:before { - content: "\f0d2"; -} -.fa-pinterest-square:before { - content: "\f0d3"; -} -.fa-google-plus-square:before { - content: "\f0d4"; -} -.fa-google-plus:before { - content: "\f0d5"; -} -.fa-money:before { - content: "\f0d6"; -} -.fa-caret-down:before { - content: "\f0d7"; -} -.fa-caret-up:before { - content: "\f0d8"; -} -.fa-caret-left:before { - content: "\f0d9"; -} -.fa-caret-right:before { - content: "\f0da"; -} -.fa-columns:before { - content: "\f0db"; -} -.fa-unsorted:before, -.fa-sort:before { - content: "\f0dc"; -} -.fa-sort-down:before, -.fa-sort-desc:before { - content: "\f0dd"; -} -.fa-sort-up:before, -.fa-sort-asc:before { - content: "\f0de"; -} -.fa-envelope:before { - content: "\f0e0"; -} -.fa-linkedin:before { - content: "\f0e1"; -} -.fa-rotate-left:before, -.fa-undo:before { - content: "\f0e2"; -} -.fa-legal:before, -.fa-gavel:before { - content: "\f0e3"; -} -.fa-dashboard:before, -.fa-tachometer:before { - content: "\f0e4"; -} -.fa-comment-o:before { - content: "\f0e5"; -} -.fa-comments-o:before { - content: "\f0e6"; -} -.fa-flash:before, -.fa-bolt:before { - content: "\f0e7"; -} -.fa-sitemap:before { - content: "\f0e8"; -} -.fa-umbrella:before { - content: "\f0e9"; -} -.fa-paste:before, -.fa-clipboard:before { - content: "\f0ea"; -} -.fa-lightbulb-o:before { - content: "\f0eb"; -} -.fa-exchange:before { - content: "\f0ec"; -} -.fa-cloud-download:before { - content: "\f0ed"; -} -.fa-cloud-upload:before { - content: "\f0ee"; -} -.fa-user-md:before { - content: "\f0f0"; -} -.fa-stethoscope:before { - content: "\f0f1"; -} -.fa-suitcase:before { - content: "\f0f2"; -} -.fa-bell-o:before { - content: "\f0a2"; -} -.fa-coffee:before { - content: "\f0f4"; -} -.fa-cutlery:before { - content: "\f0f5"; -} -.fa-file-text-o:before { - content: "\f0f6"; -} -.fa-building-o:before { - content: "\f0f7"; -} -.fa-hospital-o:before { - content: "\f0f8"; -} -.fa-ambulance:before { - content: "\f0f9"; -} -.fa-medkit:before { - content: "\f0fa"; -} -.fa-fighter-jet:before { - content: "\f0fb"; -} -.fa-beer:before { - content: "\f0fc"; -} -.fa-h-square:before { - content: "\f0fd"; -} -.fa-plus-square:before { - content: "\f0fe"; -} -.fa-angle-double-left:before { - content: "\f100"; -} -.fa-angle-double-right:before { - content: "\f101"; -} -.fa-angle-double-up:before { - content: "\f102"; -} -.fa-angle-double-down:before { - content: "\f103"; -} -.fa-angle-left:before { - content: "\f104"; -} -.fa-angle-right:before { - content: "\f105"; -} -.fa-angle-up:before { - content: "\f106"; -} -.fa-angle-down:before { - content: "\f107"; -} -.fa-desktop:before { - content: "\f108"; -} -.fa-laptop:before { - content: "\f109"; -} -.fa-tablet:before { - content: "\f10a"; -} -.fa-mobile-phone:before, -.fa-mobile:before { - content: "\f10b"; -} -.fa-circle-o:before { - content: "\f10c"; -} -.fa-quote-left:before { - content: "\f10d"; -} -.fa-quote-right:before { - content: "\f10e"; -} -.fa-spinner:before { - content: "\f110"; -} -.fa-circle:before { - content: "\f111"; -} -.fa-mail-reply:before, -.fa-reply:before { - content: "\f112"; -} -.fa-github-alt:before { - content: "\f113"; -} -.fa-folder-o:before { - content: "\f114"; -} -.fa-folder-open-o:before { - content: "\f115"; -} -.fa-smile-o:before { - content: "\f118"; -} -.fa-frown-o:before { - content: "\f119"; -} -.fa-meh-o:before { - content: "\f11a"; -} -.fa-gamepad:before { - content: "\f11b"; -} -.fa-keyboard-o:before { - content: "\f11c"; -} -.fa-flag-o:before { - content: "\f11d"; -} -.fa-flag-checkered:before { - content: "\f11e"; -} -.fa-terminal:before { - content: "\f120"; -} -.fa-code:before { - content: "\f121"; -} -.fa-mail-reply-all:before, -.fa-reply-all:before { - content: "\f122"; -} -.fa-star-half-empty:before, -.fa-star-half-full:before, -.fa-star-half-o:before { - content: "\f123"; -} -.fa-location-arrow:before { - content: "\f124"; -} -.fa-crop:before { - content: "\f125"; -} -.fa-code-fork:before { - content: "\f126"; -} -.fa-unlink:before, -.fa-chain-broken:before { - content: "\f127"; -} -.fa-question:before { - content: "\f128"; -} -.fa-info:before { - content: "\f129"; -} -.fa-exclamation:before { - content: "\f12a"; -} -.fa-superscript:before { - content: "\f12b"; -} -.fa-subscript:before { - content: "\f12c"; -} -.fa-eraser:before { - content: "\f12d"; -} -.fa-puzzle-piece:before { - content: "\f12e"; -} -.fa-microphone:before { - content: "\f130"; -} -.fa-microphone-slash:before { - content: "\f131"; -} -.fa-shield:before { - content: "\f132"; -} -.fa-calendar-o:before { - content: "\f133"; -} -.fa-fire-extinguisher:before { - content: "\f134"; -} -.fa-rocket:before { - content: "\f135"; -} -.fa-maxcdn:before { - content: "\f136"; -} -.fa-chevron-circle-left:before { - content: "\f137"; -} -.fa-chevron-circle-right:before { - content: "\f138"; -} -.fa-chevron-circle-up:before { - content: "\f139"; -} -.fa-chevron-circle-down:before { - content: "\f13a"; -} -.fa-html5:before { - content: "\f13b"; -} -.fa-css3:before { - content: "\f13c"; -} -.fa-anchor:before { - content: "\f13d"; -} -.fa-unlock-alt:before { - content: "\f13e"; -} -.fa-bullseye:before { - content: "\f140"; -} -.fa-ellipsis-h:before { - content: "\f141"; -} -.fa-ellipsis-v:before { - content: "\f142"; -} -.fa-rss-square:before { - content: "\f143"; -} -.fa-play-circle:before { - content: "\f144"; -} -.fa-ticket:before { - content: "\f145"; -} -.fa-minus-square:before { - content: "\f146"; -} -.fa-minus-square-o:before { - content: "\f147"; -} -.fa-level-up:before { - content: "\f148"; -} -.fa-level-down:before { - content: "\f149"; -} -.fa-check-square:before { - content: "\f14a"; -} -.fa-pencil-square:before { - content: "\f14b"; -} -.fa-external-link-square:before { - content: "\f14c"; -} -.fa-share-square:before { - content: "\f14d"; -} -.fa-compass:before { - content: "\f14e"; -} -.fa-toggle-down:before, -.fa-caret-square-o-down:before { - content: "\f150"; -} -.fa-toggle-up:before, -.fa-caret-square-o-up:before { - content: "\f151"; -} -.fa-toggle-right:before, -.fa-caret-square-o-right:before { - content: "\f152"; -} -.fa-euro:before, -.fa-eur:before { - content: "\f153"; -} -.fa-gbp:before { - content: "\f154"; -} -.fa-dollar:before, -.fa-usd:before { - content: "\f155"; -} -.fa-rupee:before, -.fa-inr:before { - content: "\f156"; -} -.fa-cny:before, -.fa-rmb:before, -.fa-yen:before, -.fa-jpy:before { - content: "\f157"; -} -.fa-ruble:before, -.fa-rouble:before, -.fa-rub:before { - content: "\f158"; -} -.fa-won:before, -.fa-krw:before { - content: "\f159"; -} -.fa-bitcoin:before, -.fa-btc:before { - content: "\f15a"; -} -.fa-file:before { - content: "\f15b"; -} -.fa-file-text:before { - content: "\f15c"; -} -.fa-sort-alpha-asc:before { - content: "\f15d"; -} -.fa-sort-alpha-desc:before { - content: "\f15e"; -} -.fa-sort-amount-asc:before { - content: "\f160"; -} -.fa-sort-amount-desc:before { - content: "\f161"; -} -.fa-sort-numeric-asc:before { - content: "\f162"; -} -.fa-sort-numeric-desc:before { - content: "\f163"; -} -.fa-thumbs-up:before { - content: "\f164"; -} -.fa-thumbs-down:before { - content: "\f165"; -} -.fa-youtube-square:before { - content: "\f166"; -} -.fa-youtube:before { - content: "\f167"; -} -.fa-xing:before { - content: "\f168"; -} -.fa-xing-square:before { - content: "\f169"; -} -.fa-youtube-play:before { - content: "\f16a"; -} -.fa-dropbox:before { - content: "\f16b"; -} -.fa-stack-overflow:before { - content: "\f16c"; -} -.fa-instagram:before { - content: "\f16d"; -} -.fa-flickr:before { - content: "\f16e"; -} -.fa-adn:before { - content: "\f170"; -} -.fa-bitbucket:before { - content: "\f171"; -} -.fa-bitbucket-square:before { - content: "\f172"; -} -.fa-tumblr:before { - content: "\f173"; -} -.fa-tumblr-square:before { - content: "\f174"; -} -.fa-long-arrow-down:before { - content: "\f175"; -} -.fa-long-arrow-up:before { - content: "\f176"; -} -.fa-long-arrow-left:before { - content: "\f177"; -} -.fa-long-arrow-right:before { - content: "\f178"; -} -.fa-apple:before { - content: "\f179"; -} -.fa-windows:before { - content: "\f17a"; -} -.fa-android:before { - content: "\f17b"; -} -.fa-linux:before { - content: "\f17c"; -} -.fa-dribbble:before { - content: "\f17d"; -} -.fa-skype:before { - content: "\f17e"; -} -.fa-foursquare:before { - content: "\f180"; -} -.fa-trello:before { - content: "\f181"; -} -.fa-female:before { - content: "\f182"; -} -.fa-male:before { - content: "\f183"; -} -.fa-gittip:before, -.fa-gratipay:before { - content: "\f184"; -} -.fa-sun-o:before { - content: "\f185"; -} -.fa-moon-o:before { - content: "\f186"; -} -.fa-archive:before { - content: "\f187"; -} -.fa-bug:before { - content: "\f188"; -} -.fa-vk:before { - content: "\f189"; -} -.fa-weibo:before { - content: "\f18a"; -} -.fa-renren:before { - content: "\f18b"; -} -.fa-pagelines:before { - content: "\f18c"; -} -.fa-stack-exchange:before { - content: "\f18d"; -} -.fa-arrow-circle-o-right:before { - content: "\f18e"; -} -.fa-arrow-circle-o-left:before { - content: "\f190"; -} -.fa-toggle-left:before, -.fa-caret-square-o-left:before { - content: "\f191"; -} -.fa-dot-circle-o:before { - content: "\f192"; -} -.fa-wheelchair:before { - content: "\f193"; -} -.fa-vimeo-square:before { - content: "\f194"; -} -.fa-turkish-lira:before, -.fa-try:before { - content: "\f195"; -} -.fa-plus-square-o:before { - content: "\f196"; -} -.fa-space-shuttle:before { - content: "\f197"; -} -.fa-slack:before { - content: "\f198"; -} -.fa-envelope-square:before { - content: "\f199"; -} -.fa-wordpress:before { - content: "\f19a"; -} -.fa-openid:before { - content: "\f19b"; -} -.fa-institution:before, -.fa-bank:before, -.fa-university:before { - content: "\f19c"; -} -.fa-mortar-board:before, -.fa-graduation-cap:before { - content: "\f19d"; -} -.fa-yahoo:before { - content: "\f19e"; -} -.fa-google:before { - content: "\f1a0"; -} -.fa-reddit:before { - content: "\f1a1"; -} -.fa-reddit-square:before { - content: "\f1a2"; -} -.fa-stumbleupon-circle:before { - content: "\f1a3"; -} -.fa-stumbleupon:before { - content: "\f1a4"; -} -.fa-delicious:before { - content: "\f1a5"; -} -.fa-digg:before { - content: "\f1a6"; -} -.fa-pied-piper-pp:before { - content: "\f1a7"; -} -.fa-pied-piper-alt:before { - content: "\f1a8"; -} -.fa-drupal:before { - content: "\f1a9"; -} -.fa-joomla:before { - content: "\f1aa"; -} -.fa-language:before { - content: "\f1ab"; -} -.fa-fax:before { - content: "\f1ac"; -} -.fa-building:before { - content: "\f1ad"; -} -.fa-child:before { - content: "\f1ae"; -} -.fa-paw:before { - content: "\f1b0"; -} -.fa-spoon:before { - content: "\f1b1"; -} -.fa-cube:before { - content: "\f1b2"; -} -.fa-cubes:before { - content: "\f1b3"; -} -.fa-behance:before { - content: "\f1b4"; -} -.fa-behance-square:before { - content: "\f1b5"; -} -.fa-steam:before { - content: "\f1b6"; -} -.fa-steam-square:before { - content: "\f1b7"; -} -.fa-recycle:before { - content: "\f1b8"; -} -.fa-automobile:before, -.fa-car:before { - content: "\f1b9"; -} -.fa-cab:before, -.fa-taxi:before { - content: "\f1ba"; -} -.fa-tree:before { - content: "\f1bb"; -} -.fa-spotify:before { - content: "\f1bc"; -} -.fa-deviantart:before { - content: "\f1bd"; -} -.fa-soundcloud:before { - content: "\f1be"; -} -.fa-database:before { - content: "\f1c0"; -} -.fa-file-pdf-o:before { - content: "\f1c1"; -} -.fa-file-word-o:before { - content: "\f1c2"; -} -.fa-file-excel-o:before { - content: "\f1c3"; -} -.fa-file-powerpoint-o:before { - content: "\f1c4"; -} -.fa-file-photo-o:before, -.fa-file-picture-o:before, -.fa-file-image-o:before { - content: "\f1c5"; -} -.fa-file-zip-o:before, -.fa-file-archive-o:before { - content: "\f1c6"; -} -.fa-file-sound-o:before, -.fa-file-audio-o:before { - content: "\f1c7"; -} -.fa-file-movie-o:before, -.fa-file-video-o:before { - content: "\f1c8"; -} -.fa-file-code-o:before { - content: "\f1c9"; -} -.fa-vine:before { - content: "\f1ca"; -} -.fa-codepen:before { - content: "\f1cb"; -} -.fa-jsfiddle:before { - content: "\f1cc"; -} -.fa-life-bouy:before, -.fa-life-buoy:before, -.fa-life-saver:before, -.fa-support:before, -.fa-life-ring:before { - content: "\f1cd"; -} -.fa-circle-o-notch:before { - content: "\f1ce"; -} -.fa-ra:before, -.fa-resistance:before, -.fa-rebel:before { - content: "\f1d0"; -} -.fa-ge:before, -.fa-empire:before { - content: "\f1d1"; -} -.fa-git-square:before { - content: "\f1d2"; -} -.fa-git:before { - content: "\f1d3"; -} -.fa-y-combinator-square:before, -.fa-yc-square:before, -.fa-hacker-news:before { - content: "\f1d4"; -} -.fa-tencent-weibo:before { - content: "\f1d5"; -} -.fa-qq:before { - content: "\f1d6"; -} -.fa-wechat:before, -.fa-weixin:before { - content: "\f1d7"; -} -.fa-send:before, -.fa-paper-plane:before { - content: "\f1d8"; -} -.fa-send-o:before, -.fa-paper-plane-o:before { - content: "\f1d9"; -} -.fa-history:before { - content: "\f1da"; -} -.fa-circle-thin:before { - content: "\f1db"; -} -.fa-header:before { - content: "\f1dc"; -} -.fa-paragraph:before { - content: "\f1dd"; -} -.fa-sliders:before { - content: "\f1de"; -} -.fa-share-alt:before { - content: "\f1e0"; -} -.fa-share-alt-square:before { - content: "\f1e1"; -} -.fa-bomb:before { - content: "\f1e2"; -} -.fa-soccer-ball-o:before, -.fa-futbol-o:before { - content: "\f1e3"; -} -.fa-tty:before { - content: "\f1e4"; -} -.fa-binoculars:before { - content: "\f1e5"; -} -.fa-plug:before { - content: "\f1e6"; -} -.fa-slideshare:before { - content: "\f1e7"; -} -.fa-twitch:before { - content: "\f1e8"; -} -.fa-yelp:before { - content: "\f1e9"; -} -.fa-newspaper-o:before { - content: "\f1ea"; -} -.fa-wifi:before { - content: "\f1eb"; -} -.fa-calculator:before { - content: "\f1ec"; -} -.fa-paypal:before { - content: "\f1ed"; -} -.fa-google-wallet:before { - content: "\f1ee"; -} -.fa-cc-visa:before { - content: "\f1f0"; -} -.fa-cc-mastercard:before { - content: "\f1f1"; -} -.fa-cc-discover:before { - content: "\f1f2"; -} -.fa-cc-amex:before { - content: "\f1f3"; -} -.fa-cc-paypal:before { - content: "\f1f4"; -} -.fa-cc-stripe:before { - content: "\f1f5"; -} -.fa-bell-slash:before { - content: "\f1f6"; -} -.fa-bell-slash-o:before { - content: "\f1f7"; -} -.fa-trash:before { - content: "\f1f8"; -} -.fa-copyright:before { - content: "\f1f9"; -} -.fa-at:before { - content: "\f1fa"; -} -.fa-eyedropper:before { - content: "\f1fb"; -} -.fa-paint-brush:before { - content: "\f1fc"; -} -.fa-birthday-cake:before { - content: "\f1fd"; -} -.fa-area-chart:before { - content: "\f1fe"; -} -.fa-pie-chart:before { - content: "\f200"; -} -.fa-line-chart:before { - content: "\f201"; -} -.fa-lastfm:before { - content: "\f202"; -} -.fa-lastfm-square:before { - content: "\f203"; -} -.fa-toggle-off:before { - content: "\f204"; -} -.fa-toggle-on:before { - content: "\f205"; -} -.fa-bicycle:before { - content: "\f206"; -} -.fa-bus:before { - content: "\f207"; -} -.fa-ioxhost:before { - content: "\f208"; -} -.fa-angellist:before { - content: "\f209"; -} -.fa-cc:before { - content: "\f20a"; -} -.fa-shekel:before, -.fa-sheqel:before, -.fa-ils:before { - content: "\f20b"; -} -.fa-meanpath:before { - content: "\f20c"; -} -.fa-buysellads:before { - content: "\f20d"; -} -.fa-connectdevelop:before { - content: "\f20e"; -} -.fa-dashcube:before { - content: "\f210"; -} -.fa-forumbee:before { - content: "\f211"; -} -.fa-leanpub:before { - content: "\f212"; -} -.fa-sellsy:before { - content: "\f213"; -} -.fa-shirtsinbulk:before { - content: "\f214"; -} -.fa-simplybuilt:before { - content: "\f215"; -} -.fa-skyatlas:before { - content: "\f216"; -} -.fa-cart-plus:before { - content: "\f217"; -} -.fa-cart-arrow-down:before { - content: "\f218"; -} -.fa-diamond:before { - content: "\f219"; -} -.fa-ship:before { - content: "\f21a"; -} -.fa-user-secret:before { - content: "\f21b"; -} -.fa-motorcycle:before { - content: "\f21c"; -} -.fa-street-view:before { - content: "\f21d"; -} -.fa-heartbeat:before { - content: "\f21e"; -} -.fa-venus:before { - content: "\f221"; -} -.fa-mars:before { - content: "\f222"; -} -.fa-mercury:before { - content: "\f223"; -} -.fa-intersex:before, -.fa-transgender:before { - content: "\f224"; -} -.fa-transgender-alt:before { - content: "\f225"; -} -.fa-venus-double:before { - content: "\f226"; -} -.fa-mars-double:before { - content: "\f227"; -} -.fa-venus-mars:before { - content: "\f228"; -} -.fa-mars-stroke:before { - content: "\f229"; -} -.fa-mars-stroke-v:before { - content: "\f22a"; -} -.fa-mars-stroke-h:before { - content: "\f22b"; -} -.fa-neuter:before { - content: "\f22c"; -} -.fa-genderless:before { - content: "\f22d"; -} -.fa-facebook-official:before { - content: "\f230"; -} -.fa-pinterest-p:before { - content: "\f231"; -} -.fa-whatsapp:before { - content: "\f232"; -} -.fa-server:before { - content: "\f233"; -} -.fa-user-plus:before { - content: "\f234"; -} -.fa-user-times:before { - content: "\f235"; -} -.fa-hotel:before, -.fa-bed:before { - content: "\f236"; -} -.fa-viacoin:before { - content: "\f237"; -} -.fa-train:before { - content: "\f238"; -} -.fa-subway:before { - content: "\f239"; -} -.fa-medium:before { - content: "\f23a"; -} -.fa-yc:before, -.fa-y-combinator:before { - content: "\f23b"; -} -.fa-optin-monster:before { - content: "\f23c"; -} -.fa-opencart:before { - content: "\f23d"; -} -.fa-expeditedssl:before { - content: "\f23e"; -} -.fa-battery-4:before, -.fa-battery:before, -.fa-battery-full:before { - content: "\f240"; -} -.fa-battery-3:before, -.fa-battery-three-quarters:before { - content: "\f241"; -} -.fa-battery-2:before, -.fa-battery-half:before { - content: "\f242"; -} -.fa-battery-1:before, -.fa-battery-quarter:before { - content: "\f243"; -} -.fa-battery-0:before, -.fa-battery-empty:before { - content: "\f244"; -} -.fa-mouse-pointer:before { - content: "\f245"; -} -.fa-i-cursor:before { - content: "\f246"; -} -.fa-object-group:before { - content: "\f247"; -} -.fa-object-ungroup:before { - content: "\f248"; -} -.fa-sticky-note:before { - content: "\f249"; -} -.fa-sticky-note-o:before { - content: "\f24a"; -} -.fa-cc-jcb:before { - content: "\f24b"; -} -.fa-cc-diners-club:before { - content: "\f24c"; -} -.fa-clone:before { - content: "\f24d"; -} -.fa-balance-scale:before { - content: "\f24e"; -} -.fa-hourglass-o:before { - content: "\f250"; -} -.fa-hourglass-1:before, -.fa-hourglass-start:before { - content: "\f251"; -} -.fa-hourglass-2:before, -.fa-hourglass-half:before { - content: "\f252"; -} -.fa-hourglass-3:before, -.fa-hourglass-end:before { - content: "\f253"; -} -.fa-hourglass:before { - content: "\f254"; -} -.fa-hand-grab-o:before, -.fa-hand-rock-o:before { - content: "\f255"; -} -.fa-hand-stop-o:before, -.fa-hand-paper-o:before { - content: "\f256"; -} -.fa-hand-scissors-o:before { - content: "\f257"; -} -.fa-hand-lizard-o:before { - content: "\f258"; -} -.fa-hand-spock-o:before { - content: "\f259"; -} -.fa-hand-pointer-o:before { - content: "\f25a"; -} -.fa-hand-peace-o:before { - content: "\f25b"; -} -.fa-trademark:before { - content: "\f25c"; -} -.fa-registered:before { - content: "\f25d"; -} -.fa-creative-commons:before { - content: "\f25e"; -} -.fa-gg:before { - content: "\f260"; -} -.fa-gg-circle:before { - content: "\f261"; -} -.fa-tripadvisor:before { - content: "\f262"; -} -.fa-odnoklassniki:before { - content: "\f263"; -} -.fa-odnoklassniki-square:before { - content: "\f264"; -} -.fa-get-pocket:before { - content: "\f265"; -} -.fa-wikipedia-w:before { - content: "\f266"; -} -.fa-safari:before { - content: "\f267"; -} -.fa-chrome:before { - content: "\f268"; -} -.fa-firefox:before { - content: "\f269"; -} -.fa-opera:before { - content: "\f26a"; -} -.fa-internet-explorer:before { - content: "\f26b"; -} -.fa-tv:before, -.fa-television:before { - content: "\f26c"; -} -.fa-contao:before { - content: "\f26d"; -} -.fa-500px:before { - content: "\f26e"; -} -.fa-amazon:before { - content: "\f270"; -} -.fa-calendar-plus-o:before { - content: "\f271"; -} -.fa-calendar-minus-o:before { - content: "\f272"; -} -.fa-calendar-times-o:before { - content: "\f273"; -} -.fa-calendar-check-o:before { - content: "\f274"; -} -.fa-industry:before { - content: "\f275"; -} -.fa-map-pin:before { - content: "\f276"; -} -.fa-map-signs:before { - content: "\f277"; -} -.fa-map-o:before { - content: "\f278"; -} -.fa-map:before { - content: "\f279"; -} -.fa-commenting:before { - content: "\f27a"; -} -.fa-commenting-o:before { - content: "\f27b"; -} -.fa-houzz:before { - content: "\f27c"; -} -.fa-vimeo:before { - content: "\f27d"; -} -.fa-black-tie:before { - content: "\f27e"; -} -.fa-fonticons:before { - content: "\f280"; -} -.fa-reddit-alien:before { - content: "\f281"; -} -.fa-edge:before { - content: "\f282"; -} -.fa-credit-card-alt:before { - content: "\f283"; -} -.fa-codiepie:before { - content: "\f284"; -} -.fa-modx:before { - content: "\f285"; -} -.fa-fort-awesome:before { - content: "\f286"; -} -.fa-usb:before { - content: "\f287"; -} -.fa-product-hunt:before { - content: "\f288"; -} -.fa-mixcloud:before { - content: "\f289"; -} -.fa-scribd:before { - content: "\f28a"; -} -.fa-pause-circle:before { - content: "\f28b"; -} -.fa-pause-circle-o:before { - content: "\f28c"; -} -.fa-stop-circle:before { - content: "\f28d"; -} -.fa-stop-circle-o:before { - content: "\f28e"; -} -.fa-shopping-bag:before { - content: "\f290"; -} -.fa-shopping-basket:before { - content: "\f291"; -} -.fa-hashtag:before { - content: "\f292"; -} -.fa-bluetooth:before { - content: "\f293"; -} -.fa-bluetooth-b:before { - content: "\f294"; -} -.fa-percent:before { - content: "\f295"; -} -.fa-gitlab:before { - content: "\f296"; -} -.fa-wpbeginner:before { - content: "\f297"; -} -.fa-wpforms:before { - content: "\f298"; -} -.fa-envira:before { - content: "\f299"; -} -.fa-universal-access:before { - content: "\f29a"; -} -.fa-wheelchair-alt:before { - content: "\f29b"; -} -.fa-question-circle-o:before { - content: "\f29c"; -} -.fa-blind:before { - content: "\f29d"; -} -.fa-audio-description:before { - content: "\f29e"; -} -.fa-volume-control-phone:before { - content: "\f2a0"; -} -.fa-braille:before { - content: "\f2a1"; -} -.fa-assistive-listening-systems:before { - content: "\f2a2"; -} -.fa-asl-interpreting:before, -.fa-american-sign-language-interpreting:before { - content: "\f2a3"; -} -.fa-deafness:before, -.fa-hard-of-hearing:before, -.fa-deaf:before { - content: "\f2a4"; -} -.fa-glide:before { - content: "\f2a5"; -} -.fa-glide-g:before { - content: "\f2a6"; -} -.fa-signing:before, -.fa-sign-language:before { - content: "\f2a7"; -} -.fa-low-vision:before { - content: "\f2a8"; -} -.fa-viadeo:before { - content: "\f2a9"; -} -.fa-viadeo-square:before { - content: "\f2aa"; -} -.fa-snapchat:before { - content: "\f2ab"; -} -.fa-snapchat-ghost:before { - content: "\f2ac"; -} -.fa-snapchat-square:before { - content: "\f2ad"; -} -.fa-pied-piper:before { - content: "\f2ae"; -} -.fa-first-order:before { - content: "\f2b0"; -} -.fa-yoast:before { - content: "\f2b1"; -} -.fa-themeisle:before { - content: "\f2b2"; -} -.fa-google-plus-circle:before, -.fa-google-plus-official:before { - content: "\f2b3"; -} -.fa-fa:before, -.fa-font-awesome:before { - content: "\f2b4"; -} -.fa-handshake-o:before { - content: "\f2b5"; -} -.fa-envelope-open:before { - content: "\f2b6"; -} -.fa-envelope-open-o:before { - content: "\f2b7"; -} -.fa-linode:before { - content: "\f2b8"; -} -.fa-address-book:before { - content: "\f2b9"; -} -.fa-address-book-o:before { - content: "\f2ba"; -} -.fa-vcard:before, -.fa-address-card:before { - content: "\f2bb"; -} -.fa-vcard-o:before, -.fa-address-card-o:before { - content: "\f2bc"; -} -.fa-user-circle:before { - content: "\f2bd"; -} -.fa-user-circle-o:before { - content: "\f2be"; -} -.fa-user-o:before { - content: "\f2c0"; -} -.fa-id-badge:before { - content: "\f2c1"; -} -.fa-drivers-license:before, -.fa-id-card:before { - content: "\f2c2"; -} -.fa-drivers-license-o:before, -.fa-id-card-o:before { - content: "\f2c3"; -} -.fa-quora:before { - content: "\f2c4"; -} -.fa-free-code-camp:before { - content: "\f2c5"; -} -.fa-telegram:before { - content: "\f2c6"; -} -.fa-thermometer-4:before, -.fa-thermometer:before, -.fa-thermometer-full:before { - content: "\f2c7"; -} -.fa-thermometer-3:before, -.fa-thermometer-three-quarters:before { - content: "\f2c8"; -} -.fa-thermometer-2:before, -.fa-thermometer-half:before { - content: "\f2c9"; -} -.fa-thermometer-1:before, -.fa-thermometer-quarter:before { - content: "\f2ca"; -} -.fa-thermometer-0:before, -.fa-thermometer-empty:before { - content: "\f2cb"; -} -.fa-shower:before { - content: "\f2cc"; -} -.fa-bathtub:before, -.fa-s15:before, -.fa-bath:before { - content: "\f2cd"; -} -.fa-podcast:before { - content: "\f2ce"; -} -.fa-window-maximize:before { - content: "\f2d0"; -} -.fa-window-minimize:before { - content: "\f2d1"; -} -.fa-window-restore:before { - content: "\f2d2"; -} -.fa-times-rectangle:before, -.fa-window-close:before { - content: "\f2d3"; -} -.fa-times-rectangle-o:before, -.fa-window-close-o:before { - content: "\f2d4"; -} -.fa-bandcamp:before { - content: "\f2d5"; -} -.fa-grav:before { - content: "\f2d6"; -} -.fa-etsy:before { - content: "\f2d7"; -} -.fa-imdb:before { - content: "\f2d8"; -} -.fa-ravelry:before { - content: "\f2d9"; -} -.fa-eercast:before { - content: "\f2da"; -} -.fa-microchip:before { - content: "\f2db"; -} -.fa-snowflake-o:before { - content: "\f2dc"; -} -.fa-superpowers:before { - content: "\f2dd"; -} -.fa-wpexplorer:before { - content: "\f2de"; -} -.fa-meetup:before { - content: "\f2e0"; -} -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - border: 0; -} -.sr-only-focusable:active, -.sr-only-focusable:focus { - position: static; - width: auto; - height: auto; - margin: 0; - overflow: visible; - clip: auto; -} diff --git a/doc/_static/fontawesome-webfont.eot b/doc/_static/fontawesome-webfont.eot deleted file mode 100644 index e9f60ca953f93e35eab4108bd414bc02ddcf3928..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 165742 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 G*yFsbY~cLNXLP73aA74Mq6M9f^&YV`isWW zU@CY~qxP|&bnWBDi{LM9r0!uDR`&3$@xh)p^>voF;SAaZi_ozepkmLV+&hGKrp0jy9{6cAs)nGCitl6Cw2c%Z0GVz1C zH-$3>en`tRh)Z(8))4y=esC5oyjkopd;K_uLM(K16Uoowyo4@9gTv5u=A_uBd0McB zG~8g=+O1_GWtp;w*7oD;g7xT0>D9KH`rx%cs^JH~P_@+@N5^&vZtAIXZ@TH+Rb$iX zv8(8dKV^46(Z&yFGFn4hNolFPVozn;+&27G?m@2LsJe7YgGEHj?!M`nn`S-w=q$Y4 zB>(63Fnnw_J_&IJT0ztZtSecc!QccI&<3XK0KsV4VV(j@25^A-xlh_$hgq6}Ke~GZ zhiQV3X|Mlv6UKb8uXL$*D>r^GD8;;u+Pi;zrDxZzjvWE#@cNGO`q~o7B+DH$I?5#T zf_t7@)B41BzjIgI68Bcci{s-$P8pU>=kLG8SB$x;c&X=_mE3UN@*eF+YgP|eXQVn) z)pd&9U^7r1QaaX{+Wb-9S8_jQZC19~W) z*_+RuH*MPD=B_m7we#2A@YwQv$kH2gA%qk7H)?k!jWbzcHWK497Ke<$ggzW+IYI2A zFQ_A$Ae4bxFvl4XPu2-7cn1vW-EWQ6?|>Qm*6uI!JNaRLXZFc5@3r48t0~)bwpU*5 z-KNE}N45AiuXh{&18l_quuV$6w|?c-PtzqcPhY)q{d+Hc_@OkartG`dddteZXK&Je zGpYJ-+PmEUR`sOnx42*X$6KT~@9ze#J>YvvaN24jI}4QG3M;w<>~!2i@r)9lI!6N1 z0GN((xJjHUB^|#9vJgy=07qv}Kw>zE+6qQns-L}JIqLFtY3pDu_$~YrZOO$WEpF>3 zXTu#w7J9w+@)x-6oW(5`w;GI8gk@*+!5ew8iD$g=DR*n@|2*R`zxe7azdr7~Z;$%< zSH@*lQ9U(Hx^%Fb|1?Smv({(NaZW+DGsnNWwX(DFUG8)(b6Rn>MzUxlZhNbVe>`mS zl&aJjk3F~9{lT-}y>e~pI}kOf@0^%Vdj&m(iK4LTf6kmF!_0HQ$`f-eBnmdTsf$_3 zR`hz2EjKIKWL6z@jj1}us>ZmY)iQInPifzSiOFN92j9$pX*CuV8SPrD#b%Qa97~TI zS6)?BPUgFnkqG8{{HUwd)%ZsvurI~=Jr8YSkhUA!RANJ;o|D->9S9QB5DxTybH&PGFtc0Z>dLwr|Ah}aX`XwTtE&UssYSEILtNijh)8)WWjMm$uT;+p1|=L z><4lEg%APBLn+FRr&2tGd)7icqrVXFE;+3j`3p~mvsiDMU>yK$19$B@8$Dy4GClfzo4)s_o2NuM3t-WhCrXE>LQ z_CQtR*!a0mhnw#I2S=WxT_H@^Saif`)uhLNJC zq4{bSCwYBd!4>6KGH5y~WZc@7_X~RqtaSN(`jfT!KhgGR)3iN50ecR$!|?Vq8|xa+ zY#*+B=>j4;wypclu7?wd+y06`GlVf2vBXzuPA;JgpfkIa1gXG88sZ*aS`(w z_9`LL4@aT0p!4H7sWP`mwUZRKCu@UWdNi-yebkfmNN+*QU+N*lf6BAJ$FNs^SLmDz z^algGcLq`f>-uKOd_Ws4y^1_2ucQaL>xyaQjy!eVD6OQi>km;_zvHS=ZpZZrw4)}Z zPz(rC?a`hZiQV9o^s>b?f-~ljm1*4IE<3plqCV}_shIiuQl=uKB4vUx2T$RCFr0{u z1v660Y3?>kX@{19i6;*CA}pJsFpo{nculW61+66XAOBZD< z{H|h`mJS5C2;ymL##}U*MC%fL0R97OSQ@lUXQ-j?i{z{=l-!$64H{LlTLo{Ln<|OV zBWq*5LP`KJl74fC{GzzP_Z;;;6i--QpZUrtHC@+RBlt+=_3TyV4gk=4b{TBJAx!GehYbTby(&-R337 zQ%g2)Uc&K|x|eL0yR*VCXDBqZ89C(obOFYYht(k`^q0OaQ*Y{)@7xE~KQ7XN)hGlZ zl5$1<#s!tyf%>mbIG(9WR`R*{Qc_h(ZGT^8>7lXOw^g1iIE2EdRaR^3nx_UUDy#W6 zy!q(v^QLL*42nxBK!$WVOv)I9Z4InlKtv#qJOzoZTxx86<5tQ*v528nxJ^sm+_tRp zT7oVNE7-NgcoqA#NPr*AT|8xEa)x&K#QaWEb{M34!cH-0Ro63!ec@APIJoOuP&|13 z9CFAVMAe@*(L6g{3h&p2m!K zEG?(A$c(3trJ5LHQ@(h3@`CB*ep}GDYSOwpgT=cZU;F&F6(b=V*TLLD z*fq(p>yRHTG1ttB*(Q8xLAl4cZdp^?6=QjcG;_V(q>MY0FOru|-SE}@^WElQTpCQZ zAMJy_$l;GISf1ZmbTzkD(^S!#q?(lDIA?SIrj2H$hs*|^{b|Kp!zXPTcjcCcfA+KN zdlV!rFo2RY@10$^a_d*-?j7HJC;KhfoB%@;*{;(hx_iP`#qI(?qa{b zH|YEvx~cE^RQ4J}dS>z%gK-XYm&uvZcgoyLClEhS(`FJ^zV!Vl&2c{U4N9z_|1($J znob`V2~>KDKA&dTi9YwyS#e-5dYkH?3rN(#;$}@K&5Yu}2s&MGF*w{xhbAzS@z(qi z&k99O!34}xTQ`?X!RRgjc)80Qud0{3UN4(nS5uZ1#K=^l&$CdhVr%4<67S=#uNP z$hnqV471K$Gy&){4ElZt?A?0NLoW2o_3R)!o~sw#>7&;Vq954STsM(+32Z#w^MksO zsrqpE@Js9$)|uQzKbXiMwttapenf8iB|j(wIa2-@GqE@(2P#M09Rvvhdu!sE0Mx&cK&$EtK}}WywYEC~MF5r3cUj%d$|lLwY4>`) z_D++uNojUl@4Cz8YF3nvwp>JWtwGtSG`nnfeNp(_RYv`S2?qhgb_(1$KD6ymTRgnD zx^~3GBD2+4vB9{=V_iMG*kQTX;ycG^`f{n+VxR4Ah!t~JQ6Z?Q;ws}Jw|#YE0jR0S z+36oq6_8xno^4J?Y02d!iad3xPm+8~r^*Vvr4A<|$^#UEbKvJ9YHF=Ch2jF`4!QS# zl8We8%)x>ejzT^IH%ymE#EBe2~-$}ZXtz&vZ_NgVk4kc zOv-dk(6ie2e{lAqYwn9Q$weL#^Nh?MpPUK z#Cb)4d96*6`>t7Zwsz#_qbv6CnswLS9Jt|b`8Mqz?`?H1tT99K#4#d+VwAy}#eC74 z;%UFxaNB!Zw`R9){Pncrny4>k;D}TV2BU0ua-+Fsp>wmcX#SGkn`h0O`pN*`jUj8q zIlnc7x6NRbR)=wP1g`-}2unC>O6ow=s{=NV6pfEo3=tY8 z=*$TKFk8Wv0K8B_**m*Q>+VW*1&gD#{#GSc(h#YQL?*<(ZUx~>L^RyAG3}j0&Q|mJtT7ec|Y7cr~ z+A`Wz!Sqz9bk0u-kftk^q{FPl4N+T(>4(fl@jEEVfNE$b*XSE)(t-A>4>`O^cXfrj zd_nrA-@@u?czM(o3OVDok%p3(((12`76;LwysK$;diTl$BdV)!p5Gj=swpb=j2N>b zqJ1D5E#zO9e(vJ6+rGuy<(PS-B6=gHvFat&)qr%j7T`vT1ju zIvHwGCk5)id{uDi@-e?0J*(-W-RGZs)uhSeqv7TA&h|CUx(R0ysoiQC8XnxL&RXI3 zO`H`8Pe&^ePw*`{rIJhzUg@MuhUL`IONG^*V?R0h5@BRDFgEF45b0jSrg0r{<4X)nw^c)uQ_Ai_p>ic!=K$pmnyqYb=`6fUo40ru#Gh= zMRJxOD(1n?Mjz_|IWyJK5^fh3*n>eI0MmEKq%=-oIdGd4F-LT>RL)Bp5FWxb4aNLNXB^o?YBSXQ`SwN zI*N~(CQW~P$HpzwrMG4IZKI>TVI4nQ$a-#)zV}LE(xgQ5MG@L#e!e@ ziNtg{Ph&qpX9FLaMlqMh>3)Nu%sAO#1NEsbe=#4Vqx0Y;<~+mV!xwj%}Z=xZn= zSqjxSH4T~v>Xd*=2wmHPN?@+9!}aQz-9(UIITZ==EB9}pgY1H4xu^-WdOFSK!ocZc zd-qhN$eZcN#Q^0>8J%)XI$4W(IW6R810*ucIM7Q#`twI|?$LYR1kr>3#{B{Z4X(xm&Cb21d^F9MKiD=wk_r+a=nyK!s^$zdXglCdshbfKBqa5aMwN#LmSNj6+DPhH4K-GxRl;#@=IJc zm{h}JsmQFrHCioWCBGzjr5p9L4$t4`c5#Cz(NJ#+R7q-)Tx2)6>#WZDhLGJD964iJ zJXu`snOYJYy=`<+b*HDiI9XPo8XK$TF86)Ub5=NC@VN#f$~GDsjk01g$;wDY!KqOh zC$x={(PT7CH7c?ZPH{RNz}Tel$>M0p;je4|O2|%Yq8@sCb7gRhgR4a*qf+WGD>E8~ z`wb<@^QX)i-7&*Z>U6qXMt_B2M#tzmqZTA1PNgzcvs|(|-E z4t*ZT-`kgepLl0g1>H!{(h8b`Ko=fR+|!L_Iji>5-Qf34-}z%X8+*Qwe^XrIS4Re$ zWUblH=yEfj!IgeIQ>m}+`V(4u?6c;s&Ym_6+pt|V`IQ1!oAC@R1XC3tL4BQ7`!TnU zWaoqG=nhI@e7dV7)8VzO8ivuC!q{hcxO7fo#2I=<`rktP0OfAO-CQE!ZT@}e7lw;{c) z@2l7RV$@&S5H@{=Bj~^Kp5At=Jq=Y92rXP@{-D4j>U=-a^gM2s-nIZA;u=fbm2BP=Zca5W81_cA>Tr z)x+r@{pu_la2Q(wm`Zqyd@GhNDNT&4oNHb_>w4{jIU}m&iXykMxvi;WL8;y7t}cp& z9CEpR)WlI1qmOq!zg4QTmzv#eP3>NLd7V-+YKmuyLFP533rd>WnvL$F3b}g39PYk; z)^hXQ%5jO(B}-TMio7@t<(V?7M5!ycd)u4Z+~!hym9+KwPVO^Wkhi^Dc7$R@)o$oh z^mRbgQ@5EvalJa}V4Bi3cs^w5pYtbXXz5W|e%+z-K;8M%Lf~BlZRvNI7=)cG6lbjg z?)l8iOw!mU`uaKN@UL4>d#edM9^-ePb(VICy6Cg-H^Ew$n_s801w`A83W!_Z{D+1G z(<9A>WB@>)D%cxw7c?Xv7N}6gg?&TkLX|0@k&VL)YMI~SsE^dzj2^3BKL7SM$!0Lt zj;ytKWw|(58n6_NNH$JVRh!W*wewMr7)H2jOCruuJAIIfPMFpf6j=hL!D3nVT9Dpo zut}|VoG<%v&w;HrQtz<%%T&X##*z5{D!!egoRN}R_Xxuy+E3dhx6!7mlNyuqsKR-P zlP#8EKGt{Ij~8kXY?&*%q)PkPG;rziWPd>HefyPwV49!>f&Q_@Fn{8Cyz{HCXuo+( zJMu<#{Tl}^-dh%nM0IrDa@V zMHgAog4`tk;DNK-c{HwRhx%Fn%ir3mex!XeZQ4QY)vQ_iZ(j4-GcO?@6Z-Y*f?u7_ zmf!}WRoGkI#BO9;5CFvMobtV@Qm?#eNKbbX!O@xEVhnm z6LFnWu=E}6kB82ZEf!g}n5&IuivccTHk-_5cazDAe+O!_j+dQ~aUBy~PM34Eq0X-LOl zjunFnO<4Nq|BL`!xwvyj&g9Q0(A_*xLT~l{^nM&kGzB7+^hP^L&bD7iVdXe3wobJXVX~o*tX$ zI5xthE?gAl!4+v~+ASbN2nYIqNn_#3>!fi2k=g*Hg_%caA#plNQR+RtHTiW>(*OFG*-nzu~6DMCrX>xzP`3sj}D!||8 zf3dk-w(NCUMu^C%k|t?sa>9gU_Ms-R2Hhm~4jNfPPyH!3Zy zV0QFf=MWK%>|(eV$pB5qOkC)uou{oIJwb_i4epV{W95%N)`+uOrLx7fNtD^czsq4B znAWb+Zsk|YX}a?b+sS-!*t2w1JUqU6Ol`&Jrqa5=4eeLWzr1DX1fWW`6MYf+8SOW< z+EMJ|fp${RJ7q9G7J+`pLof$#kBJP^i@%wNnG3fnK?&k>3IUVo3dbs9Nt)x_q|wIB zlBAi#1Xv-<+nr<13SBfkdzI?dJ|3~?-e>MzG(yRsA}I_oEd{HEGZ&7H|Km9mEbL6r z{Ubhh;h6_QXN_?>r(eWJ@CM1-yn6Y#am!aXXW!EfCpu}=btdYT?EJ>j+jeuc%;P2g z5*J%*$9La$^cy>u0DqjO#J%*IdaaPnAX#A6rRQ+sAHhY@o32==Ct3IF&sM14!2`FD zA))>ZKsccTyp$U0)vjABEY_N5lh(@e+Gj>sYOTgf?=82K)zw-?JX2d$x}n2Y0v%SjDtBXDxV2TyyxQmN?2%8zkKkKF*!AA$P$1#qrF%fUu~URt`tp3C_(>^tkcbHhO0Hh0A zpTVQR{DjsD=y-Bsl#nuTVKRxYbjpSJg|K+SEP+^Y*z3S9p(_-s9^YP5Zc?Vz*o(Qx z?f03co`dGfW}0T>UdEZaW>s0XVEzlw@s&bc+B-9;^^AGsx$AE~!1-7?tn9z|p4}_? zRsM&sjg1>#Rb#6jFBRKMeZ>I_4<%=&rF3yqUD&Lik@7<@2*(0rC)UqPj`Gfe8L&{S zhGtB67KhF{GnLZCF}gN0IrIPU_9lQ)mFNEOyl0tx-!qeCCX<;7*??>lNC*Q7`xe43 z2$7wD3MhiII4W*v6;Y775v{FSYqhp+|6)6BZR@Rdz4}#KZR4%=+E%T%_gX8-9KPT4 zo|$Aa1ohtUet#uro3p&@^FHhEX`OcGjq==$UeAQ~<6AZzZ|l75nn<#}+mo0rqWv5$ z1N<|1yMgX+Qmz?53v|%P=^&74bwqfH?xIC`L()W{|G`j^>kbs7q<$hb6fL@S za#nHyi$$TJ7*i!6estChR}QriMs#yy!@Po#AYdeWL~* zUR%)FT#4Q~O-N!O&it}b8zFOmbe=egH*Ka<9jT?dFCMAcagAo<>tKrW%w?P_A_gd& zXwHTn>a>WEWRzimu7EJ*$3~Jfv|@bLg}6iH4mgJB!o60eP#_N!xYrQoMf4&rGLau~D9ila zYGD*3*MNN?v*n6op+dQM!Kkr@qH1|^ zh7skG&aC;+$C$OSR2!ke>7|B6JDpjV%$Jo5hI14PGyx1I=Diw7>h@vzL?PLTzC;`; z?}nkmP%J6$BG!9mxz?+Np zIHbVy&<#H&Ekz1(ksSJ_NDQ+XHyg-!YcW8YvE5v*jFQ->F;|Q-IB@Mw6YP~v=jY$~9n@~8MVO{1g z@g=-I$aXs1BH&>hK(~|d>Y9n*;xRm&07=pLuqVYV-bwyCUIKgMdLSrovEs2f3{b z<++d|UX&}*7)y8){Ntc{RL*udOS8r%JV4EZ64fUF85n7%NAWejYbLV}NB|lS>SnYN z?PFpysSR*OodDcNK;OVKsSbKS^g;|bSdogA=};1?3rYq|Nc_tR!b2ln>=bNTL59uS zZjF^Y1RoS7qF^>LEqt<#Mu0ZjpiUNLtsc5%t*8}5lW4OWwFXfqGn-q~H)5}2mSRZ^ zKpfQxOe+KC(M5V`tz1zQ)@pTTQ2?NgStmwpvPCi&U9wd)m<^I-w&{(`Vb?Q*4ApV5 z(G}DMfgox!S_C+OTa5UkEbB#G$SC<8vLrDPPT_Uq5N~7`%Js5Ut3!o!f@HJm?b;(N zbbv90V6J7=E&)E`b|}N4n`VOOuvo$IEMx`%EkX8mpug0yY80enF3?M57gI zQ((b(;dv_v7PDKFgL|6)q^sb%Gp_aU)wp^uX96>jGEsOmBhyuDZ8}+y{bG?UqGqyDfYMtJ{6@xXI>fVC9g+uG zbQzl4fY>P6VAkv8GEpapl2>quqSIoui)Mr95Nuw@voGBux%Mq zYqG!&A9RXvoI%gZRwI->g2SYPB1tbg0U9UkC70cRFPTKU0L{E!2e?|as;p-wNwA;> zm}yKfYURNzE545Jz^T+srPZUGX{3qx0H&3ol`)Eow3xXj!2lx+DkB=}EoF`(n^)2W z_26hljpwvSdw}akJQN9;WAQnnHTN=3Ko19hR`Qqt#60*^1acxN84Oi8W-4nXd^@w0 zVpMzKqWw_(cHwQ`*uQ>F4F;Ncc?}XU{q867ZF>zihsu1j_i%f38%41S53RkO-5Bq< z<^ffy6fQNDn;z=lDz2OXjU+MMr0ziZ)HseHI3+}-N8v$8UWEK_n5pL6VPUS@YH^ z-F?^bJ%5Vt}@l0B2B$XfpF!7J0KUW$rc!~hPD3+Ms%)ia=pl{0nuS0_) zMk9rt16uqE&;%{gtVGqhUs{u$%()O~zzC_11`vYVVXfdfEU}YwTDn~JYTSiTDRNih z4#ap?$m%48h4*c`rhEH7?VLTW9aCi~b>z~)W0xM$c|y(8H%u~4?Yic=Yr3WyCvBMC z9P;P}Ra`!CY1TVd3~%qgX48EO<*6O5d**2Osm_lAM&ZKw?7XUKU$o?gjCIcqH|%NJ zuxtIAj>_t$YW%D0ShIfD2DzU5%qnHsRN0vm^B3-wcim7D^;K7~Uj8EuKZ;X3tlbVD z(=eh%wxAVAWPvDL3Mmg=TPKpMGzTdG=aT&qTw(TFBIg<;`kFOrB)&>#;&>KE1kb>+ z2B2dhdAN+pj}^ZH_t#P}WOC_RDs4ppbD0<}eknMnviR2G%#`AniYwzKw-y(_5*$-_ zmw5S-TNmxQbkR$TmM>p=*`CF(EG{@lszbazB$k;2MYhTooy&w{`02hJ3>+yIKEOe7 z@JMkSHwDW^-jsRwlSM}sEqQs-p1n(#FUOllp3=O)Tup&?1<^)a@`nk7JGz35N>n$} zBOy~(>fI9qX^_jCE*5|=cn@Q((|dZ4jk)4MmOAk+0xA#wuDRF-%lTtBwIA!9Gr9Ct z$c`7mj%LBTedqC%Rm_T=dk5?Lu6Ta&XaF9q!a$AUtk$ z*e$72Su7q{Rad`o)%w|Sbyv5rzAip{{VH|GtUY1tf`Dk1!6*HuN9YH|>@$Gpvq}N6 zCzbi<_XLxmE|LLdr@JCzPlDyUYO2J>kDK?krp5CY@11*7)8aCVVb&~zrEGE2O>>tojkD`+_dDb1*Ao``HQpP(giSRL)4OKuTMcNVOb@(m7M?noGc?geUJ;8t6u0>WYa5RLDJ>(^Zu~>-DTzEbb z=Pw6=C#Q(ao#It|Sa^jEBWtV8YNL5Ce+KO1 zHqBg6?QNQUAP0QbaOG=Lqb?5ZLlZP3JdqXFBbSG?_!QPegco`UzEDBCfy7n?l|5O(2uWh*{9fh*}OFkZGv)4J9g^Su_Z-y zktO~$6KAdO?4HIhm;a)+gVRbF%BNDw_qH-YUp3>pUiriPU-DaPao4J;%WF%Dllm58 z#~3FQnvO5O$UIv}o~Up(EN-l>@f8Ipwl+*yG^2h|U81N>`H9+~R;Nq6WZk+k_l_|; zqH`}-wki9Eekf?yVOxp~wx$i7mS&wyRfA;|YZ$pD0iFQM7=^Of;Mb5{*g%Q+MV}ZZ z4uCY|_@8q>JQ{}h=B5NG!svf6mRKr5#bVli@?ZR%doi+~75m0rb2XFdcTK&}XtK)Y z#n$?!<(KX3?3gc;rSMQ3)+>e{<=;f)h)dXgJA+DdJ5q_(=fbyjlD zyxOq~%LPEFsh*KmXEIW|_M9hDm%Gdrv97&s&LCvUqb)02CoZ4W(b4X%EB2q(#G5YM z&@wJkH_qwtRocyZt7Y4`(pa=cD4!kEPl#4{yum=*q|U{&O2DV&=)yXRws%3})r>`7 zty6tM=kuW2FpR*(!{^GYty*Jp1woSmG%(Qs4H^#!;!Q>OdkH@{*K(vzM1v#qO$_R{ z7+Jto9d&*4xTs#V1lt-9mM`tTxU{8|32n(X!6M-UNsS#R?m__F|Gn3X9 z&{djT%C$c`e{S8Bi4#KMy0LTS?(Vvq%{y6Caq7xk-@t{Re0DV4heM^6gkrEpL-{{% z)|>$4EU3Gq;JmPH{E@zsRX+#@>gc;qk2i2FwVHuCI??#%xdiMweM zWaT78*EG!|+OV634wd0UaR@TenRhksaP%AUUdHC0VcZ2nT> z|Lq#TX5O&2h!GYviFiX{IRHYEViDCLf^Wf)se&K4oOU>MQK$_!7!L(|E5Bx`dn|^Z z8D!P9pUu^~tYLFpB<~24WRqgt9Jadj5ce6JRV}}8O%6hRA!!0JH5LHs91WhgWWLJ- z!KL(|#^$p^amdJ5g8rZ$Ggy6?%`B;J_Kppf<0XMKcmmW9@>-TJn~gIShXI5aI(xEx zlSd-_6cOeEGR2J$MBqWpK*2%7D7_wEFG0(EP;?Sr1EpZsk|pld3%9nq47KjwNtga; z^X`AUY0HzBudMExSE>hYgVxdT>O;3bbp6&zv#t6lVjtU=7OitgFDbdK>r_jozEYb*t7qdj?MRk%pu)4==CR^bNgHOU-j*emraW7T2WR%b?1^<K?p<`lIUQwM$W=cui|bx}?bTOb6E1v3`QcM^BdcQe z=PpkFc*njs2H)6MH*NX+$l&D3bkD1=@_CF6^b#6m7%YZwDoKJobt%*>6l7EZ=V>@G zzzY{zEr!q?#B%Vk9VD%4E~MxbJ)hcn+q^0Z=@qNy9XNJiUX{8Ns(OzNq-fqrsbhbE ziWT!T7SLhKQavnveOJ`2^uK@O;eGSx?>nsSlq%#_#sdo9iphZ#Jwo|{FhMbfSrS>R zQiwFss8KQy?9j`|&<*8j64q^OVgV#e63^ksE_l^9($wb9f`EyHv4&?kqn<@TAOMm< ze1YGL4dcENbcWZd&n7h~Atmwe(#RoslRpeyDguGF}j}$MRo9?SM8!=4Q2wU($EzceOopeaHDv$UhoQfY3;W=e^g5xM87H z;I{8*GeL)G;HH8ITBt8$#)NOPnG>ql&Qh*h zWt>ty34rm;*F33uigBg#?eg{u7R{5>Q`U$R2j3@_Lkx_M{bOC#*zx1XR_*c*B-IGq(GV|B@o{8hJ3p1*lD@AJn%&$i*n1|9(=hKoMs|KsjeFu0HwhG-gj z6NR02xQ2KllvU2l&Q+ddYuKj6LihSj-&!x-tUR@F>EtCIlkybUel`o1t{IyqKm3Y# z^I%x~1FN64cI~X$=bbnBPUd;Rxn=jXhSG-2Z`jT3lX2q?hsL#({W072*)OlJJQjT){R0dcw$MIV@Im_3E)riYBiU=q`Y_6ca&e9uVeb_jW)Y(*6X`BKYM85 z!b8t)Ui*XT*XL>UuiVO9x8B8yUlNM}WBcAqm)&yESfoE>5R7X!w(jnYSbl8TpaivJ~v3;LD^f$vOykiS%0kDp1GRq zVCg_iC;5ATIf&(~gt_DK_8Vo2`%JbUh z9jfe_*S6Eje-d8cyItyiX=UK|B_;1L?UVG9n?6x~K;xR|0vZ5x!At8OJYq-&B}jT5 z#x}{P70vb-p^szS5EvI&o&q#3;_jrm%4X&6S8u*@Sv#ZVm@V<@Hf3s4l;7vm>@w-r|)yZS%w?(I1*QeIrsG=I+5nepzsGxrc~ z!pSc|SCA)uB~*o*q}1leH+COyX<6)cl^Ly@AOH2^A6)<8mq0BH{PW9E7WVFW74(6f z)`kEd2^SPxr15s^#3*QkxXWqEyk{wqj1GtNbEQ|(J1tK6 zUnIYs&2$CihuMv=&x^lu`v>+G339PrtlYp%HorK*>MU~Tjmr477+hGhviLYl@>d-K zU!uTPY~kv}%w^h&xW}uU?TFq&;?(Rl#6glkWN>Gw4B#URl`pWSWHsaPj-^{T?+Rl%;){@`StD{A2dwJ|V96v& z$16bph~Zles|b2KXKVo$Gy2J6qqP8xDY~bRh4}rn$()b-mt@e#Fwd)MdNQq8Y*-I^ zKqOSY68uyOQhX&e!epDI){mhNNM=IwXQLY2+&brLfPWf!2x1u(hS5ey?BxMlyyvL* z=no!g*pcWU2>q^rYg;4Lqki3-zG)X;d+6E=r*#^~7*m$_EGg_eQ=4jA+oZ8YMYWd6 zb?&a!UGBQcmfE7Cu~J)W?WPsCJoTfeZdoCs5nPtKdb}+(w{hma1+}#c_RZX|z*J-U z`YpG79lHe^?%Xkc?nU**&Cy^m+F0WA*VWfFHrCYF`F$mgbgj9#{-U|#cig$|;T=<^ z?0A^d|2~dA8{jc0T&>LodGPkA2Ce<%xn1wIlX?a%!@Eq4Md6Y$Pjh8C)#tL9&B{-Z zDl*AaMfM==qY6ZMs*j2-_o&#DtOvEgKO^o#a!G8V!FLJa99SgR=R+3-1WD>6kPt4T zQEnn&KOhDe*4&&kDJBfJWl@4anq%Se(e27Iv}pbO#r>3wvWJpUt}zNZYx9klkhS?P zCbrI418eh@4+uTT5z<4YR!}Wu!0bb{)|g-CHs~wgPLx_;gZ}Pe*r4aOmyr#+pp0lb zHFY6iYKHu9A$fn1?OWE+XV41w8uJSK1!e3*OLwh>v1U`ou!Z{BA27G z@n6d|J;N3qwe4uQiV3KTDcpf57p!m?0p3so1Ax@X#2IiaA}2>9&SUXL^1&>Xh8#Oo zQ?C?L-8M|oiJLpU6Q{%GGh;&0K{owhQSY%3!h1qcSn>U|R_L;f`cCNUO-efJ#sSbh zkg5Hb9y)Ys=YeAvt+X|EzTjRz37BGClh(UmXfNBmxvV{Ttan9870vRhk`;uSF?`m! zyWBXXtg*^vTY1s31F*aP^xb!Xf`+yrz9*G!3+V51{2PK^bPhMbp(nxq$mtS*2*~V% z(N&JbY2FYBI?V#24?IeNyZFFOpZ~&zB|@M?sbh`bnlV9zkG}tHdLK zx+5aQXm)byO7#8XHFtDn$5~LO*5aqH%?m z$2wT6nTmGDI)?$JimeWHNO7Kra|S#r4ugug1UgoGf)+&L03keV@p1OHE$p^lBA zt*GJGLDNniq=XZ4I+Mb*82pqbfoQ@+p_JGdB0aQaeTB!Lr#Z$97FjWL@MMe@Z^D+s z&IK)jih;Wbb%1MocDc@#$)|IKVWN*g2&aNVGFMmdoaL`cE`T^;1?Tcf@^i>q-czu= zA7p!sX62V=__ATa&S(g9I0rd{)J6Sdr^qB}JA4(U(1Y-`7)a4D)MA`g7I!Mwm6+KC z^C_nUK7sX}(ukntS*u>(uyyY=UeDi#4Mlus`)o8@(xaLmYhKp;LGw3oP&Rni)G|cQ z7Ur#P!U!VO1g(pNoJAP;`R9fA(}??`-wW?AJpaG_{Fi;Nu)eT^;QuU%IRlFc*+_>_ zx`&U5+e^|ih7FuRhmOU(m+aK71UlNUGH`jW!KA(Xf;sb)=69M;|L@O||H&xL zl74Wt!{fDxvzf&5M8E`Lo>IUfK@P&dqXA1j9Ysfw#32a=jPn2f=>Dps?=)zh0y=nF zlN*J67GXr@2Az6He%|WXWJyrTG^F6<|JoS+k`Xm{tCR{6!43_i__z|&s!LT*4`;a3 zwB^UO!_$ZGtWdT77?_S^7Dqv~y|xiDP)-YnK8%pxr7p+Lxp?4~wPvULd zUmZLLn47GQg>WUt!yAzB$G%F{zYS~B=am%aex&q3x^I|U4B;Xp?}AZk z^YIrlk>Jo6{xrIjl;V~Ot%d0#DhpmMHo+{Xi^Rz)*c5L{kRh`PE-|>;1QQ0h^lDfo zd@>|=U5Y91Dt-M)<#*Gl`Fr}3$-Z}Nfx!+IeZ!v7G% ztcDQl>kp+vdVk8V$G)HSg>V(Daj1A4`JRB+&HA5cq3-~n7Y2oBATKb2YG`uA6X8S{ zY?6>Vt(nsVyAxRF6YnNNtUn~CLrIFaIITfuxMVt=e)j}2Or%oj&|p93A5+|pOZ*pd z#pmb`Sv&G65piAWD5e2SoNSIcgY-cWl#06J$28$_X(YT)8umd{pHg7Zo=kQW0->a_ z7yr))>upwE8ZMWr(itk!ke5-mNGO~-u?owjq}8&~H}EaBRQUYJk_kzaMJ-j~1H#0S z1rxw$&lCSsY5*5Eh9p`{{~@y^&(mjM(r6cji;VSvEmZ0dZ}u7v>WxNaH@lu48ujuc z{04p_HtH?AmEG!dXI$pv!-8`CYpz_XJ(2siAQuczyy!!@pi$wT{)yp>!Xhe@`nl`z z1^zAe8p<`=WnrFL1*!@PPZ=huBJ={PS>a{s$9bBsNe$AX5$!cHKZH|luaOs}hA*pi zw$Rj=>@_5!LqS+x4X9Y`l2I@7_L`@81m(I&E!VL96$Z9khIpPCg?Db=MU?BT)g7f3 z1oR}eOn#rEov2`=TqatC@g-cu`;n}|1~nUG-Vnn;qJfhg6hp5T(E`dSLj-kY;GX6Q zi-z9$l?TDudYiv<9p*t?+4_WO=CNA5llp|}o}F1=q4CAqvoxnl z-+26xjr)Osgn&kH{tC8-tSujYAX&ByDk<0rhH0A)eE8>_MbIX>Z9mf=3Xu{d5DSGe z{bXd;!bUBGMEs02AatuZk6h5A3ny8K=vdpjVylr_0=J@48tARLevxvQQ6xQRF2uMT zDdlo6=qryT!$n?JVgWh91v4nu1G=%?-N5?j)BLSd2l{{#%0EAV&&xf1Dr{4qxZQ5= zL(D1c=mH9)qTh-=!wPQK;G!Plb9%5!QL&)AKmk+G}epRD9NQD(&9O0C6ZElh(DA_jLN=MkxobFd(kGnzu)+M~#d1*vxjpI7N&Q;y&0Q(nt9Ov@ z0UAx~93%#q(<@Bk9CzjhzLPRMRY32Y!M4>0SFb)OeWL#Q0u->@`-CeGuA;1us}BAQ zc@mIQK>2shoeQcVJ#!PiaLyd@Kj_ibnQy2+9_9fE%1-skgH%88v00xH6V6~l&y7;< z3z*+Y;rwAP`&tJ>jA`DJcZ`7&@iupQ%b%(G56`bmS<#9BG;0CU_T(luy zt=;C3Nlc<}xz{ z@bcSeLnyAw`PUGAL>*F~12pf(YnG!XZdkkO7$`Hc?ByN%$Z$rECfLDLP%2`Mw2Lkn z%iuczcuO)T(Vwa}C$&16nxS+qnzVRQ5p9I84;?;p=#nva%=pfXYl&x;$;i_ zP|dt~6wqbsm-{)G2ROAL$rK4<&wrWS4F}$7>VLjZ~K@NB#Cl zO&Qzj{Xrj9Q?1IwthH&{H`*sEN1LX>TEL$T9bDBnzAi-V%H>rqOSs{8i9DPnOQEm? zKnSNAa;HMY+M##OP3;`0pT=G%gsg(SQ~>24N?A+(Cl^G2rTi+Y_Xmo`>Wi*@@Y*8% zxO%^0U>2&c=s7QU*VIcq8^q`sm^J3$P#9i9SGJWj|-YQ|Bbro{q^IrwHjL#@aw6r zO5(p)w}zsz_FT2}`msf*s$lq^*3AS90U;2;%8zQ$AmjS~uU@58ERcbWhv?f>K#BeL zYN8qi*%SY*!e{wB?9^3;*7vWVA<6l3`r<8_4JXqkECB$U^#wWOuf$1XFNlXZ{n58dU(CAELUC!&Oi-&kb(YyL&bkw zFG94K{HSTIT!grnt(x7Mt9azgH#FZz%{*?b|DaQ#z(AfKI!4Z}p<~>Ge#1Se1*{80 z*9-3X((C!(%0GrhVCY#e9J%8rDwB&WM#Ib#hh$(WdygIeQucm3{$#|=Kl+eJTk1Z-(L@12&%MZxw-kLv=48+WES(PWIT1Ks z0C<=YX2Yy?Fc%$1$a>sE6N@S(ydbyNTznjed+MRp# zqQd(Tx2JkitUck{ZkFv%h>+T$y361us*p`!x@ITML#@u!?BZJ-!@DqEXFzk1cNoI{ zJl=+S{D?*ZKK1{XW)YK5yzt`pzw`QU#6SP_sM{sCSn6GMftpB-*B5YYd}6E1T{V8s zBM)6)8@_GeJO87$68vfVhG%-%V?Wnl^6Z65%hMOv_5&oUSnJohv?fUse?PIwpgrjj zbkDBTKUc**{+~4@My+3;_M*cli^%=z;`psm^74d} zCj*Zab%E6QT+owC_c5m2HMR6aD{F5vvrm4M^bRUw2oc1;q9jPZaA_vxsFaP~U?%O27@cleW3dOF$d>Vq0Zl}ZBVHjH ztf_?4md<5`q8EHId=*llqXPIzIAX%~1B?b5_S~HV>kar}&i$g+Smv7ZlTat1QzXxJ z$_Fac3X5RMSd@80O63eVgMA|`7viFSV3ZmRpY_8pOoLm0i@%=q@I7J=7Vq5YX9ffA z{>R`WG+DU(#C;6O|HMaLg9l zl)V7Zh_060KjCS9biA=f=azMILnJ&h}h zly@(WRadr83lyzrB*7h*#Kz%c#TEcwRZLH44Gb)Vv~oEAv$QE>6AfHr(F(C#@+ zLJlGHE;Y1|WL2(ysP_V;dWc_?Nl(dVTAaYOpjag5{{*~1y#T?AsgabJdOGqoA-oeB zE0oxN_!V3X&c0eE1?A93*;A)ACcg=udm8GzJ~h))e_kxCET|AT%Htl--e2VXnV<@TsN3YA17M0e6&-Kk=YQOE2LMDBtsJQIke# z@?QDP5g#LZ(1S@bh&gBDacz8F` zRpD-jIg8-ap`Ym@6rNlM3=JFCvr)2b9N_9ODp{J#8`v;h=Es?IOxlxNiKM<#Q9_2M;_jSYUH}t zqe$Y&x^->4;JRt+*3Xu{ylQW~6s%=u)@ z9}!qmL7OlT#T4rTQru(OPi>~6!BlKwMiZNC$FYcG5yvTlmyw#v=M)cWYQ~gfFJVt> zq~`S7oR)6J2?icV&xW6Z&I8CNu=}8Y!-3V5*oU(pJV!{pyvacr8HA5P0nDoEQ%(JY zi_HlS4K2djpeQwr8f|LDf-$pdJEIqbnAcQ(`R2Mwiz8zq+ZHaqq%>Mu7wuYe%n&tL zfGjDLMa5%lx}tTse#w%qZMbXkq~r%<8NgEgk(yfXgz;U~-7DFX3+bnQ@#AqBY=^OF zLbS7X)|dq=R(4l+ji2DHt%>*r30Rp-(iA+JEy;u?keU%+qc(@`QA$BS9Orf!N}fVd zAL_Iua?ljh5MAJ^c}*yLOiMzDF9{(p(30MIi+m$<`Ua+XOL>c2D0t=$9GupiRQ`FA z{BOl%>K)}7|3O^Dzk_}@em{Rc@>6mR)GzU+fJP3!_lP56}Ebt+|2<0=uUVxPy z3)N6@44izF$8~7*yh5H)fjBg#!VE4emB7mt}4}d2r)5g#{ZnU8q)|NhnorPaQnz>S+LontCn2s+La0 zh$jQ|3fkihRKrX7xJMtz8qh?orW`edrfqDgrtxfxOwvIr^UxInxzk2wXb_tKnHl(z^v|lS3R^;C5-qU z@k^Q^e256y0(|hy8uo+8d0&n6hRC-))pyDz3Z=lgVFfaOs{79aG081CD(x1Z!z{a6rfg{`f{nt;>Z~S~76JTgmet|iqonNy9qSRCrj5SG zE*k8okuHXMA1b|YZ0qc>KB6<%`;DPFQ>HnqYN&4EGLuv20mv@Zt>Scu^WHjG$A{{M zn0_!1B4y#@2tE)shK{KGiRKDSUb&Ams?2};;|q5pJXA^P3}#c(A}>+?UHMSdS`A5u zx!-7KdwaT0vc*icx+RrkWvS1Vqu=l9QLeTd`z1pXyttbcEn$YF%gs^<``o$khc~%U z9?(+A$FHjL21BG2Kpc=@FYF5APed6YZ)jh=UwQm-OL4H}p<%olMV739mlk7y|VeJq6h({N-N`F)AkKU*9A zZncuEumPCb0)>TTg$*!DALN=JPBdym6qG@%J)>S~Clne0KH`mlb{f%P!tPP}AjxA# z93;`Q1V$D?)kIu!LsQfhjw9EQ9F=y_B1`piC?(juo)nIC0- zDn9&Z<}dFxHQlKEWj$Lbgq~n;oLYO|eW)MPm|++FFVI|Qe8Ff4uCPwVdtGoTV=nn! z9Mg!5}_H(v@l9y2_n5lmXZ?=E&S(lJU6Imo&ZWZIn@mAKqMS=Au89C=0ru@=+;YS z)498q9ZI9JWB0j$+}686F?+mvy={HRr$^I7WzrL;!!dIDMD^t8ryc8UdcBwRSe?@Q zeCZwRQ~JDm!Eo-)4?J-5xd4^sKe}D^^(*(gg=;zY{*Cfo)5#lh`mXYC@C%ts-TPOr zx4Ya5jAH>O zc|Naas2cQjC5qX ztN*_ zp0iX-C5(oALou489mBshd<ac}LWi(CgsaDL(eO*GXYH2uLp{vr@SV&-2TX_wJ$c zu;DVWH;0OocbL`LWcxFSsKaT)I-4jmq{X-c2t|aJQkL}QXiTVMz=F`J*S(Tc{UO0! zi%CAn@koN|GR(ehQJ(p;)$Op{@wSOMEh&o|_Qx>8!DwP- z`FJ}oaQjgCpV#o@Nx!OH&py^S(Mo<6#&dsVsr*A}PIAih}WFPR&w zCRp$^BQjucQVv0ZvdTb~5Y%*mLkorYIJsDrg^}#t?y#MKoS(VfIorvSE~hJ+Nkv_H z1NyT0bd&Z4`Byk{k++vY9$qbIp;T4E&6tF`tlp*!>j)C5KxYI&p)K>A@*LYD^nxH$ z?vczftYFCQBHl2#E4np$pk;es%l>Foya6Zs>Eu9EYEz!e5Y{R^h4l>CRPYp*(qm5H z=D~}jc&KkX?%Ns_4@L11PWDH)q8*0URaN#UIU9C%a`k~+cScW=kFDx3OHQ<-c(1A| zhLPT?d~EY|Lya>!Q^W8jeqE%Xq@>T#)`R;Q;n0=BC`ofPQDBM+{rFksZ55a(iGAa) zU*eU+_dJAYMzc*kC0`CJJP^FOO9?7Xpo<{uSO7rZNrA__;wfikngXyqdcC>NU}wp6 zrPBc|2Xff6WKjHOlr*OB8%+b_HySNtDX$lf;WU+r55_k%G}>I?y}14c>;mc66GV=~ zB>p6tL*)LIuB-?uX}lCp$PRoG3NBNh#Q-2Qmv!*o*&zk*WvQ}QR7jc9RyUZv;eI1q z1myA@D>js9##>)#Y7`z3u*P$CtoC0yo8w|Q6F271w2yF)%8KD0_2xTV;x+lRX_)S7 zLESy7mmECL$tj(~EAaM1nhN5QP)RT+`Em;B3)pSP8(VtVYgUKyj>BSg0P|KE5JF0S zre930DlR@=+*Q0v=*uq{`_A#ko)-3hEcA%gLXTvULWp5*D*ZywDm-z#xOi1heo6D& zsfhffDTW$dtI)HAE!7yiAVDOsdl1 z^kJ2l>S9UXuCtekeIpWyAb)r;s3gmj-+uKnaX)3%EDkWLFD+A&-j7eww|&#xTfkW^^2cYa9_rm4Q zin3x4(yLf3=0BYT{IwK{%rJaGAcrfB}x_x6~ z?NgR#`|L{eSv%T*Hvmwtyp-4g+;<#Yu-bvpE@#a&$atCK%V}j(r9`g}0;71P)B2$A z^>07GDy&Am=Vx|<@=_YGAKMS!>s6Le->|zU{Oc`LG~#QV)<2JRJPc{DYNOS8_y_LC zl{@TCrW62$lakMd)^-st?P%lI2t z)Hp`>W4-6c4x>S@{PH(^%>AB~t9w+1&30NhSzJq;*3A}|Fx76iJC$XzW&Y(3cE8JR zb!47(SvFgpOI(&s!0&j{;v!y#gh|u^kVZJ9B^rTLKq!cWhf6jz7>B3{VIyUy6St8` zt}7v#!kob_%sj7rhkZ`%r086h2XZFre!9|+So+}e;-=^KDM@y(a^Sx%DRgARg`+6@ zF2u-VGLQ-ZWzz#K(++!YiRJ=~3|GVj`!3)x5$zUkh)3uGfML}Os*EV|5hF(UJ{A{; zN;^ys#azEYS4VvUT}QTW$g@cuN;(_~!om}CfZ=y>M0q>J?!6&0ot>C}-$GouFs%Hh zTmXOk#{D|~3BT@JuRegi$szQ;LUnyKd=u@?UxB<`_Ui-kIc(E;I{yK`ZY?|iTsd&P z-Ds3oUP!mxQvQ9=j3s~$dYyr~$?Q9b+{-|eMivJd_6zn%Diy*g%^dgph0WMnjlyQm zYvbd%&X(IOX1{WrZT72MGXRGk%-(<@szG$F^a0wjK{JzM4tXi@39NXYNK<*-69LR< zHA_JJax@?fIF6fq^$B30HaB2{+{uk~5)kSg_1^k+EuCO#z)8DSy4iVj*ToiH!~Bac z@4lm}>JH~j*Yjl;)*~sL(K7eK*OTEpx-0KkaM|Wbua?%#Xj@*tK(C(|>l{C&ZhWb0 zMo~pu{jBOKI=QucYE5gb!YQVnoLhYCh8f$YkM&BY2iPFc51wjZM;I&Xyq~eb&xB70 zb!DyRW$vzMsVFjQ1?9U8snP5KICcCp+z|F5YaW9djR7^>S60XQbPOU4qinn+8ToxO zNmqH=nTD{Wfv@awt2Of=f=NR|5D_7WgKt``%4VxKRM|4nPih20e86-edqM8Km6$g( zF)F>V8F&FIKjPI0*Fu5JJohBIjc8gc^_8vam+bbN) z^b&a)S?@-wcXYVkV5Z!+PTi!3PaWYx6x{?3=UUM zy8MhLFoOTujq!`V*3tMSxoiS#=D?7Pp0%n(Q89qC3)`8F5QUBrh37*5=v^&^@-+(> z0htu_oq#P)lq8+7G(S15;V0Pkj8^Mm@ObujJiy12bM!;%^Wpm2hU;Hg%d@u!H?ron zhpV7{3eP3fX1D@MX!O<)`U>hiqBVv!FrlFe?i{Tt*v_Hf&)NWd%*!uj=XwWu1V=%m zC=E2Y%d?O9C>(f5K@*3!6y2GKU?CtUfo5X3XhJ~Qjcg?3QbPGiIU@?a)bx-J>E7bj!{QCXu3mQVoR({~yqt$+}u$pqisO>>~0Lk}B@ByTU1@@rY z>u~r$XBHw_V;CUK2l9wfE-|f+u$d`;80<3WWT;92N!SjR2{H~6qAwgjz)%Q~BE5t{ z5sXHIfmk23I8e_Z=spyPNqq^MSm$uq;)aRIt1IR@rrxz|-rh(cR#D{NJiasR3>XYL zQ?c6>sGBu5Y=Z}>%ZU`B67$U8nWmTEokDOZfCCqnPOb^fozyaELUjAIxk6bm033#B zK)9kPDhNB1%fimKXjQzX&F%7()mOHa`eSoz%C&yCm5&2z3k}+W{3v)^aQ~O=ST2;{ zqh1e}hLNfmPB0wKxK4n)$lD{=B-9?QB4!5iAyd1#&(;uI5^TqO<*$<7Dnfn947Tvt zS#<%IyV#^N7y{04=lIS3qKa4`vUlFHyQVtkR$QH&Xo%Y!jyh4ywM6DmD$Evdk4Gmh zpTE=U_G_b+^J4zew#xc4kIUUw6R(Q4Im646I|U(HBwPXSFjgH1mI-sGZI4bs!_5s5 z3VlxJW8l7`)tX5d8S9bLfPC=@;-9uH}`2fVh;~5}+A$u3Um=pMOMiBA#5(f+jB~MSC zn)!Lx?D_0_9r0+`pq+|DG;S}OtTT^^ggZJy6=Tf00YNken;J_z?vjl`&(-CAEmN*Y zCIyenIJNpZr0o0Xx|%6Qw;Ryo*9)=h0Xy!_Sk9T#&@^8c(nn0QS=duDz9H!G1RKVe zc%JC!;BeL*S`*&RKFe1V{`u~DM2I|G-q7&DbY%s5VEO^&mde^;UG{pRiU8kB^nWzuB+3UUR4BQ7)%rO`tFm8O&c}Ju*E2W7p9T9;I7yo!5lX z(M02^IocHA0|sI3XLKxj9>WcSSUt~xtJ8+~5J5C2jfxN-A*?|}r&Io+23KzE5u-v> z$p^6hGe@ZSLfq%|`r@qnoO1>zZdIP&vYv%jtSCiNV75YUt{d0P9x(tvw|d2j+HuYB z@9tg+vR3!~V7#LD=YyVw>~Aj&yNQK8!ugN z9UCp~oxz?gj&*j#ii=|%ov~uJU}aN%okhQriOygttN7OrFRS%-*41?$TfI8-OZKsH zO_fIsv2DtwH7}(~ORJa!MK2%;=)9#Q0e- z_BW5)m|^T*v&rE5TV+7}mC2O(gmsyWM(^LM{K_LvffdF7!z*rZDzod#Dcu7mwar$` z*4sUU=djGz-40u=a6w4CiClcL>lMlWR2F#kgGfL)E^!$C{h|!XpPfWluYi?|c7qNc3!frpzTKbdDdEx|9tNx80$qoyY*K46?85f0sW& z!7aa2ZZbRGWXiX!R!fDr&>YFc1tlDTfX&`!!oS+D8#!ILKE()Z+kfC_7D`;pT=h~J zBhY)eOM-}%pyjLp^|L}=3dbtO3hGJ%;x`FW2IZS?*ETc@zhv(z#m_v*Cd`@z?SI%G zDz$1|ag-7Xu5}ewtF<)b4}(GsDA&ELygY7vMMZRq|I9nAAvVB{pUSXJ24sg9wMM(o zrY%~PNZvB0^154YNvyzv?6VoQqUfS5)sk!s6`k=rvd$y_Iq}U&@DFME5PHT1kJKP} zEE^;b^Tc&c&>7%g!ecN)VEqyZlqJhD3)xb|seD(iW8I2Rd5A4z ze^$P$IK@fI%gP_wWaYhW%I|O^7V&L8tQdZqg7Tj9rt(MS6=qfbuKb7c6ILP~P=2EP zosEO=Vggafln`{`kuTQ?GZ?HQo+QOOT z9l{$Ong7}-Y~1)3dncttGLMU)9@dYzj8x6t-@Ho*98n&*MR;;==JZ~1Z|3qI;fhoD zo;ZPVIc$SdeJ>VhHsNXxx8JS}#q7!uNUUwQid_t{L=-8{Fsd9E_Udc(|1mz31cb(?I^6JaRZ zOzye$B}*=ydBfR%5-yO9@4d2IXr z(+>fwmj~Z*h2;hVYeof&)GC0`+b19}sRuI!+(055HHC{*^C?{$8X}1Po$Hc}qp<{*!Dk8*^uyoeAHZJU8U%?shoMt&Xib zYl<(OwlbyH9~UkQMhyC~<8{XJKyk#ND=F6NBZJPshK^b8abrb?-d)}l>3Pm>xa~G= zd5ie;1B$=2vDk4S7Tj(w853+Y)IY!XJ2L~drKL7goinzKq9^I6`gfQW4iB zl2x2%Fos>-71gXdzIe8N`N3XMNYqZh`AK(2yynh_YGNH8OI>;CFJ22*)VG*q+r7%> z`^<8{Humn%zh7QzyVl^S-u|WnM2=W>gQWLXXqjH?v~2l46QA&xl}Y1RW&YR{?x?Qw zy0NsUFij`?*r{2|!NL28 zsjd^jAOi;(BavJnJkV5@q6Njrx_pnV*!;-$`QZm=?(7`rmYGiaFE&qk+!E>-H~;02 zBJE6QS+!@+L?QH>z_N2MTvjXVl;wk&Q>BefNa&bv=T|ex#<8>^A^`R?a_9izLs%{U zRyz#ZBUff=dwWf5MPreXAx*?dJ(G)?HgsNDz3k3))2?Or<+tCQr@YKpImX9s`YD@k ztXaBwY0)>8)e|o6og%Pt(%Ag!lmACj$e`|sn$To(P86!}giq}j+a3JN9kL(9`Y z{Ef9%UIYG44HLEL>^n)PM^>{TZ54Di;NP@qDndc2gsadLfSJs%0vZVKL>I%adq*nDoUyd%E&iq!a(OQ%d)xUk{) z(OY-yczEWP&E>UgH_q6-y0LLVWXd7s-ICJD&CSscan9_=7?KCFDf{<77Yc>TaU%cy zy(5Q9OUuirR3tkZR`1yN3+b{+bLLELcAB(Dw{0CG+Tm`l`qF8*ueg}y4qyR}!j*y$ z0Mxzk?aWg8)20S@k!zRW%qtMWj59&|43(l zRJX}G;SP2*@$+4~exA6>qSKlWR#hD|Yju{)(cDwjt*ux`iSPOxO`=Czlrud(#EbK_y0L1SShwjawriLP+%D;20XRBpcdlLLkoHhta{ z^Z{xF;tp98FCrCAgdqm6q(YM3jowOiLFwCZj(R6>PGxJRo2b$0UM!pZ&2S<>8&R`n zUrgV^M@nVkc9Q|AcjZ-*&4_qD$p(`w8qDrlhMGW8GnNH=QI#WB9u9gff}qu! zbQZCAL9^FW=p|LAIrKz`K!ZhG)m9I;zuz}q$8H2&*a%a$KunOLo)9!W|Th6I$ zoiwXyoGBg(hea#1+5+~Vw1K&p){Ik|XtHRPZl(uZm)?Z-H6oK4I$TihaQbaUL3@d@ zTvsiRyTI+9eBZ^Df>e81UA(Ofz7Xx*r4?S!lybd@%#`(wOq^QeLacmJF0J$!MEwC9 z1W4TksMIEu*=ouJ(PUsHE^jHTs*r3}vyWK=vfgKd1B`>24GzQqOWS*Z$5EYa!+WM| z@4c_KuXm)KB}*=Hmz!{J;EH=$7dkdzzy@rv=rM+bVv4~K1p*-uz`UjeUW!S8 z03o3UjIAAi_nDP!;gG<4{nzg@J9DO=Iprz$b3a-so`jY9I1>j66mTJ=@l)$fIt8a- zfa8&};F79ws#SG91uJvZ7d3mNzp6COmD?@8dbisIw|K)Gbrxs4M4>B)vAXKw0(-Mu zFK2j#tW2*P9+68698FNSO)Il33nn{_;Vc!KV{kIS-w>VoX*u#mvr4!&8GV8y#^Wl3 zoNyfBTrAIg#z^Iij%YMePQ$|jqGkzq@_DtxX0-zLY~)PsF1^gC@L183@s-?J4nk@) zXxVCm$~IA@FA9egYEEek1ls&&p4I4bq;|DcrEAt26jFy=nx$o>d1Vbz!&7DL0fk*} z_0V+QbIY5}SCuV&u6up1g?L;!`r&}3Di6xhT1ghHCIw(Tse_keCZxa!8>CMEC@gPmB+B{eEN#oA z1IAc_fg+2Kz<3QQEg&oBsg)HQoGB8eXNjW;IHZ6pDjz~C$4PQ#GK{|bx=oh`b&q|v zz1ET?{889VCXFt+_VV?SFlU^%X2a!uS)_n{=YRe%F?-2%{a;~HXGR@9(J^Ypfr8_`djf#7FG;gj{on>7Lh|!^&$cLg14JiQ18@Y;(tRcsrUG z3+;eso*#O7N`aS=bwnIyon$&@w6X#g2swm6!^;6&2#s}x&kI=yAv+`PiDpH|v|Rwd z7_Chj>zYZtg~AX`Lo5c=K`Me|#9587gAgM8 zsU=O3_6aq+x~*BG8%oC%=ahI#O20kOcJY!%vgm{TTjzJST_v1)a*2NQzy{&z26?Mw zYz=Djv%|PD17Ve!3((nH1d+{kg36>_HLwOjNdpL5V*u z=6|HfKUmY*pv6QRmWYl&qh+8mnc_e+Q7Mrs2td3+mLH7y0U=4O)brQ;?-hu4YAon2 zXoRmw@qPYZJ*BY<5Wu$0BdK|9;HDCKwmrUW+v5bdkX$l;yD&#*1abG51&xgbAU1Ux zb!6{$;b3k>%ws31MT>-#o$a9~Y|A_=ctwsQ&Yq%!2ZUWXT|}Yx++VnbQD=kChukQm zE0T><5$KBlSO>8v$U24N;?uB6nt}y+0ebqEicfM>D5AgY)k3dW-V1sV^3vJoNQr&a zBJpEfLz9H)gYk>jT>&+=S#6;qV-(Ai>2UrO#wOI-Lp9YQd+mhm0yu=YN#_hOpOLq$ z?L9sxnRNOI zjpoF3Dd1?Nq=(lT)F)18^w>*EGJDnP%wFMT?A2>doKTD3JjFkScnu?3s3c6sH9D+G z#SsvhI>TaCS~25#c}SF$Da8i`4r2pcKmRPRctm*N(ELB1MmX8lt1(|jrVAGx-$zr- zu6ULhZ_G0o{S&6_I(gly3$lG$*{67$@<;matPy_w=2j3Nu7BpmZ`Qp`-1}}Mwm)r@ zGTGU_k*}<{?&PjgqfZ+{pU&8%Gd}HH`ZdI%3S+VV-*Eir`nb8|5H<~F?$92LJtrl! zJ4>--?h<1JiKIVCi$pIhx$7(s2YNCi$vWLD?SXxuk)pxS>T{t0Bc@1f1{fD%mj=B; z;XosWnIF(9N?{074C0VzbMT{43=jkn=!aQWX%Cn@nvTK|UT%DjHzyls7Ntt(v{h?$ zkDA?f&?g&Ss5(v`==gmmFs|OmcH9TPRnvXPokB}G^#oBq!5}5`!PT!K7QtkCme*%z zAwPG2$`y@jw66f98#n)Tc`w2!NhEV(<}$+DjO3yxop;e=xQ%bQsx2+kN)znAayW6$Ci4qlA^oC@uqVxC@94?~JFB#t zbTC$N#^8$9-OHxg9m?S1`8#T)ET_vMMzxja^>TBWPVXttjkz_9)TmJM3<5VCH5#Md z8h^YiZgy#93B@mf%WUiBbrG+F z4;Z|sM-ba&`ZK+bYeOii|R4-PiVHNXH+FB6*2!InG{fP0yA<503J#ROk-<} z*re(pQVIiHP7%pk8i5N!42ldDFHjEc5*Nj#@f}fyYvLvaXu%m3ow*%!j)9RDtFd{^ zN;wiMdSnK#*86b&UzRKyQ&{-w!X-1HBlZfXcfBwCuU64Z$gcNcD~PmT{W~Eod@OwX z`qnE_2gv01hI~${)k&pSyit&!&+uBMx^ims%5e^pJlBQ?Gf%3w=Wx8!UPH!DER8Bk z%AIm|sIKnbiS8n`&%OTZ{y>XP>+}bPWx4ihTs+9vd|F;LeQr-EaCpYFsV>jMH9gn0 zXl?)4mHFA(eATx3bxo@uUA%&DsRI|cC$G_}(F&OA+WHk5ElBf>RSTFI)7Mwv?s$g! z9u4kp&*n9wdeSRgPGgCy>rnHsxKZk>D3m%u!f{r%SPlz`iRO!^Gz3wo@Q~UKASs|p znM26XjDgaCXie_?gU|l{;N{N*g3kzh(|>vxFm*2e@SoBTkC-2kxccf7e68T> z7tWjYCb2(3hP{!_5k7fy7TMoVKJvaHpnJl8NM(n0kkb%NNVF^!RizS`MlkbYEY>ox zo`BJov6a(xp04vSIK>Ni=>41)8V-i1I?O*>+L5Jnm0y=NY5M$G(?`|l4ai} zb05i_8yY@+(##2C{mY-fWO=68P?#bXkXFdHkh)j>+6ek`gLtm^RV`%%XTz7+D3Oz z8rxE?({WRsGFyGT%E#D7Ztkk}8qs~&YcG}AstY1av4oRYfPwxyTz3>nZWiOKLHqq)>>1s5FqT!cnZjT$io>v){#=BbB;qt1GGS*1GmWAB z&%t19AH`Ow2g1hGk^bj?K|B~zMNog{pv-Ih4;cdn{JA;*EpNa;bUhgw+xPG312QtX zbQ)xGi=-T*fK3#~AfXu(mi224wJiu1$y#_nBhY* z?N1NAx0fjPJxp@yww1qs5r~VnzUy3`LjI(8{dQJmaFo_hZya`>On5()3JPHE%*d3Y z{4VAjBJkF+(2p_2V93OblQHR1l^OFE#d9IPn|^6L{ve`*S1S+xZA@Ndyo$Rrm>bn( zdAC+Ca4mL~b*L&!bTzu>o}2&j&dH(vBX;YbrE=jLQ%~hP2g?8Wq*^x3-eYendnob0 ziHBgAc9G5fXZ*ve+;EJJ~ zrU!<`Y~@l<3P*n1t2Mp}7=}V)`*iTvs6`=Jt#jIt(Fbxm8m|M=kARQ|rmvt0%^yj> zxl-OAVHRI-ODd@`$*MX#s}Qb~Ox*V~NX`Y*J_Dt(3m;`Vur!6dL3z6sh6)Q<^GFj-iI~arAz&Pyw!emlrWp$-_ zp}bNZYnAnfmWI4V*A)qGL~@D{tON0#93{ueQ3{piG=7I=baJ47K*L2e0PUk^v(nN_Hq_^KsVXqabL;TRA*y^fdwtP8U||3%%{Y4=vh##I+~ z>Jq{W3Hi91!VX>HMvtX-Od@aJf_+YFO;;lC=6GfYfL`VD@$}&MZ5C_I_?o<%7u;d* z?jGlQl| zhSFC)I0?YGN!x?8q>fL7>&Q?L2@6Vzz_an0jg2!4pDI-6C@W%YGFFku?(d6L)P@Tm zj>Nq(RG+Q@?h7HSFnTd&t>j9uqcNq`_YX%#E1Fe(MvxfwdXto>Yv)%Qey0j zk+MS&10M;|?h;B^q@2af*$l)Kh9@n~*|<94%MXPs-}ob$_SRd%rzHLvdtW&H&9$p< zC6+(Y6s0Ni9qCCj|PMBy5(bAJooxH476d1n0HDI&v_AL9~=?{dP|bgwBak5^Q=lfjY7T})HDR;6N|8AhHZu`6`CCI7&a z)qZ;IOB1!)=&Y)X4JU9L+Ftk%#5q(#{Ir)LzB<#hLZw+Y8Jtv@0N+XrnmT|LI?BDrrNiJgMIV>QbpV^ul?g6 zS8sh^IPw10qTy4!!kD(tj1x5OH6R%&dL!^bvZ(b0`Z~3*m53liw3!k(9jMw@VogwD zn@H3IxCMnJpo$<*fgcZRqPqtR4puvWt?OVfJUdEYbg*)*dVQVn&pJKgw53IB*Az>Q z!m+aUc)XqbHr`%_wNov#Lt7uNf1VbG%bo9c9%e)~n_b2)z zS*F+3)#>z7X>qaiHCzmBsXI)sS=LqD66%%`SAMuG-X1S0<}JeWvhHw8aj;6~^6Y%! zg`HUrUF8#JMwUzm#~4G$Q(8|MTd)rG6coo((N;y9Ev+Y7O<~bMO{+(&Ct6{&qEI=J zXabW2{5n5fRj6f34-Jpl(5VMf5_?diiGLo~Xm~xJ^KuTa7leYkg8XDY>B{`R2?&O7 z*-hmKNxqNzU5YGE8n~L9mU#1WYqFgDmj~|oQtI%L(xD3xn0z=?h&`(>c`^FbpfQ6l zKqMbK14|KK5aJ(X0}tWj13;BpA_Lbv8qkkmk~6zk_O5hCTzgh@jalI`n_T3w-Snrs zX60=w$e43%>C9nQ-KeEYMhPF8T`u#QbzRGsjV72(-KO&Q*KIPp+@|$T_xjNYUb^pG z13Mj~ZTR31CYuv-sfG-`;y^)vdyJ51#tr zexk0e628upRT7j{d<|gw%BhSYB(<#F5K+H9`;|;8(G;YFn9Dfnt zV8AqTc76Dt(w~#z>&cBTz4THSV@dy=3>O}w1vfEf>}eIiD!HEfxIddYjD5?5t8h#! zbC`Jl1UAb4uG_or$P}Jg9n!z3T`P$1kwmYf6)whn3|Z6D{v^d;Ln4l5#faO%%*MIh zhqHFXb6xJ7xbUxm6=u`@8_gzLV&aBlrHvc!eqdvJ)8oeywHsO6&>Cc#Q{9LyHjpu? zDfBm8Ow>=YBdcae)7!IOHZcpZ8R~xwtK`Iw>sKksKCO_wgt=p@dd{M$C~Rst#Wl%mQ`*2euFzN+Y!(PRk?B*lRc{ckhUVvz~+7*JzTDEd29}5?fTlJ z@I%r0ZRA!qSXo*DLV{5ZZeduDRGF_f9rG!(*|h`+B*M&K3tLv7H@sqDqSl+J*N6Ar zcjWr>82G~Yu*{?OI>J`Jvp%~6Z9=K{wOcinwHC%1pSI~nGv{1t)$45RLakM!1VV^t zvJ7FXL1$%Sdgr6P#i0Oew(E_iyf$Z+o<)#{FX?u~VvI`n25*t;q!8d4Fr4Rl{muf{ zScM|rO-KisF~bsy+VTyRrVgDVKH<*ia#@8^VJerY`o}qQedPree7=eesUIj3j>1Ku zQ^6LR%V=cGN;A+e=?!Dm(qiE1>6J4&t`XzQKY;@+mrO%eB?*8S8EXjIi3lG@8-ag> zT1PUyOoY^do`PyPu*(Cd0QMT30+cUpM-e#YgN0dcPkh5s;qSsx;p5j+(dw=dU4TaTxMo8oD!HI zMyJ&oq@0=*TJ!VWW5ph9nGFq{NkVGd>IfSs$X@gE9m3y!yLiPPh`V?4 z-5ZvTNP3j=usLRTPad;3;u-1E*oO^Ywdo*6GqAV}$Pix4lHHOu7!P!Ca7F1Spvpla z0tMS91Kq8)q@HDMkg0(C^szET?+_Rva0t4-t(@ix!WmI&PEX)iFtD)+AN8mJybq8! zWo3#2)(BQMHd@cr5t}%0a0R`4ybbq_*Dq}wzh?3!A478$3;qO;D{EIera!rS}GJvcS^Py>|TYrTPiKZcyK#3eS&(>4A)q-m!fF zy(9j5n+{LZ;lb982@3=WJ6tv}rlQ`prcllYx1v z{)$s4m`Bp>+*@-Wp8e;!`NxC;rdBw4OL=VTt}6eyQD4=|m2%GQ=i2UTopJSeoiD5; z*Y}^)rVC^mklrKS2kLJD14XwQR2VO?hz~P+_&76f+O z1UD9EkQx{%tJepaAP{f>-C3BDO1@-_TUy4DVsc!kvFX&TP3J^69sAWIy7Fe=B)K z@;)T7(+G|90VGg=rX8Fy`$I0GF`k2|g{5HO{XcE9Khr*buKk?5pSCAFoY?+EyW{`I z>;GTd=ef^w?lzyK2BA|Dx+HxW`k%AxKmTbh^-B*tdmMuXJ0va8f4cJ76T~&zjFYqh z{vQ@nIPiWD?OakUh2v*V6~6wt)d$ZUFogH$XID>ATA~b}40HBDfA+Ng|HH9EE(TeI z0iH?E_3=IMBO?Agve@K>o2wGOR z(3=6+y(7HS|GWsTO9?3vT310r^Z@sVAJP*(%3$j<_LLOtT{`HWrHE%7gPw?~mg+r_ z9jRUd_&&s(0kH>Z)Jix2Tg7}aFfs)LG-*tD$kEtG!c;RF5T_uYsUwqWJ2uo{*}1+( zxMy5v$F>%6K`viKjE@EC8*`h#sBcWSKf3hpqhxsPq)5&BPP*JcW_ONj+15c9T&!l% z$QAqA=yGrR*yvSD_O*{*z2xS?XM|5z6x4cD-II4sIQHvR$3`xyY2Uj7%eH+h=C2;z zzHiB@(d{=cfo(5|n65sINi;ST@)?Ywbk<3jGOvm^W%`!S$Y(-G))Zp$XDlDT`<~t7 z*)OkoHr)Rr?N)3&{OmQUZ*IQ%8+DNhOg!rz&$iI-kjfA8{@#bcMJTGBUj z_iYgVXF>Nf=|__Z(9+4@JW5QLzIU0yyJT(2-G`oP>%96+chjaR4|iqVwRXh%aaGQN zZ-_4__CGJ|KY4hQRx!`dIsPwd0}_psc=!Sa*}EXAng@P(j2M2DLs!h8(kW9DTVg{b zCyPoM>Ipk0>>!&i?7eDHw0&IX{kN|^@9>iw7-jQtvX@-HC3VLw7r#_@xvH&rnM&YV z79vRhcR%)m3D@-hW5u#ta>|xgj><6zPe0Z@U3lQFW%IK-hAGY4AGmkxC3pNb5F;0? zt7s(3PQ0I}Yl)nWGWcJjkOR)3B`9(;K;?O=1Hi~aHCV*|4!%Qq!Ym2W2(tjx1p^O_ z%O(=pN~8r>y>Qi4FQj+un(uPW?`-h-Zs@RdnX^{4&S#H4v}yB04{hG`&~D*hM}!gT zr?;R)*DA-ba+@6&|HK#D*WtGz@tjzwsk8`KFrG#+`- z5LQc-7OHrJ={KbBC}Zi{(|$)$)6f=07#CmzZ!hm%wyamsuk5Or?kFp$S>v#m)^=IV zU2K2GGjgf|bYX8Tqj_c!X9oMHg(OF^ZJinzx&v$*9lLN@M`iJsNIF$**kVT zzjKEKY~!aVNWTE)Sp%zVKJ?@fltBt^XFv?`wV*&*UC@|W(7P7Utcr;!uwM}7prNrQ zS_7aG2}e!PdA&T%4k|+cTm&TvHk_cqHNG5Dy_Id&F~U^zeU(h72rwh_4qaP+UXhRG zo~eppC$ejr2eTG{K)#HpqEE z@fK$SNBuA-QrH+ZL!f0;6VxAV9ySVLAjgqrY5Ml9?1{;YU6Gb3>+eS9g^QHrKFh_1O$xC6bxt*_Sv@CAs7DRfH_Dn#k5n z1@u25ZbBZ&f{t=rd_M^!E6RV3_YxHlOox8-$OQcqXO@^B0ind_8d&nj0plnk%8*0o zbA*&cC~-ziWY#k}QCj$vDdK#V?85RRvI_`p!;Xj}7<5E-7=Yp?*PdCVz&Vc- zBEtFNV#ruyk>moGM6oafY*=FK5rueA$6$E^r8Ev_ury07HK8;l+7k!M0VKfTb!14a z1UJw7JK>_6a$HtEYx|PF90WGN-4pzW@W&f>7X=+M@479-_Nra$2riCo5+1z&PrWu@ zwom1`=-2y6{ydAxll#&+ejw74Wm*wX0Ymg2Yg0Ya3B0 z3wwPz@^EvlI(y1F&LBceBMs4aEuh% z;i*4`b&}7$ntt3ToaYt3@RCBN)l2q!iNTA$XTbj}6%uZxM2i`gX0)#XW`7)Fd z(F7vK2uy{5NYnCC0Q}GH$gCqE92{t+NJ(NsY%e{|ge`00+^x(m(Z+~SCYJ7|b0Byx z=twZQh1fi+NmeZGV@z>OIkYt(hcp_nDAmydiH+U?#veV=C>5X)A{vF2fa)r&NkQ3(-heM@gEEYzonr^c(YK_IBQTJe5D^-}y z3aOTC5#G00lrlYIG%|Xba=OW+l4A|qa@9dd-XTCLuy zCu%j(TXnB%jZPzxO4Wc6z-|u6`rNxN?Ek06=pNtm4DlM`l^5Q1$5)I>snsge|N2U) zDLclr>*WY%)l1V)lD`wBOr?-%$l}x{g|1v9?Fz%iV9^;;I{r3#nAUQ)exEvgl${dFuG0rse z4kn2ce!=PJJ1fz5F2R_DQ4^DxIBX7xGd7vQPxC1g3bv*$TsYXo=848Dv!H!b{R0k+ zOmGOb^8(^VZLl=vpqfEDhItpSjRhnNEuuhe804@&635@D88L=96vkhecM-U11vsLN zKjMa^>m&eO0C%NedfQIcDAmFr)MOToHA_pt<5gN+b*&dc+(gK7AjFs;wbyawo z)%KMgMOu#AE}Gcr-6?5w%-t+p>QR$Q^+_W_;bNrsq=Xsc^va5@P_94{AM@L*g_ANh z;grtUynKa@Va6}LbW_*fl9~K+`NeyXdnQt`imwg+Pg;F)6_T!}(@*rxML`pvv&Wj+TU*o7~HYmz= zLDV=~8vogvUeI#K{*;Ub@iXDs)c!kKgx9)f@eBig0U~9tUVb&hBlenM_*vb*pxW5f zqVyv2k=d!2+t~o3J(=qfrr2(FT4)|&K1;#))9)*MAj5N-$s<4$p6zd$dKml5>Vbv= z1mPK|rrux#`v&PYo2d+_D5wp%5eh+E2);uT`?Hk*Dmcf8dAyRxOLIt4!7l0`!REea znuJf==W%L;pAb%}TG%1H*Zkzuzn~gETe$F6nMuw`IXGZ%UAT}Kh;z}R{W25B;yUX6 zsFN>+k7zp(u|(o{lX?FNDuMozUMkiA6ifKGp`^g|NSPghL!c82rS<&zcg`ZM(=O}C zX&TjDU(_XBJ(cjQ*Od7x>U_WK1@G3`Qe9)#xJ--EuM;~Eg8r__KHX2fQx4+Xf6+T( z2#UiS#8LGM;dVd!3S6pR(npOSqkES^oc;yRO^`yWkDijk@k@IlwwxL72kkOJFoh+M zhr0{U4A2dLH=coC%g=w8ASGD`Op#&@Fq&c*G=Zic(>gOCMl-1taDwzdTk~JXz!Z`P zF*_E?uX*npxn)*rlr?Zf%=N}0{lJ+&1ctHSLr$Jq1FAM0?{lTKg_1t$Uv zBW3hkVWJzD?=tPL64_~||H7|DLBCXPLZ(Zq2vHpf-fn=p^iVp{3vE`t$hs0m5v7o& zB{%^(_s@P=0wIUyj=T%$S&)q7E2qvD{9vt#Y?xrD`Pr#Z%t9=POLj4>7Og_~o+yw^^Ow9b@)&2% zCAb1oXQun;`x9k1QKIet+xJhvb};1^zF8fO9mQB{qrP*5BO-jo4@vvOI%1#Lya7{&d48vLyz?3}H+{eE)=e&kL-c~re%iXYG_KKc~F5+@dTDxx4 zfmJ(iJ9_BBr>bO*rs@Wxuc{=T{GZ$Em}j4}T`GKit24jI5MO@P2jI=T;FY(9J;E2y z^&I%ea1uM*_pf7p`!^F#9nG3IW@7iODUZK7;L{g!&L@zi zI6P=@hVEwI!;n$XpEH^GVA04J!mWR1rU(xT5C86WY$?{h5gzO$dQ4tlUO`5t@8n+k zo$xTxr0--)1N|>q@+|!?1p;g-R!{&-&IM%N`=Kpc`rjeD4!wWzBab{X?R_#2^pjs~ zAx!8H*(KbVn|?3bmVQs8VFI>n2KkAY03`YMC^;O(gVPt`*Fc7ym}!$#6~k1Q%Rttl z*blLyZ6fX-ehw+k&R9aFO?sHP&&!K2(FnC(X1)n_WwL6?mt6Mw-JFg+)rwHwdp^Hl zs``!#XLODr(TDCL_S?zHKmBUMW%Km)>ZZ;_XJLt7cAX>?j-E zUYR?pp|P!NN&UKenErx4th?h=qWs&P7d&1b&0TR@)lElk6+XXRY8Sp-w{w=cP212^ z9&gTR?&@mJxoY*=o#!o1HkMWn%M|ROuPTnk1O9i)y-A~L5-2|>Xdsk@S1GY20KzCs zM5V|hi)A1xGiH^Gxn+5fz#z@MnR(&gq5n*uu>IiEUH5c7ed?>H-R`HmnMSf9Q}6=G zq>5!{Ki%E^G*Ih5ffUwahnt>CuW(Ss6~VgVm|vPs&W=udbu%CQjA{6 ziC_{jfE}X|4TFc?Ps2B;>6ZrM>A+I~7!h5e3>AoY7lYjkIA}ek)?%;RW*oqlo8*6f z7Qy1NWQCt^8(uQM6OinvTjv6uV0M0vRx>|3(rhAt=-%4vkFuO~l-oToughfe1t8UHkOQTpF4kRD`LB6e|+5u(v^{W#I~k}o*RR`YMNxRWGzrXH)680 zL_$$O(C`mR9q5H*5q-i2YcZ@=G>TCM3kHxtwsIED45bvhV?z@}Y=#UVAKEPGUMx#+ z0bB+H<-lRl@(`GGv0KDm;)Db}MLdf(1%R5*1j9h#rol01f@LTSo?UoUxMg9LC$HhU zcMJ{bzl^oIDre5D^qRVYyu50maLdt(2E#koHRP@PRIB~O*L1kDyQpkxSy6Z8;U?cF zTJ5L)#>3T+$iKURM5jC!ODfChttojbXmuSf?XzWrL{5`p*N{$coiWI znoB+ueveq0-+y??B_EO+#IDqQ_|Q*ukhzW0SMCiImsI{LZ-SaJxNFM%hsaHb{1p}M z*-OtCJ_+3W3W)916Y_plS;9;ioiib4^wiGVnv7p5m0uZ~ZtI*X7ESB8t=agcQu(E^ z`L+%w(#WVLre)fq znR7$!ot>e`T_Yrdo%hfB1z%-qT$6QEyc|2p%~>48|#zg`tjqsOT!yIp5+rt=IdBPbKK5`=jJyB z^+%eLTHa^Rlj|-RWkDrEHt255c-whUEDS7^_m$^s+>R19y? z`@uwlI)&{73vrf%Mpr_D<*3|fDWyLOL+SvlRUAD1mB`<6=uLiGtMn> z{$s}8dCR?fs%xq@Y*x2od`NH+X)?Lu>NK^gr8Bbl=(>0Sk@*c;% z$1&4d=hbzWc;ukYlUgD@(!WX%>MFJ4C)TFF99da4dQ^3lb@u!@?9|$>Yc3%#y`Wa+ zW^aDTCXYmY$S&y3A6qFLbyO~Dzq5wR9)G@@vmY39#o@yKr}8H==S>gzr=<5ze&F}f zSWVBQYBB?C9#3_Y2eUUk#R=DL?XyKz=DJY_3EOv;R3MzL6eK4un;VCI7+OfxSnX`R^TYKhc{kv_@ax7yJ|`TKC_x6 zj4anVF&a`>3>K9h)-b-h%{(?C2Q)nS&-jWlNu6AqlxN@96>MHLuEFe6Rhu~^t1Mch z;W@dnEgNPhkU_p}@|&yl);jeSB)6t9VJWW~*)nT%6+gB~Tc##FPnQ32aqe=RIm_aM zk>;jh=5Rp{XP2I5w3>Jru}D7n2c6~NSk%K?ruP)(t~$t> zPm4U^e#ppeB8M#PqjcC4N2|fra^|Ot2@d8!yhP&y3fQPD5u&Ujlv$3VS8P-w4S{=J zEMb~UvU3|7bF*1TY0Qb>% zWIM|$IRmr#?H7?vp15z{{%N}Y!q+E0e13Sx*Tnnvjve2i{ZPBWY4i z_f3B#ykYcc6(*|?3$tuc3O<7u-#s~(jAmyDfwOmiQ#fo9@BaJWX|tndw$E}>%jfn# zdl|F2|E~kjkeL_D#4&-&ANX<^UAB};h69}+?Ew^0s1(s^4nq%wN%7-Sc41nWF^Gts zVNl^pK$!U9zI%li&IgMBGNn#0YkO_={3kCTGv@Lq=g&OUav4oWEdUi5i+Z;%BBpEi zA@VSNauB?CT!iAWZsB>#&2`Oor9*zXf>F+xkJFFhDy@x|BLOzW64K1vTjnfT_wo&y zENw~f7xci0@}qatLFSW4vb2m|l*2(D@}p?7twMiBvKB?~xd+KL=Qs{|3B>N92MLe< zn{TiVJ1}O0U1!^&eVy0B{Pg*)$B zvno3r67>k$Uns6^Fz*OO5H|rCC80KIiY^@LaUv))!AeSh*>m@uvrV%W(KMB$N9bkx zD5!6M*R8j|_xN$CB%O8qY#|HO>EHoO^7!%oUTP*CEFluGIbfTSq+m2orMMsM5rADi zOBpwCm^cPz#)2^Fx5P@bhoBBA&mKl{%%fpCuV$efV?r(EUkyv*5(%b$Hp>mUmWfXNs11uDEuozE5 zR|)R=%UMtGbm+g-bC-kp+AUH8=NYe{FOd@o&!* zdZ-eIIguCrrV_I<@2wrT2i16TGjJlO|I$$s0Hk zS9X1&pi6~V@`QNp-ho>gjl%}-k0;9DRK>dGfXm01hn0@?Gv}Cq2!Qr71d>OhHa?t? z$^c7171WpRQ!j3h z32zLGMu(A{7+M0T{;BGNu_?m`Rgc+}W(}bhhTD+4?g$+nGG90|Q3CmJ&Ndy<=;-yI z_J`>%KMo51+>t-O-ybjIIg#U`j)R@S%OQZ_M>nV2nOU8}_4{Zu!D7fNll;lz^waJL z!$e%n>7U&FAI>7Fv>F6B~0i|3=)Q5JAE;XFJO2j3kToIaVB2zXbyQnZE z(dgOLT@lxoEv`uV|8NSqT%(-NkU2_?p{!#>XH_^{)j0wVg^6eHIu4h_h3V%OeI#Pr zr7Ug~y#w@wsI8ru005!^HVDDenc9payEPyOfNEis&uDY}nKb~coxp5i;Qm2oXFh?d zhEbYsVkG~SUDp2=r8+_aE|C2Wu5o>7>`(X6nE;661-5jO>Fb9lO)N+P6fUum#PQ>_ z&cvlS#-p8zIw0g+*uOEpa8ZH@Dq@615NL3*5Wmv@4Tps#yL)dJst*ghA0`Vo6yDyu z8<^*X?O|c*XXKj5LasWp0LW(?Q@BAqX-BeEcff)W*J&hkBZdB{HiUf^%J4OnQziArTgI@?1AXGOO^WKk$=5m16h z$|*KrKs&Y=66IEQ!R7}y;~)8MQ}^V}n49`Rv!v6aIQ=Sum@x zbQx)ZrIQH1US3j|6^C5*)H#l)X!!;?=F{vJM!j8VCeV@68m(2)vKr%Z~PMQw{(FsuMxco}qr z6XO~q*v4c;U0kpq(+|PoDc%-gxSk_bi#8@K;ac=yl3AHC zbIpcH%!HsTcbZNaG^T&|eAKM$(8)p1YAuYBIR_i1CWGx=il3r+YN#J4C4RfJ8R3GE zTPyG#@%2P0j}8n}+8g?x%CHF5rMwOZ3>Zr3;Ew}dNIm&9DO@_mOW-db@*hGToZM3Q zzg0ZqK~hUc{{ZAHK|>N!ry&5c67f8&4fx~5-~J@q*Po=L1(!V4=l4apw@-;!RW6yr zsW}pj>v z0P9qg`B6D%j_ummwQ)Yvv3cv}5v*~Ka^&Y9e?C&VM{-)FzVwqD#vj}~yNWUFRst|Z zQe@3`*5l$4TiD%~%0*$``2fDD3jo`oj339Rs}& zqnj86MGcdHK2dc}96-?60JOsp1xRZYN+7H>us~3+yNF1KQ2K?@I#CGZIU+olVECxx zl*P^}g2s@7k8HbW-fx!9joVcOF~y^9EExUXvMai~XB(NZL?yfhEdD2azK59**j%(| z8M|)W8ll#$I&9A(4;Rg& zWJgx1I#GI+zzPovY&Z;g1cdlyTv$vCWGV%9p(#j{a^MSKz^9@jG#Qz-6rmLq_(DY+ z*oVSU;n>mytVpHjwqn_%mut(AAd6L>+*+kd3g0rwj;XuN;9NEQlHU+MeAoQDm>Y(T zUcV1S%|(%#=!6!lt$oSXo0%(%^NI_=u}k_=4c6~|9ej<~-2{8`39&iJu|#r`oeGfD zC)NOmpcyq)XrJ7&+9NQ`mh>iOtKPM0`rP5Rkj0zjS6v+-Yi2KOb_6U|KXJ(SmZuN( zSlijBPl*@f#kOfbQ#UkPA{WsHNoe|$FcQoIK6{;HpX4#gA0!`1en8$k2kI25u*f82 zExZEX8WogD&H?2x!Wh9*kBoapaD*8d)D>*%G+HVc0BSD?XGS#>56Yrgi`z;QtOdN1 z)x=U7Ehz<<2=-^hVU)&8L!#+Ntnd(Gs5q)1id*FaYXMsziXoN`vKW4gOX5^-w-(zh zR*TF{VDJt~k*pVxGflx7H{UzVDI>k00ROHuummRZcA9Ua;~ zeg1M=R4RJC;z3-7z5-k^i2)08g6@mbJC&Zj3$9|N*TqgeBz+a}y64{XM<)#I9DE>I zAc#gM`sHX|Zd{A9yTdXD6I+zl6L7tQvUWzm=4PaBocH9VW5!&1Wd4n*ZPRDmzG>=| z&6}r8owjwx^lhmd=O3Z_o}70hGe>5Su^x_>N_iw&;^ho75rGs%`~z?(OHNs>CZpAA zG?6=N_!e@B74nVAc+wWK*+Q34%p?qIqRkzkN_rNGP9A{|J4>ha*>zs8-|O*v@A7yI zPMT=Mt$VOgYjfDlY7oYF3pIA1!>n=mJ^rn7jmA_|wzX%kH&n%=z z%%6uN`rl$%q#@FnbsCLOiOf|<{fb)9@Ocrt!)UTk%<^Sc93cnY_Fyl43f!LFoq}$$ zjxBCH_Sx-b{Uswpp%L_dbCcd2tBaZK0V%^Nbt=2oZuZkvgVtt1)Q8Mk>&nh{)t2mx z`Ld!WtIn^^isJl^Am`?AqTa3{_K00=*IzMssda<9uV`M^YR<07Hlscmu}0`ah|feh zzVY?218?%t(4j!&i^zC6Oo$TH+0zg%(?`aEVO^jzBK!e()Wr$i7y zsX{nL7IJJ2jE`r!6y`EfL>lZ>qAwYpj`of??RBC<2AoK0hKE2nC@+M?O!TG%29Nl_ ze^M$UujuXK|K>F$l_3wJ&T8Eu>6b~9x&DW-vq#OC(Vk!9ZD=6L?1abSvUu!)?8>~F zP(fI3a$AdRIeD$6Nn#CW7uVMpA6va*#p=h%C8HN~)K#3q|Y|^eR zR~AK>-_x5el#>a^j|=xGD!MD$D}{%y)Q>DI6CS#V37t|`j2v0PeTyX($KekcnBy4a zXx2gxbpvG;fi^k{zOR=hf58aOgZMK99L!80X-dI$MF(SyYhhd5Rz`>4l5pmSWPbQk z#4ZQpvS8E_j0R<(@--Ps0aG$-Iav2mhR`6tErHW4fGLXuWDxnO2S+DNj5cwshxnhs z0PK%@nexFxL(qb|M>8WdoqNSC*%=*I+<|e@Z$ay#|7Btf5-y0AMkfl9!IQ31!a-2} z0FZ#O7{^k?wCJJ}%iwij#X_Vn6!#52CiD=JX}~xQqCVOqrX%XZx0ZVeFim3P#y+Ik zIJ*yF zd2w=HzqN6C<@D{2OB^jLdoEZwzLU8@WpLZ0_H4zb(PNPXgd5%U%K5^(Z@qQHb=UE) zW!lyfN5b*8X_=YvAg!IvmdqZna8x+{8hGT8_ zR)wlYT{m^zcIU;85nC>*m*wbuptyB~JX6m*f7Wt#!s7JBqec}c%12)CR*ipH%u`Fg z_S8fc7Ybj!hCekmL!_C)(|& zY%zr*;3?1dTV@fR7nUb%`@L~RP-j)jW&$wgNw36RD{xolfbbR3rB_ahCl0_=c zav)S9Zttv)n}qpNrRf4WY*^?0h450PKeo87y2Wl*EA(K&Qz-ZC)+=~s`F3upT%#mQ zD+W%{to-*=h#u*r?j>54(1Y}eCSnR&aXTA%|3_0XwXqD0=St`-CBPd^#5lefabH(R z_Gac`OsG`)<%4uFFz*gXoRA!W1u)5q~4m((-dPA8D<{IR3#ij*}=vm()!ss_8(ruR9F%d*4&kGb~_jH*ie$LHKKHPc(_WG2bX zg!DF<1V}Oo5K1V45Qx;!JA__D7&;0lMG!$SE24;s;@U-w?%I`AS6p>1aaUd4RoB;D zT}U#Q@8`LbgrK29ZNvq?a;IcW*mv@~9S511Xthz~oXu+4 zFp$p6jrK_U*x$o~PTU5sSQT_gXMIY>}9Qzx0p<#K&)cJ){SPDfezTqimnj+mM zoIrj5vx-x_$>tH3^EgE9TtV_2qTGct357-r#1Pucf4|Q>5Y{|Ec>yy-9(-saeD)}0 z8Bs~-6G@Mg%&;Iprx4jMu;>ZX)N?!1%3AVNTIn}h6~74f%t=)pEme~m=`I$iHV#i` zq4eR#Y8Eh9nzSf8E zj^v9#kVD9>L69yyLSoSxFyj&NKv#yS+-1|_e$EF)ST}g->eAPxubJu9l)71?N=z$E zn+EMX{n(BDcWRU?mD-M;?kDg9|A~(ZJGY=dgGd_TKV* zUPiS_qv11u$&00@AEE)04PyFH2U23766Kg{;f_L%E%x4as~g|yh#;nrk2f{(%4+j6%Dy|XN}UTnw*;`7TrGS zSEo1sY0KE{J}9a*;tFI4;8uxo?!?{=Re3;q|Dekg{?pTlY3T(#LG8@;Epi?|IX@p% zFekW+^VgKkziUdLo=e?B&MKi5{E%@x+ejxll`_ zMX5L={cGaKvvJ{DTKQVQ9VuQ7$k)opW`8oNEhJyt5-pEX0!=l^7|k+;RCMXup#~(+ ze}@8odR%~fk&*mPIih+_w)F6pDXZ5#GJ#vyr{hWgwmK$A-~Zv-vrBuc`j?a&dl}*? z;Y6=gOsuYGi0rs_{1fZLqq%;??LQ2i?-+Pq`sc(uURxm+_*1-96Z@o5ASBU-XuD*0 zqv^>A)#y4jq`|Erc$GR5B3Y^1$XP1oGqi2BlMiMTI~I}lG&5gyha?&Beq;pe{EJF7 z^3;KzciE=+(;b!Kq9VK2m*~n&jZJqrlG18(vTM^^cBel!HPe;os~s0TnIi9GcV3g7 zQ=69LaHP{UKfOghiw6ScgYqIo|6oLER}3l%)L0W!60N>*+|TZW$*7Z<5S!pIn5=Q} ziAiyBQ0O>tAW=RlZ?RBI^lV~$^z4r=jE_rjw7}fcB89qsO}uGXT}>bTzwzKT&}8-|qV_y-mZug_yK4wtYYKG8WOznTvzQ06iXEq-ZAZAM>rvNOBSoNAMK z;hpe4&d?=fi_`LG7!Tv|MsD$s5!}%%dUe-;eI-tCjt$oDv($L1l=b*`f z!p#u-YLC+XVAoV3&lE1;ME`^*77zY4H7#8uaQSJ)P&-&B`n8?`g|%xr)0F8+=>-X_ zuFsTeXQ_X{h;ZGEN9Xdw#8V5NoM_Ya%~*2H(t~%-Zd#V3PIdH33ziJcn0Ih?PcJX_ z>HSq&y*H85>$tRBqcLq@u{O!Jv{q$mY)DcY6MMyry{mWU?w`4GP=3?n)7kt-7cWeR zT~Isd)bcqe=B>0(?mfP=zdvCI_gPPmFuC8$HeSMxO@>uKaYg3cG*aw)DD@3&xaG_O zSO>5;Ih+Z-1ki3w2zUCiMpwM-6)UY;kZ&H+3MA0?N@wCOolH=NOn$fU&=qfF zQm1=tmnZC=D+(jie{%7_G(gdpv9NX%Di?+a7(3R9J?r<+1$76lu_$2+EXp3CZ1tx)>pbH-6&lgQC%tBZt*^OlOamX;Y zWXAQaWCe$f`PcOy$y*AKjp@eEc!Gti-R;R|qzh;E{Jp;7W)|K&YyWSV`b@0U;Vd%f zpwXVZaq}4_KNnA$a(~5CDKq}g4-mMz1ew1cgH;}GnMJ-tsR?eY@*FASACOl^GAv3p z)OTPGhS|T%o@^zU9|GcnCIeqgcEQIkh>iz7kCYgr%N2~)sfa>?<&(n2oK{DteOQQE zgp&q|sm_kM&Qx)b=yM4^m+vo$wn*5Pm}uj|Hg+EwgChzo!f~@Sr;&MX3`;nznd4-- z9`;`@hJ~F;Nlq#3%E{ptrY9z*Cq~9cj)wy^HGyz+$&GJX#9kP_qHo_7!=>Ic<#}N{ z=9CMV7jg(&fMRse73eEM8ut^!Puqk7C5I7!c+09$2U5b6Bl{G-KMu&==nDGixVjJ7 zqAcWfu5e1f56GVLkBvRH8B7Eo4-3X zn=LI!+hpGKf%Ln(e~{))dz#K}#y-nG@jcr=?Mzw$_vh-u!s@~?V@4OGrWM?D;sNRH z(_P!M9{3-&Iklj^{%+}aA8umW_X^VFJ(mCBCh3Rw3Mj5Z2dAy?F&EOeO+f!&E@O)G zP76RCQ{-6b98?WXVFgZDR8y3^oSd4BS2V9+H)_&C+AxYnLDP_;!X*R?a08@WnT5vO zW5;3O%OLcOW+gOA5GDk9;-QDCE(Z#eY8Gk>hqD}E!MK_yCvlF(mEXtlPb^t}+*c~? zbn)Jln2c2E_1n#EW8c*^c~;wqS({S~PPg7yT9srgJQ~;M;*mceJ_tFWM0$CtHzp>t z|Ja66NhVdS$tWcDFLQ^k@$$m;8nuTTSv=|L(?xDNE{gY}D{g z&mnd^r&qu75#E8LZZ8|*GfXu7O||NbI8LSFw@j6;fiY?F z2dN$3r`@$P-Vi(7T{|^YEFI}pvFFZ{_b@IqZ>S|dpc7pwMTu4*wpguciSdruob3aW zm%3sA*mRCl83KcE8=2w>#mqLxqCYtpEHH$f} zmJ15bbo7xgUV83trX)|T#|MT!`n#9P)G-#WqCzn0)qP)l^NknF)CPm- zaaRI~K-2dH{?#`0aQX+n0EDa&d_fZM%4Cm6$h#2WAuM{pnsx5bNQZxz*@h;g;ocb< zf?PFVkvezyRynt1bCdL~ya9pzjcuQ9Vc{*GZjbWB8&(yNE(EHunOyNqplaRr#`ZTFw{LG0@*1~uk1nC7&_ZepR2CIg z2HG5s&*|9b-Rl*H0+p2kX{O!&a7HC}dl7mPn1}vkIOnbpgHPq) z_et;X`;rBvGtwaG4E!@^At~n zEV=|`@*uL>(@EDb5rVqO%i--v*E5Nz$i2JTf^$q9v)s8}k)8Jas(RwQBa zL)qqWdhtwn3HVj1K^~gJpw+{Q#X?9pP6zLS;|aVUR1PSwaFf#RShtxrSr8iY{ z+BKZlZx&UBfS=0c&}(>~U&94>YpRv0Dvbj7G8fw$*(j;_MMmhfbW?expq7IJfog@zuC+)hx%PnE!D8%j+SHi zCzR!FO#dCn-@9R$$ZfDE3({>GjSZ^@)M{sn#b&d4V%0Hhgph30XxMZy*@kPNXAxMM zkN&PLUPCJY^rqB#3u?!J}DhkzR1Qur{-A8OD~z)M=Qnt zBjzCG)$1W?cOom6?h%Z*`m|DHtEyP#T^~MuTFnPwo;T@FGrdlF`3UR%)kkXS!jPA_ znAT4+fp_{WD>UwsKK(F@ZExq$5O%Z|`~(FlAIYVD_*nY9<9g{cmhk64SF<_Dh+#wv z+%^i5DD_nt|DQ1L6tYpZTMLPA-95e?g^z9G0JiYhrjCDZdQ5oZ!BCErm=mhZ<{LIW z!)CTsZ9aQ;bK1k~9>Oq}Y&rd+^kx(2&2_L)P-gF5=;4BbM<=1+NaQ!C9SE7sqVPs{ zL_&%yR=~g6!6P}Pl(N$HI%|Am6q`PApmc5I`9%}Uo48`>*iz)on3iskK9E8yXYs## z_SCk+3)qm??6sBR+|^Q&^z1cb-(XW-zoBy6;>feowS&g7ja={czHB;YTQOnQDybZa z?`;K@qn)p_nuP~9KhQ}Vkmu`PvhOcZa&prI(?LH_aceO=)r$+=3{xGkEAnxk1YKuw z5aG#mNX`!BEOx499Nx6Xdf-6o z^Y^Zuv--htuiSUvcfsG^eDI?Oo0qJ8bNQRc?|Vg9)vhibfAh`bON9&T=gw`vtF)4j z4BxeDcn6=El{$ZZ3co|R<#1I;U17n@d0?W6k3NpMdA!U;Qv?=djbG9`|Kj;5j|%$I z6KO@JEig2G;Id7$x#WfPsmnHlwy}_K{A%0c_OI@0PrK`@b#t`8T0C=jHp_T=f5$$< zw)>8AAKG0mdnA<}03atUBVW^!-A_xYPTrm?Zy&(&uDiba>aJzaBYbZ0ulhaq*L@xP zt4ch71kLrM4a#L%LI7>2JZ*${lLQ13%GH*QZ0`Yh?Un(xdjS0ThQWWg9x*8sL7iv8 zk983um{!7@bv>-C*8^vCk77TtFpewEV?>bZhg^^~P?_2(dd>OcAD~5@J${susOJx^ z0=V<%e{{ak9{iaroB=wEK>wfo5CbDqf0{5D!p)1Zfhi-k+n)|5qiALTI2{Ial%%{? zDmpGi)Z%SzFLC?1V{I>uL^`ABzY60VV={g&c|F@WVvcdnD*RS=t~)B1FxygQU&?IQ zxV+u|xOXYi3|@Ks+u=*Qp6m5Swr_a+@eLavdrW%I-?x8Xf76tBKDpoIq+m&Euy#bS zSGqlAuo2vNn#N^_cf=$G10JZQc1x$&s7n55$5iQkG5zJ2rFWJty}8H#n^JN;hLoHX z`sqD6DJeOg+(|hpIrN*Di;(s=(|+_%x^KkND-SIlk#@y1@%+@sHbzU!u1o8s0V1|N zzpx@h>&QyZ$yG5O@(u&TtT!|AI$p^k&lb)1Jo?^JjK5uwbxiORzfy(;hx?P@JUQB^ zSY|XP-`;xkXe%!rZN2^WR@PdPec|2gii&LZKvszRE|kR{$gW`9>D*Deuxas8p``6h zRz*dY*q@fa`W2RVBk`f>pkMD{Jr2|hxoTyBC`To83q)1Oqd_b{yfC)Fh_5RWNLu;1Ip0#Av!Ma1gdE@r!@79a%M76=*cZT%+ z`YoSqV+rS0ojT%QLgJtGOF{1dM|zxT+S z!3nE2Z&@`V_}HySo~$VolB{+^Y@lKOvUj$=&P-!>+g+-XuAkmG;=TH&U%;jH|SFgI`+P`8dF_u3_ zmvq3r+u`L-zZO-SnBt5&0YNaQ<9+;H)y0*Tc&Uy*Fwymos|=p&j!Syv;3=-ezC2iIM8-Uz6ITRz89wPj@`WoqSFDhFiqO zNv%>FyM~2fsp|+?dRsa|Ca4F(7LO42@QTPR?$(YDUI+tnGTiYO?pAq&g=b0%ORl*? zVY3MebFPI0egUGPVf*iMJ}6_?z`$wF4R@e)UBp_M*)Lt zRET+5@AxupZ;)ZJXV-q ztVTvqFvKiI`9`p?vLQeN6&?@an2e3(YA871UDHi(_#kw^keTR5XFzTV>ws<~y6aFC zs$4u5YHXy22sbhX$7#n@Pf;bRrc{psUJCx{@Sl$n^*Xpe>(g?qTD>ktr`K9@()3OX zKsm%1o-Tny?;U$rcN|!~SCf=8GBEBP2lw1t<^gH$EZ6+L^Ici)v;pR~o>L{fGpgd6 z3=<*>LKGqu3UdVlr?zsO70@jf4UaT+9(BChrb5Q>xYQINB%~stUX03ygB}68Dow|+ z)i>O*x@^hy3#Y_?5DLY>U!*jne0PSoyxg0yyF8<`Bz@$FPdw|JZ=!h=S}?dc2vdH6a#b?oX$O#h8f&HB~XrkD{U1~xAACR|bs=vIRd9U6P>BO#gY z58pa1D~VGqt^de{7#d$}#AB;oVojJqCx5+k)9#yIx$ySV2c6OjsWyvwUv3r@@M0Kh z@hf%i?4Prq**;XI`?Pt{iv#D?e!4Ni-=!H($X*C~n^2JC2xq&TuEaS@kc0qp&V3aL z@$W_2_bf_wCqtqm#XB_jSE}2i{D%U5D6QaeN6<{@fp3DFd{LoMgJ%%T3I;*tf{B9< z%D@_EHCU)f%)8R#gfvmalyIH1q!_;T_3x#&?_a;RYT2rR@mYeH9N)XKG#$}Mc~dt& z^Y$|vr{?j@m|oi0J3d(yvf>A>T2>{6k=i~Asesn22{0(d8|7SA6*J0`lgnmQLW||r33e72nPH0u+Vy8msqDTzhd(siII)*BiaTYC zPq0gQhxdGNA#-pjEiE)S^8)d39CYSku|tlnfi_5?A_rwcm4{z)RF?=7N0+wFoWr0n z#TOPVX=E$HPY6rzz1K>5Kj;#n4vcOd_{WAA-HuPToMaiNpsGw zuP%>XO*gG$>*U9@g)i5INQtb=5W<*u%c8M!fCW{k;P(BqO&IXO!Uk75P#n+?kPY+} znUbiKU4`b$_nbzf$|Y%(UmM+gPkQh4p5qk=bRA$2G&aD{t;`tGu~6mJR&yZe}0Uc-oX;o4ax2Tw8+abbF_%jM^aDALO~F3YgTeIm?5y ztG$5&f%g7|`cW5wJ_SSo0cgHJSEU36MbCGAjdfS6-~NAWj4?6yt1CWeP+Zz-utc_9 zu9k>?g|CC9#jy3#(U-4YL3ASX;n!HE(@<57%s1_gJ-?Rxt>oC!d4wMF-_(u19n_fJ zki(rLq>G3}hm8}ot`n)a*nMRqh`-zj_{i&uW@zHId0M8K19!R*Rh)1KEQT#}$8??; zS9+A~J^Ej^5_N-@j|LWLnL10Ipk3O8w(jw9=1uB6F|B0Xx}UTn>3%>nloDdrOQ6%Q zfpw8AGY$^v-hbNfJwHQ4sE1(IbRgZj381okfy|I#x&%#Ozz@R1;2~~;*A#U*q)V1! zHvHp&{Q0AF20ZYU{ps5~OngYql?4Y6o0%Cn7l2S#qp&EFnli(eFl|BddSqWdUG*}>I!WtblG7ZD5 z*mK~)0x1tD_<<0k;w)!g7_u;>D1bnWc0+SP67|ai)Wwun^t7QBj%4Y($KH~T^;`bN zzFM{BhCgjv@yBcA{?p^jOMOxv-76nNfa@La<9|o^qvJd?yc+m$8yb>tK?C9dLJ0yN z3XMHS+Goj0cdo~T4&@KJzk&mBTz5^A9munB|didgX&N!xjvh~Tmr(W(Hl?rr0 z#ABp&84c;7g;OPu{(fnxX9;mO2tr)($uRlxCZsU@3Pz#f(WQYp2Mg@h_d- z5O~*^BunpREq9l8bay=|bT?rj$b5=yck2U*;mSEP3Xw!o9SyA>vuE(K$K=n>qvv;O zG&vwbJBMF6pANq-di=ig|9)P5XQwtE576uyapn9v{J!Y%`_9Yl`qO!qyClf-Y^j{j z(E&_n4uEYi>spF~fo=vRAj`U4j-Oplp_jV_7xi&5apCuv|CIF3$t|Dk&=F;6rf=Fj zAzFx6ATYiXttSX&Wr}{b;}fFyyll0;9DUG) z<8p1!2O3B+4nHpc52T1?xdBm7slTo!l0*sbC$W@`k7LD>=Jn zR@DNa$-fV{r);hE3F&?Ljhlb2jLi3hR-28B+e4SD#38E~9uYn9L@PB#E9Rk7ETg-9 zq6eRdzNO>qpUkWBw;}ydl!xr%&uGF#9FU9aDy+;d%0EQ33|ICfEi?&G3jgOz) zFf3H!-6tWkNHn#6Iu zan!s8s1C{3m)4-|wnCmLC&Us3j8`Z&SSBhYsuPT+BXfXN0P`zX2s0c0fKuG;5Qpha z6?9m-V90Q*NQPcZG5=cpJtAi|EzB+5GIjURL5v?5o2ZOcS&eFS!2mI(f63$+t+8qS zmnWuAKk=o6)v6KS9R*ou&R15gdPVy3*590zCU2j=>J_e_K_hBCnf^d|_THv>W7XsP zIe5L@wq0c(tW~K8hXQ#jX+-Bkuv-7>@h^wX7H85!q;t}judJH1mF<7%_qXE79fJ}Bf5jy^ZiQZ)3N zf*V!`W-OmRxnH`u4FAlHLn+A&^}(>}Uvm8l6@+fsRX^&92osReGUO%dP$3U71PV}E zK2nFt7z-+qT)&cW?d6I(+;kdn#ps=v>-oqZ_r%4s4?iVNgF>p60twx_14*) zS5){A8*<2IO-xFR_jcDe^6}3<}_O5Q|AsXT#4L(ySAtzr_v_aV|D}gwKbR9VGwm9aK+asZPABUsxY{yvv z*J0a1XAgvK{{-7%G%)5goRn>$4%y2EfqWhnG{kUY4|x2ZKq2YKk=!s87HDhxu{Erpq?rG%QXz#}!Yv&wJgpc&)_4V`D|!!o+vs~}u1Q7x z3It-3!PCf}ssgGOkmR&NOJ@Qk8czc8{p}B*H<=vmtqzmv{KM_w%f6M9IN`~l^-pc- z2yc8`e8rfaZhS?2d?O#;@>E-koU@6&K`>AB4~=@oyXCR{bMNm;z(nuw&T{&*W%*My zXK5$`tDL;aLXnoADONPqD|?QL73sM{Wdvt&=?2iD75M%XV^5ejXdVzyP=2Sxr zmm~<|+vg#1=a<@Cr?AYHXuPE0XLTH9TCTeNPjSim5BSgcj%NmPYdB+~Qu+>BCX@^9 zj4?@gT!>QWiLVatyB}eyBa76PNb17LsP|i}V)P}Y`cC8?j>akHD*D5+-ocd20`FNb z=zL!`kd0)MfJ3>G{hB?;-h%-~;^0sy5>gteU7(sk7V~H(X1`Avl($KA@+qU&V6MeA z49F>+;5z>3tP31eh+3+04!T|kcxOlSiGtTaX^#<)0C+XHW<-~Oe^XeP{jLG0a&Ev<36z*n$Lg|I&(VWrEFU=#2jo9Du>`K zPD67Pl>^7bF27lcdgCSPR3-95qs&S`(a;eR_#J#PAq)CY8md-tkP0H-1+ItU*OaPM zl*uUol^Z+qJ*oBrFI7ubjNFg-Lw)2&i2z%tRw0jG6rX*h_F3Wr92=E@N)@Sm);PE} z)g?F_rTVcc*+aJFrRTOS(T|C4=5Q~wUa1Kw#lE6Mv1tS{2)9oA$J&HN*R2@IeW$jn z*!Xa9UV|etGV)vJ*nD8>a-vnOj58#tG`hqjm)@C}8gH@bRDlNMPc;tbQhbS`KF7dw z+Fn|t(b=DsFHUsZ)utiN-hjA4TIq!Ryn^&Kxn(o=TyM)L@|4E_3o9_SZ+#jQRltg2 zd~fGq3uem1MSTax0`@#Z1NB6fUQG0*a3c&FbxcD*t70}wd}^Z8;E7MrY1N5(r}VvM zluJlRw7G|;#_9XH^detUXdL1)Wa#V;lk4JH*C>t0nwXHD)L$Q$>NOSy1}7Av)Wao1g6+*LehE>mffHY95VQTk2|n3lIWL8;WGY?Th0dX*Y2 zfO!`OJjZ)CGv{6RG5cW;fM(29#`uy#XzEp3PN`AFAh)blm|H5uxJ*E4{BoSPM+ zHfwq(v60A);qSG&K}_9PTsTJW6n^vk)ZPA*v!lclu+oy%I!*|-_fsiC!Mb!F&{ zHvkdSEW{d+%*JTUFldrFQ_O3>et~Ng8&+lb2AFy6n8MpNJPzM$;`U9!_$vbdV#askxc zE05z3*EuZ7I<3Z$l%&xbY=$ItOd>v+aWJPH5b$M|d(2*KoJB-t0-&4dlN{rDYnk;&aHqm8Q^A7;_Xu9{>B&)C@V@q$n z+h7RIFd4OM=~}-3*8J)2xFm~UO}chRvZ42u45iUDz0zE{c9DR#yk;Kn_wBM;RBGF% zz8tsd__F24k1t;)`Opy)R$x%+_(A=i6dD@P?6%RPL?ic7pOtZHrNwk}61UN*-}OQ; z|G8WBcEC3g#*m7Q%fOIS>+?l5fSvFVrm>l=I>4=&ODi<$9KAj%4b2kSY%mR6p^FL3 zD-P6hT;C5WN*0$DZJ&a~2>|Z0I(2$oUB8sq?e=~7sScjEC-x1q+~O*qhYcHw{u67n z2*~4bc2b|6#q$C&x|P)?Lq3X+#Ms0$^wR(+8T_u1Jf@M)`wGtt=0dx|E+Y_0Qk9E2 zSf%Bt#D6w!pE6~8Wa*Ucjg8wQ<4WgkyZ$%OF0#^hcl`dADcO9+!1-&3JuxF`^2Ek! zU(AR@(&-b@2Om7WacTelp4?2j3AfWy%~kQ;w?-pW2>WmrWpjbCMTx*ZM`xxYLUg1Ur*5EYYXMjx z*hMhU7YgJ>1BFdU5+?v!RS;S9D9Vy2YcEkCZ~N_4aG@i^O%lDU)fB1;r1my1A$`FTbMMpuU(@|ICPy?%-!#(6 z#)+FYO^j~sJ$J6-MtDsSCreATEc!@i>=Yn-Wh)bSH3qzip5CZ1@C9UUibU=%**EsQ&7?sWlHESQ&cHTK}bD|V2`6XBwv)BmjjjHN(+u4VlkgFk?L^BcmCtpha?@Ph| zN8bkm(j`&27P_QFyd4Zvst2wI(Nviv^g@+{P&H!qg#~i@kBu*DZLz20@^sHgFInSb zV$#!NViGLuYozv&(r~y2r`d0DPBdqTtr=#~s-Sl$cyRLYaaAz4oq)B>HV>9=ztRJ@ zQ8#cT0)^%xdD~fxGki#DfsP^+3Q6BKA8`-Dt!SZ zlERb=IC__W^PT_Na0hZdU`aV2Xe)vi!w3s=G|K1(R7y*2s8OH|NrH{)hzj9NKshYn zNzt=bSJn-ohn+QKJ!=U~q!$u)S5+x{FtSqo8;WiXm#IGH7MHTSl6!L+tTlg^5C3-L2$kF}sK336IXvY@)pY|Z7h)zmTIz7~DRZw~%IeSUEh@9z^rajEAGZs8vFbeUdjnShe=^c$F zgGS*XWJ#C*c%VT}X;~B1Za-x!cjPOV~^4 ziH{>)dxxUy)l6|giz|-s=n%}EUcxuyTq7<*CU+`Y30_Sfvl9 zt8Pzrs~BLRUkOnJuoaQp$%zjXqzG&S6Ixl3^jh!1eVU9& zuH{)=q*70Pa;jQY*c5~O^vd+w#$}DQ=}O_o;sGMB?w1p+;vshr=8LbuA0iz}SjM^~ ztb=&Orj}C=FhH${=v%+Jm=XiYNEry&a0^ThBfXyf z>(lt(D>9@PdsBK&`VLQcZ{_XGaO8+IbjSC1HQph;^W?qKA5YG>=PO=$MRnvpr|9O@ zz*~wxnuUKHnMR)Xm*;62(=Td603V?YTlMWwmRj{fNN){Ks%n?H0RgN7#$4CAW|>i- zgN<}q=V4*k<%=h=@@84zN)N+h=vpM%rar1rhp{4G)&M+K>JcRdT?}dI&}1rfuTK4M zO4N(S1AiY16^@#t%Q2&ogR-n57P|CnQHu+7!N7=yGFTvx8bUhhKA>y??NnR@ncx-d z5ko~f*GNoHTZ_#4G^SS=Bs*=gzuBj*ooZ))qn$`aRc>xouCROJjr%t5yK!RmlIgPr z%TS9jd-{^3L(nA5DD>NJhJV3nZuM9q7E;Ww@L>NER{D*cy?}8$CSa#syv>m zWrKA)-+c5*mB*uc^3gYU>aKdUr;allIwu7Kx`4yd9o?G z(6uLqk#lCz+_};ssr_=5Atmm?h}gr#%f}*plh!}<-R8~TJ+wYalh>dA`$nR_MEft7onoo}H(#f-?1*zj(cxMDOJ4*+@NU;S2t! z-{9Os4|N!Jy_}Kp@~$iU)4=~_iBqraPfC@Cut5Hc&UF1e?##UF(XIaTO8lfF74F$n zNImL`?_h*=dobwXk4Q=o4#_!czsI0fAd?iX zC@_o9#dnddy+pL-V29`iXdqPPkfAXtkqjNQ(vmKLWf+%`TXy%RpThV+J86L%RRp#X zoy1s_v=%@m47R+Ohj8Q$<>ge#i&R$ZM_w6-#oGB=`DlUPpux$?0#QA>vb3tt?34ue z^qu+z%BI>#c=UYfwV}JF=|ts@$wfJXgfPG%Cg$}+WMrM|K3cctrb_SnD@g2(>y^eH zPV4mp9d=)rUa97)a>8p0hlwm)kW!qlx@r0kg{9Ka*xcHt<)c~p;F+z{cCpDD?E`46 zQTr&Aji3|xKw?*rVpx`wv5tfKmYRtghgt^B0+~aO5+U)l>&ou7K>Qf;Z17Q*%uo0d zB%Y8upW`Ps9>@to48Lba+qh(Q0B`SI1KdIXk1j!&HcNvu^WAxIYa>je34d`$pGf@^`4QTY`tL|f8FiIz;0siMG!tc|X;FCr^q9f6u`FK39z5-I2W zGH22JQG;1sW-(L*uWe7Gb}ua&kmHkH3Gd1eh_2-Wd|KE7&54_8=N>Ts{lMJF^oAYw zdMEedz#)d9C#On#NLyQQNr8>cdUd?r>nI3mnhinTd_i3kNUt)y6hfHK+!rb`XLcy8 z^|}FB+--rHb)J0b-JJ63oHyR6&QgyIWDGKcVs`dDSsqN2@$t};Fbq3+!ZPOVW>)AU z&<8;!Bt^NC!dKgaF-b;YxeH>%$|KqdyGQ3{v9P{uVH($WMN_SW zgf7ybA|KT@-LsP2nGqQ^eV@9rsaDxCG4dOKsG|}AS0=NzFqsc^v|w93D4Pq9PcIQe zTHtjKsG5YaoNv;zvREXjU>Ma(MM-|gKW=|XIsywr?dhAEYTYaE32&P=VwStM>0%3; zc4R%TFY?8^Q*&&|J~vV`8nSwqq#KPbN#03S?s%W-s6Hp*d0Bxak4f3rumBjWpjkdY z1wG3Pvd0klNdQw!YdN5n?}Q{le7-W3C-3xBOn=d_YwfX#218sw#xg>hWYVVsUPC;L zT~RuS+c3n7eC*X>tF1Hi;xg6RiRMjX>o(fzX4y8@U9-h7VU_AyZP1aIk{>tcKxu&_ z_OH+Pm1*u=zeiK%%M0_L7<+4As{|gLom7>o3zR zi$B0uTvAM~VS7povmNZi1lPpv+WPskMoM?G`$o=MI#zqb#Mo3xp~^J5bh?}8lsEaL z&4tQvo-Z4-1J|>d>|>L@GHebsbv*~h!tpRocdm`z9s2pG!KNv1xM5b z8oA!V5#hu0KHvt}$EvnXdT-eRX?JL3lnl9*@3`Xn+9jA>v4Ji5SG9x^M0-XT5z#LuC5g1AjLkm|MFk(F{VBU>~sj zNl(x)WMHtM7PP7A0f*NfuhwtYR^{MuvnJGDslG5Xv*HC%rJB%7hN^VvZ4G(oz5%=`mjy18Z9Idcz;ACk402(i>I z4i2WdjvcPZXQOQKIaS+Crc6ts^bu{Rxmcsc2CVE^j@ZbG0gH0Jf^olQMKv5~pdTHCG*8;MB7-JsBf`?)9kAvn&##OnR=MDl*tWXA0yo6sz zxLzq($%%cS5Cm`)MIjJG5yNCn9)|oi@Y;FDqTdFuoj>TUKy``JTLr@~rqSxR##mU+ z(`x%Fo90Y5v&3xEYc<2MzR{-nK&$2T!iO5$F1>|sU9Puuye;3HWzjD;SghKP3cXHi zj^Tz%V-bvbZ{(pEvsP>1pN%nFBNt*5RH+&SeVM6Bs8A=4r3R7By`ymm1QHHes~AO< z>*D80ff5Y@0gVSzLUbN5mp?Ck`=jScHSi*T_}d$A{FV*vGNbgYcQ$B^oau_eN)K(2--ihb z97gvLas)}S<?ck0Bl{6I@z&V}9WabcIzcen5?o&E(5a0>yaP-o zozbKY=#9K7D=;ei=HEWY$KXMuRq-4eO8EtXMw zfzu-|kQD_dY{c!Ib_BR|)x7X?AA6;)T(sC!Qj7 zsa4e?x@Dgdg+_3y{2CV2@cy7v1Lsi{<64Q>MH;#06ODr;H*0-X`j~6xnj?+aXRVU^ zS>|b!!dxpUR_TO%868fhi#ji(+dgSzVd~?uyejLB$dAPj(up@Y;fv!8`ZZ$E9|U48 zBKxoGy4>r?L-1uoOQZB9bEc17FZJfL*b7o`WC3vED050*rjO-^UZs+cB1+BK@C+`Y z8^gGzioJka{|AqI29Lvy4S>-5X{RJz^#{<`rJ-%Cuq#BfYz_dD(|83cLe7F+y|T-y z3aoeHTMLSz&_nmc7Uc_&4XzGcBX1!(oSixC(c9@>)F*#KD=7 zHjq3zAes}YPlIBKd_p{O@^fwn9BG1ZTMr5wgTsTt;T`_P&5QA0*s!>E#FE9$9RrRn zU3Tow&yNWkk1bnz3_BekOaJrCb#Jd-`}TFu@b^j*;tZtaZ{Iq8?EZ7yNa;IdK}AXh zwoYK{v&uCK4@nmeZ~3A&ca*N)UHj#h!_tLA3pM3gY{7nZ+n-w54O~L>^+Ar_UOb83 zxp*;?%g`df_!#^A*s;%#N$G4IGp;?~c7Cm(TeNWep|_VWee>WXcs}DWJ_BAW2!-nl zZ+Y@I>B6l|(@L&&toBY@d@EDm_T()%K7DZ$`pir?;2pv|tHHN`zp%m$?`kX%k|mP? za?XKA5aldafi0F1k>M001GOU0F?k*3AmthPA-Mqa2NFUKM0{UqyYvIo0=Y*k9e8}x zrpGt2EWMyl&-O2UX)x2dTrtUGlKZ_ReV;rAo5@T!=+!0u>~vhBP0I^;L|fIMrqc0u zd3~NxUK+O?8K%$RNk5!=Yp{8H>LsxT)FJ6+G)LqtOZ3HoNIFBE%H1< zE>)G1l4M~<#V(e}-Nh0A%b9#`gygz^qCUQT;^v7HH?u-*TAyUCZ|%kv2?@!4(zK5B zeswn$-k9%jXdGpZXO;}ZQsZzuQ?zSzzx07;rGK71i-bUHdP1GTa}Q6N82P~#E5@l~ z)6*=LI5F0i-6tzxD7rDP^8rhTMjv^$$Pmct1FyB1v-C9fMMr4mJ@>5STd>5JC4N4v zd|V8}kB@x#WC2n}V+4RVq(DeDmpO8cjPEH6-O8lOaoazWo_*j!>DkY>PY7|(=BBcn zy#w+g`#&u`otl$BAdT(!h~e>-k&6#XEuU}O_BjhZ$f-gT+TZmMz+(OYkMs&F_6*1` zOp(@-PKTi^2SEd7QJ)hLSp-uBq8Jf;kqSgGkKF()Jq0qWLG6j&77*=G2QIi}`H(?8 z007oP90IAg7V`$`rVB^@7QAHOV%aRdD$i%jwCy6oil9oBb} ze8)J}x1ZfJ-@ULRw*O=nI=|0azQl80|Cx$CVHnsap1sD{j`GNNo>|;u`H@Ro;BfLR zZ+oR+=@`+cF5nV-r}pXCJ-v(_&hWEO0|U4MmdoYjRR6vIJNtwAoGMMpSUy)?AXR&i z`k24y%QwKElgkozwTEh=e638QwXo?d0av@X2gM`F6Cuv5T=3ddXbL1vfNQWy)_;)S zaEhN2%n^+v+9k_NMpAGD36>WUQ!WNyki6b8bAuJ8)F;pYK-_|KZ*x>&V467c@aW0R zT*1ijk9gwZeJKUt4JK)pZ{0DOmyW4cZQePFyJ0q;7$@la4Eb=A34DW+nFbAc@qQL- z)nkxwi;pG`(CWngh6S7_LD0w9Y{ObN8#z6$GY+hH?E!y`&b#Q=a{6N zN8J7J$o|GToYy7jlhXN`Pc|C?BY@Wq>UZvb<}k%5tuZl8hg`T$tkN$i(da`pA8m}` zs0#W)f018~Vq7i|x8W*NmP|8P=iKU0q!2m|Bg>lChtE}2b2oi1{gdr) z(9Mua+D@NtJFQf3Yqoyl*WA6Aow)seX?|qRO*bb=WuA*{{Rd1JJRm(IeHf|RV&E2S zVihZtxZ`vijVr`aLXY&aY)x=0fC&o08i-!Ri_;i_M<`J^mD8_;F|eF$2Z*Z2Jm`0^ za##n^uh3smc0plva0Vvu+oaE=0rPuXst?Z6>6Yj-zFt003L;_x`E0@@3UE#g1_BKN z3@gEV19lb(NCgH!a~fL3Ky>B&G;EOG`26wb4ohFnthq)IuBn;HY=@sazFK3F>&GE^%L86W$bF3xPI@#`Ky@v z=5JX4(~lBw%2sw7qdEnX#WQ9wEY`kV~?+5Xugcq6Z@qbhxwP>8nsJQe{Xm)*G&5Y`~qv!8k{px_ii!V$W zv-FlVkL65d7r1xDcW>JL2X1Uh-rnaYj=ue$Tk4iE)zap^_psSNj6iw|3!BWA#|NiY zEj#%rd$4Y5b?!ZjwzaPvGqG;aM_XU#hTM4eEUFlte^g=2KSn~={;@|`)T(LkG6r^Q z-2&K>XD6IdDXjX7FhGLpz)T4!HNj&O+cm!dqG2$kVCnb!N%+1RecHlxQ|9S@w z!AmJbmtlch`4-uNN#$~2Ui>S{PuE^nRjIJHCD|x;D#;HY0mTb$(2I zRYL!>$Bw-;+}A6lkI^}E^WD=QpthBB*NCfSeMzyd0#g)Kb%*h^E`_6ao)Q-wDGEGr|*4vly)8^c~?~OP2_AX8|njjPUbhCF48aR92 zz|g|YjSp=dyldx+FYOG(a%$xNwI|!n`~sJ&<2*}Wo3mie>UU~KX6Gbpbh>!GMm2Xv z_~tDe5-cEn`i=M8dGLCja&dVmRMFJ5ch;ChwK|dU;|8pqIkmW?B#06Vyw%H%l1r>D zs}fC|(V)^+R+*A4VpXNtl`v$*!Z{;rCrqdvHQS>~Fq;ym^=Eb5_QqM~_U?Pbq$?;? z^Stt=Su?5!)(&crru7@V^})$6?Ap0AkisGTxmt7@xf4d`LMbU@v^8f!?Z`Pz>opP&nU^)=EmtwLTRWs^_e8tTs}dcNkG3}MjAG6F#<;oAT~La7Py=kUbw~=dogF= zk6>!R?E_ZLz-MrnDde~Z!t4Vql z(daPh%QxKm@rsq-JbZk5ids-=^wuK!!%a9$=mQrZ8XzaOWm@MM6teH${P-|f8 zfd8*@Zb8mkX>)?tXVCvSeYn-CGx%0+-@R#ec}c@{t9DK+u&0bw+WQvuwMg%0jazqm z=JY$JRK`UbtE&c&b{YE2UQpRrsZ6q(f+PFomycgQv6sdOggjw+{)1!E-!je1uj^&d zTC;C;s5Cr)iK5A3InI=)RK>7+lB)_bbh=jWFq=*1=rcB5nOAqy_|ZEj4(^qx;nr8W z1DwM(YB>C537(sJ|+!H_AXVCJJHXb@sXt6LfNtIPb%1p9ZbU)Irl#?Mx z6N7^g60wY~F2QKoMIj?SwuNvT94%UjcDBk_^w<;?LyIo^uQU?*ZR}h|ku{=TsXeya zEEIakg?{`b`Jq>|j}bB{wGnx+b(%M2>kDQA2FIme#QyBz*VA45C}v@_Y0*|f7>*$= zR5LDw+)xS;RRvgDcQf#c%i9djOjl{OaM4iKjGLnuM&1$>EkCKVL9YMst2Y#hK$!m( zoqfU&&PDDM-pe3s6vurzlAe&!NEAngqW`mY7)ufOXU;@p%%6Tb8g<^af98y)!~Nei z%`FJbzslp}fPZ?t)cXIey=;)9(t#QRtXO#U6KE2eiW*2>{NFW@=#&)5IwQ44Tjm26 zZL0Rh|E^iMzLEl<%kF4<<7x6^BfbBN#voZb%JU|5(h(B=z^!zyFhzHF|wFm&D|vAM^8g7eqt!jo!d*7tt6EN z-tEP>_@g{Wc`42!s)FjSkf)nCf*;0M=v3cdrlwF~Q-3HVmtN(YTJ5gH^tKlHy`gAS zsvkvRi7q0ERk?*Y~*0% zpw?hDW0%7&H=CR7Zja?c?Tt{jw?xRvssDZBeh77ebca8FZsFLHv6-T-Z;WVtM*qlOdHA`-l z8Y|YS627=%xBY}#$tf&Wy;=z*9jg+|dRxe*hJw+Gx!tBlWB&9Ae@UUWwt-3K88$@l z?DXA99&$q-qR15^_;PZH?bHExWmM@}L!&KAM(an#~5!gihJ+=mfgm_V7GDdeYo}Vf0lzJb?@D4xxYjU z@EV=bA$knn_`JM+{&A6;PBH(z_folKI^Lt)IW%|u7{OHN)Hags1bP`TPe2O?)G}D+ zG{E~oAnmFU>8S(0Vjm>)auK>PctA4L%f+r*voEFD(vdfB+Bh~LHs|2AnWY2DUSreV ze3Ol&3Rl;>AhqRJipE%h7ZFq&!>RJ@y<%OuBad7*8F7#FsByIREWG2Z>ziI3QqVYl zWW{`+QoZ9VX8B6maSDy0exRR04LT#31S8l&b--DYGbsHUraZ9m>-%QRxbJKEJ8A@l z_%HN8CA`%2M5Td2ZDw&uBY`ys@e3woc}d$qF7-!FOYib4Bd1xqaFn*W5z>2f6fMaV zqb{{5?-xUI9J-Q0;m`YcXv$Q65-5Vj4yT3Mkv4JAB07}!Yo)W&uRptSYF5Lbddq@g zu_tnFtDn5gndJyp7S5WX)~_iItzvcUeA`#j6lo+=HM1(F96Hs0OZp9J&4wM)Cu1)D z>R0tU;@R~&HGSi#9#sK(kte@m~gm za=r8h-AnyCs(S`w0bj8C&ii4faRyjLFq+#4(I0o)6VD>%5N2!S9TzNsgO0FD|(zW^%wCkPf)x*s0X2LHS!YHx9LF z^@CZk5O{!84i_Ay3wHFG=NN? zx=)vNGr92N8wqO<*?OV|8N`ptMi`KD@@4SChU^rfpX;9%s z71kh+VDS{59tlUCd@6#4pa+BZfimy?A>Z%XcVTz^o);Hx`f}(W7D~6j@+;~6x7V$E zoB4iqo-LL_+#}0iDF5csE=&2NNOp1jy4(GY+uhkQ+Uy?|t-4|Ng}n=3+*7}L{&n}X ztb1E}AJhYnc!#T&nj;b{_Fd+6>H9CGWz7shBqizS+ivhFt@wt7)zXPa5cDv=8KD?v zAUZQ~U*ymPer($#j|;ck_C>y86Qr1qd)Rb<>TbNH%?lmlQg=RALW16?A z>@=F7uPMaEvi%gq(q2&P;&AWfd+;noWBots-UB?2>gpTcduL{QlXkVMu2oz0w%T14 z+p?PFZp*z}bycit6*r0n#x`K8u^pO?3B83-LJh<~0)&JTLJK6s7*a?=38`Rf{Qb_% z$d(Psn|$x{J^$x#YiI7OB27?qt;@uqGejpF5p{d=MAqr#Fzo z?`}uB*XQ%5JEEZL?tI;0b69aK116lB$mtxvY7i#=08co^1YX{Nz5*jdCAX%rRGdvp z$_5ZJ9SV*l=%tNup#*+LI{2$tXbJOxvjwhIS(SbYm>+mlx+V*J3=vB-(VAW(+9w|| z8chc0iQ6*^olz;?6kk*`c#p~sP(EUhZuV8?7ba#!yS$0{1+ntAo=aDf(9X(BJzcQ{ z`H5avbXH!P-Crlb$6gpEfKsaKCXEZ|9-~wio z|G~t^U@y+by1(J@gz)|^FfLh;NvOoRL<>d-!fV7;1n-cHT)?{~f>;W$p;hfptB&!) zW!m0_jAsBV>Tp`&1wT^D=FIXdEUFCWsVHJQDO7;IuRdgO8ggQ-)|5oEciZdd>^c_i zZS>?+=`)SFx(+{>avNN3Q#-#hVig#l`5EGo!7+>Cr7r zx67O3b;aAFdwZj8@$psB?2#!=F$G1jiGsNzdFHHheztAz*2D$g>U_`K{cr3aSa8LQ zpWSucN1n$%lArrs+>=}Hzbe%hH9fwI@viu)3|ssa^>XYBX}0L9_*~A0}Nt$Vj3PmAMLZh(kbpaUoX5thz%5kMGrcDrx!qhctbY6 z(sNm%sAzoQoDjym1aGoY`sMi#Z{Pm#`5zD8kh=HdzQ@jKh3R5bV!@IPi}MqV-o)Ol z?BN5^1>yDUW+ysEuIS9kS+nbfZChTvV6{IvFPtC6^{)6}Mq#4cu`)BWzAe}6uRnjq zyz|!0E>3fqxoy?xl#t9>$Kv>c ze1D)I&1NWDJ#@+X1y}88sR%CK&|O+MJ1@y>j`oLFgq<$NsupC%`oqOjlHw}D)nyIg z**Gj9_*Lm9RexP~_UQrff-tKUDQ3)aMdwRVN~dkWk!W~!r@6y$WoJH(ou%5%nu!rK znJJ`&*-3f5>giV1Kc7U)sq!{BZ-O@cDQ$S2uZlSf!3knc5BWI3_KCPoM4}P;IpdiZ zovG8#4zcX7_U`>keg{|fDYZwL`zohO2})--{P=hFeswC>0+pZj_0K>XPt&jD(eP_M z2|S>x^P}g)>d7UrBmb_izScjd$4rw)`d7VEruN1uV2DjsWa2fC zo2fUS1e1YS4TPa4!Z&^Jfewg4(^-ze{=Ep4(rnVR13VEPpHOxn3x6cW0XDr*2#QD% zv!#+^9@iDl zG7dXPu9QXM)47l51nHU?#}4CL@dw=s_1^4*Oh*phrN>Kgna9sxcTvQ3+3Gt~dG$M1 zU*?Kjw9Yc401;##{f>ee0`=hdhQg^+3;6*APaNeCsXiQ^F6O|Lc3fID!ssNqS?Q|N z;TXi{i0Skqho_0}%I)m&l>?M$V5K~h-I!la;c~!#DsaiKK_>{XGY=10=>i>o!Q}={ zoXC`0sz97`f{OH0A%YTxkK{TXqWO%|Goe%wa-|TJApE*ot`_8S1I%SsvoeR-ES5|0 z^5csPu}7U|ldwQW=mQ*9A@pOqAtjqxO<^S^o4LpkcT|0UDn#X&h#iHa^M4+VJ*l(W z?MGwf$FRIPS^2~r4@YB}`i{+_ck+u9cdM1=fT-)iIM z!+raO%l7X((ZXJ10sMb${GjgSI*2O#02$aI5avIvOfCMLT<4ft#7SVdK5`vi^JT9sjd@DX z1^Jy`Hp)hO!8Lec{3Cqh#JZvKk#eA4q&vkq(l|;wr(Ut<=OXSGota=O$`oWRYHx7J z(KT;g*EoLo6X$)PS|q%{cKoQz2MDx@KIJ~%tiAaurJE-x$>+%_69x>AxTC)si}%O7 zqb1y))S}S=l1?}|Q$H>}j+t(TyrLIAzu*rBQfOta90(K^Y%gGpN+|5@5@Ju> z2%{ho_6px8KQjLL^K#&MV?Zj77;unrqY$e+8ilG8Ccep*7sG-lO!_tBH}ZDx_)ht! zF?qJ}OND>n$*aJH%5OW0IYFl`=p}3f(wU+|o&~b2EI?NGa2Sl;1GrNl-_n$wS_b+G z{YBiiXf}5EurQ-*&+adq*~)+JyFkuXY#WTVt&+zd+xAMOYo4p}m2Hp7}X9wAD z*}>2Gk)z{ptj*x8X>N043uEUUJ@Vvj9orAS-@THtmEG?j+}?59ljKkyD-Xem>C|{m z?6X|p{^w~r-_VmF&t|kQJ@o_j%Y#dK0}+^5dp$%Pu(DJMf0I^XLV8>{0na#J$oH^i zB$hkgEM!@YK6%&cugkl9Myu5*zGK9e?QwYn-}5V6jxDb`o?W$kd6oE1)pEXZY)p4@ z`*xYEAL!KZiCZbhN!>m7U``s3XQK>p{ec4q+^4gVB}rP3v1tVCr_icIqS^Fck0W(R z>p-lM&P^$XvqFhy`K*WsCqN$qznC!e#D%f0@;$GmWvnu1WmQF1hVo5fe&fjSHFK|n z`;buL{GZB;=WSdvrLu5t7N*fNEcEfEi<2e0&Bp4wV>q7m`cq2^QT^T@Y-KK&jJ_E8hqf+-`xG-=A}!$aLSm( zW8tO)AENO-@f~DMgX~Up;_C{TLGFaS`WRyYGzDav02P<@7c0tk2^;+7stiST=o7TYoY!Yg|)iz zteU9K-fgeQADva9T>K3?DWYNOfxn4YM14F9{fkv+VjtzA$!W+^IbgV#0qpgVQBjQj zQU5zwCS+TQ1>lCLr?RU6PXPf?J<_@LQocAXM=#`82KLjuC9IEC*Iw#de7dc_8s3lvS;ec{O=7#* zyU)0B`#U#Y64`b2D{C(uN?`dbZcdhJS0=sbHAKt5i7BcJ{NBy(>Y`%4dV1QPk-cB- z`~JQ?EBmf~8DB+v#tC|#By?9}UYt76RtaeaqX3X(QxCh9BW{=rQ0!We3<>QBNr+bw zGT}Zr!%F79DyU`B`gV%G6$UjI#fQnVQu4Gszc0zFM8zbOrX+>(R|Lzml1fcZi?P=% z8n%6S!F!*|CqB8SqvM`Wn5f*@)n^mMjVMelmK_T;Rwly*OH0f`2Q>_W(x z182D4#S{OPeRTp!_b77?n?ynJQO@YNfow2h>XGCRq&U+3S#TW-$e{;6^N?szh<#^l z?b@+5?6RqKcKK?^ga`)9Hgxbl@2#{Z~h(BIaQ@v(Qb0~}L2nm_eWFh50i1D(2-ou2Ik>+r4 zP4D=#%w>Pa?vj61W{#Hs7UQz?d>oL8{9drd-uF=@@(9aD<7bgqhz|1aZ}c?%Al^aV7m)?$YO znIZ|y9TJxFV*w_{4J-k|OBgJBV2?q_pQKR1v#0lvy94afhMB~|=)bZ$xPY^WNra4` zd%)P!dq9mN3Jf46296b!2yD1fjuM4!xPf=agR(HfUS@`OeQcUdZuXT-1Yxv{UPSU5c?MK6^2{UzlI(?P>t4ri5w{D*da|pTIgmV@wv|=fNseH+=qH22wy9jj(oy zGjj&*C}o7y)eK~X^M%nSo580U-lTB&S10Df|I({Ot)Ko&`oJuS(KCRud2;~jd5^gHdM4ME6yqmwv?$}RH#jwV~F>Z zEY%c4CLZYy1CLh{Y3Ff0IEsqUfJ=5Nq~51D;1RWJa=4IZFpgt4Hj37@l~L zRbg{0f|YdO- z{><*kjyi0ydw#YrYX8=hg#klKL(w@`WltBS;_Rh!3q!-58S%mcr&7eH7bL~0X+&d2 z+2mBw|E4NtPh{y-7q8~9i9I(|o@z|VN()`6-MJFWqSND}QleP0uw zr(p6IGH_?e#SZD+VHtG5>pV!cfas$M0=uWUUG&&RUF35FK}>%5Bgx3hPRl6u9@s!I zeA5RGe^N?%M$o(FhVf^QjXz~gv)*a7>Z@`2IDTgB1#4clrST&gxbM}#pM6N~?dUFr|q~~c%f~`fdMZP#pPJ<_@esS8$-VJ*jJ*zxc{nTh?;*Jw% zsOf=9h0L4uF6`0AflkF)83}?I^ymjt^YQ>12ni5h7GxE@QF@Vhzvvt~we*5YRXPn+ z7Jw~R73m@{3YYreyV2mKWI!4G_fVShW@UBvMrF(>5)-X%Gj~=yUHl7&QSWK2PPyYT zhu)lI^se9WVDs*qvQ~usx3bj2LLUxz8$)>>$pCo<_Tg7E&UvaIrVuyHlZ41E%RMQs zZQ`r3NhuC*rTmXe@|P?qf;@rMJfDT;uNl9?U}J*Qw9e?t*pss6fos>_adBv@yDpJ= zvjVgHsoB%lZEDUnae@8qSnsiCFL#;bYg^@SX9yKlHp349Lk#Ea+aX^!4L;&_qjyLY z7Jsx0M#&l=kg-1iX@0Irvuhh6ZmD2d7*;GfV*%25AW<8#Yo7 zM%wQRo;CpUl3)?^mz29pdv>7*DN(o#1`ekC65gLyvNzi@OJC#zGxD%0t0L@YqFkL* z0n5`_?1}Mz%jT7mz^kI^0jB+v5^qo_JTv_>>7O*5XT< zlW+ysGheiDn?rOITgx`^oV}sy_tSDqGyfQ8PfML23ys*XVq!AW=eqxVu_Goeb3xQI z5o2;Jlt{~SvdV>~=zZB0cNb2T+kAOqxvxAM@`k>tIaxtgEmh~F7ffAmo}QUez?(B! zq3t~HqE!D&=Vfv~{2oXwWkHiHU1ZQArIGz(OQT7z#vXtXu*Lh zNw7+fr4VU$;|RXmO@;9TSW{6lni!#G=Gd)`=dsz(dKj4wnI7j)oa}DH7CD? zD2vN{Zna!*sLT=m`Kie^r2_o>th`uuuEl!kk#&M)sYzZ@T&B zo8G?WAA3`(suTZy=iQ%ta`&qFwv5)fN90%9ndH0t&e!i>Gb8QrxA|Mgrks=?pSxvy zrfdDxap5VMOXKsCoy#h__w`Mi5ABFaeEfJ_4!FJbpn8EBvj7qk#3|-BTuoTzUAuS7LTxpIY;^$AI-Wkr(@P~uWLq4c4kz2O>nb6I46|* z`PbHj34Yi@MQ%>{CK_tmI^&x`+|e-8vPinV#M+~1)t47m2#TZC15=G|ifk2bV2@2^ zhlwXWbsb5DtfH(;w>8@$8l|X=UCUmW7X?`qYqmKi9d8WPyF8b0qr+(}wWn9-&&k7;+(w6wJ?3birdl`x|+Bn)*X{%^*Hpd zOOqr|p-0MfnUd3!@n>{rOCEOoY(5y%Ilvd(h&}Eaj6aYvfh!HAGWCg808%E#0YNbq zM|8r3J`?o^NtO}nQ9&I&M%qf07bG!7!&X}3t~V<2F|u%An8;%CvaJdn>|Fl* z{Ah4cKuftncqnjiDL2}kwo+SqjS2@f>9(NF;V`mGneL3q03fihtRbms4G5+O7i0hk z{PX?uxHC=#0*jr1pooCLtO9|_l_z)v%UN@Q5pP(rbxl~$E~(@XfII^t;8hIVZZMZ5 zW&b4TiI#-$Rv}~xf}tRWIa-G)AbHEGL=e>`-HgH7kjEpKOTCVUnnq($mwb=>>$N{G zTHtidd~C_ic~5}mHd*xgXC1z=V|!)Y#fx_}=31Hl(vOd@z8_1jicmv&(B8rQr88TC zwdZcG)$0n^Hq6c~(no(%m^9s=uTOc=esAb}XR^VNFxQu9OY!5x-6G$SWQbkGSz=*Y z6!?4kGS&|-LncRB!R*2Z#QDwVTvfAp^PE)mOhvJu+5nn)J?uY|Y#W&T!0(fOX<20k zSS>mIBd$Jh`=lSxBi!Ge@e6XuR??gyl#mhaQslCsi$I62%0znvQ3_Q4C%yiY4_w)AJynX_(SpIo&5*5 zuJg_7z=a^?c*2NfST3Ty zz>Dfnxxv(EbQW#MfJD_4gfzpdeL5n#uusA2qbxPb8wDd{K1!rtFG6~qwzPC?tlX$q zDS#zAi;`p0M_W5(5y!HGy^2DuQyXY0=OFh8(<=?~2ust-)6&W>%$b^haXOXYX&Kj+P>7RPj5xFva7d9tqzzkXkGd18re@WLx*MI|?dk0md8 zaPL5yO>U@et)AXKosZ7_R_pw$%8J)?gjQuh_*I;{jCt#(R?45Q5vSy71(czXqVm zr~>{W*Xs7^bnq95Nhd+b*g%>|I9Ds=XpaNl7$9mbK)DJnAfIGt22BE}FF>f}bV>9+R zYUiLRxWa%uP0bQ>ah)|(A*NZf>WdiUZ1~}Lzr8*&=uNbgms_JU;zKDlP7IeqOX(CG znyKuaPHzJs{0+hYRI(Qx=wTTc8{!p!ys!&Ej^K0q!5knV1}Rw#R0#&CH+%(^2aB;P zrlDcmZT(VHabsm;V6DFYwrvd!F;zy(_)nQ(u|oc06b)U*PRr^q**)(hghsoz=xf9KeN1C;PJI6N2f z$gI9<$wKo8m@G_z9t|(c0LQ}>g^$fFq*Rm|XxyL)&`jd7VF!W!LMG}lSZ$J?%`yt+ zygSYpvvL>C$z&{Z&VqcuwB?R0G&a+iU|Ii$G(UevEMu`V@?jjBms#SUUp-@u{Fcy| z+d$C`xsAfxKdubf4Wu@xnE9X%&N+uY4;NbV=Tez-=ND$=9Xqx%hYytEi_

5q!RY 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 z zq1_(l4SX+ekaM;bY|YgEqv2RAEE}e-Im8<@oEZ?Z81Y?3(z-@nRbq?!xD9Hyn|7Gx z-NUw`yOor_DJLC1aqkf2(!i=2$ULNfg|s8bV^xB!_rY+bHA;KsWR@aB=!7n&LJq(} z!pqD3Wkvo-Goy zx1edGgnc}u5V8cw&nvWyWU+wXqwinB#x7(uc>H44lXZQkk*w_q#i2O!s_A?a*?`Rx zoZW6Qtj)L1T^4kDeD7;%G5dS816OPqAqPx~(_-jZ`bo-MR_kd&sJv{A^ zs@18qv!kD;U z5Evv$C*bD~m z+x@>Oo>;7%QCxfp-rOkNgx4j-(o*e5`6lW^X^{qpQo~SMWD`Gxyv6)+k)c@o6j`Yd z8c&XSiYbcmoCKe+82}>^CPM+?p@o&i(J*j0zsk}!P?!W%T5`ppk%)?&GxA`%4>0VX zKu?YB6Z)hFtj@u-icb&t5A1}BX!;~SqG5ARpVB>FEWPLW+C+QOf~G-Jj0r`0D6|0w zQUs5sE6PYc)!HWi))NeRvSZB3kWIW|R^A%RfamB2jCbVX(Fn>y%#b1W%}W%qc)XVrwuvM!>Qur!Ooy2`n@?qMe3$`F2vx z9<=L}wP7@diWhCYTD?x)LZ>F6F?z8naL18P%1T9&P_d4p;u=(XW1LO3-< z`{|5@&Y=}7sx3t1Zs zr9ZBmp}YpHLq7lwu?CXL8$Q65$Q29AlDCBJSxu5;p0({^4skD z+4se#9)xg8qnEh|WnPdgQ&+te7@`9WlzAwMit$Julp+d80n+VM1JxwqS5H6*MPKA` zlJ*Z77B;K~;4JkO5eq(@D}tezez*w6g3ZSn?J1d9Z~&MKbf=b6F9;8H22TxRl%y1r z<-6(lJiLAw>r^-=F-AIEd1y|Aq2MggNo&>7Ln)S~iAF1;-4`A*9KlL*vleLO3vhEd(@RsIWp~O@>N4p91SI zb~+*jP?8B~MwmI0W$>ksF8DC*2y8K0o#te?D$z8nrfK{|B1L^TR5hlugr|o=-;>Yn zmL6Yt=NZ2%cAsysPA)D^gkz2Vvh|Z9RJdoH$L$+6a^|>UO=3fBBH0UidA&_JQz9K~ zuo1Z_(cB7CiQ}4loOL3DsdC<+wYysw@&UMl21+LY-(z=6j8fu5%ZQg-z6Bor^M}LX z9hxH}aVC%rodtoGcTh)zEd=yDfCu5mE)qIjw~K+zwn&5c!L-N+E=kwxVEewN#vvx2WGCf^;C9^mmTlYc*kz$NUdQ=gDzLmf z!LXG7{N$Mi3n}?5L&f9TlCzzrgGR*6>MhWBR=lS)qP$&OMAQ2 z`$23{zM%a@9EPdjV|Y1zVVGf?mINO)i-q6;_Ev|n_JQ^Zy&BnUgV>NbY9xba1DlY@ zrg$_Kn?+^_+4V4^xS94tX2oLKAEiuU0<2S#v$WSDt0P^A+d-+M?XlR**u_Xdre&aY zNi~zJk9aLQUqaFZxCNRmu*wnxB_u*M6V0xVCtBhtpGUK)#Dob6DWm-n^~Vy)m~?Yg zO0^+v~`x6Vqtjl4I5;=^o2jyOb~m+ER;lNwO$iN ziH4vk>E`OTRx~v#B|ifef|ceH)%hgqOy|#f=Q|VlN6i{!0CRndN~x8wS6Ppqq7NSH zO5hX{k5T{4ib@&8t)u=V9nY+2RC^75jU%TRix}FDTB%>t;5jpNRv;(KB|%{AI7Jc= zd%t9-AjNUAs?8m40SLOhrjbC_yZoznU$(rnT2);Rr`2e6$k!zwlz!d|sZ3%x@$Nw? zVn?i%t!J+9SF@^ zO&TGun2&?VIygfH5ePk|!e&G3Zm-GUP(imiWzZu$9JU)Wot`}*RHV<-)vUhc6J6{w&PQIaSZ_N<(d>`C$yo#Ly&0Sr5gCkDY(4f@fY5!fLe57sH54#FF4 zg&hda`KjtJ8cTzz;DwFa#{$!}j~g$9zqFBC@To^}i#`b~xhU;p{x{^f1krbEFNqV^ zEq5c!C5XT0o_q{%p&0F@!I;9ejbs#P4q?R!i$?vl3~|GSyq4@q#3=wgsz+zkrIB<< z=HMWEBz?z??GvvT54YsDSnRLcEf!n>^0eKf4(CIT{qs4y$7_4e=JoIkq%~H9$z-r* zZ?`xgwL+DNAJE`VB;S+w#NvBT{3;}{CD&@Ig*Ka2Acx)2Qx zL)V#$n@%vf1Zzms4Th~fS|(DKDT`?BKfX3tkCBvKZLg^hUh|_Gz8?%#d(ANnY`5U1 zo;qjq=5tn!OQ*-JqA&iG-Tg#6Ka|O64eceRrSgggD%%QBX$t=6?hPEK2|lL1{?|>I^Toc>rQU7a_`RSM^EPVl{_&OG-P;|z0?v{3o#pkl zC6Y;&J7;#5N#+H2J-4RqiSK^rj<_Z6t%?`N$A_FUESt{TcayIew5oWi=jxT*aPIP6 z?MG`?k5p%-x>D73irru{R?lu7<54DCT9Q}%=4%@wZij4+M=fzzz`SJ3I%*#AikLUh zn>k=5%IKUP4TrvZ!A{&Oh;BR}6r3t3cpzS(&|cEe&e{MQby|1#X`?17e9?|=i`sPG zL|OOsh`j@PD4sc6&Y3rT`r?-EH0QPR*IobE@_fkB8*(886ZkjkcO{K8Sz$H`^D-8P zjKG9G9A`O!>|!ivAeteRVIcyIGa#O<6I$^O7}9&*8mHd@Gw!WDU*@;*L;SYvlV#p( zzFSsPw&^UdyxO}%i)W8$@f}|84*mz&i2q@SlzMOd%B!BHOJ<(FYUTR(Ui$DuX>?85 zcdzl5m3hzFr2S@c_20C2x&N)|$<=RhzxI!}NN+yS16X^(_mtqY)g*Q%Fux5}bP3q$ zxQD|TB{+4C1gL>zI>g~-ajKMb{2s_cFhN2(I(q^X!$H(GFxpc6oCV9#maj|OhFZaI z;umX6E*fQVTQ@lyZauuv>%E)5z-?zQZne18V5A}}JEQmCz>7^h0r)!zhinBG6 zMQghGt!Do5h%HmAQl~%m+!pr-&wlrcwW;qw)S$6*f}ZvXd;cHw=xm|y~mHbT3yX>?hoYKfy--h+6w9%@_4ukf0Et^zr-DbPwFdyj0VJHi}4bqRetSNR`DoWd( z(%n5>8MQl+>3SeL-DB@IaM{NDwd{{v_HMIO)PKO}v{{##c@ihB0w$aaPTSP4^>n3Z zC8Il%(3dCLLX$-|SwWx1u7KVztXpzNhrOZQ78c$jd{B9lqsNHLr*9h;N9$i+vsrM1 zKzLB_gVdMCfxceejpIZat!MbR)GNZ%^n|fEQo?Xtq#Qa_gEWKTFxSL4b{g}kJNd{QcoQ}HUP-A)Rq;U(***IA*V_0B5mr}Xp$q{YSYs-b2q~DHh z?+muRGn~std!VXuT>P9TL_8Km9G{doqRb-W0B&%d> z^3@hs6y5jaEq%P}dmr(8=f}x~^ z*{I{tkBgYk@Td|Z{csd23pziZlPYt2RJW7D_C#&)OONEWyN`I19_cM;`Aa=y_)ldH z^co(O-xWIN0{y|@?wx@Y!MeVg3Ln%4ORu5~Dl6$h>AGSXrK3!pH%cpM?D|6#*6+A# zlsj;J0_~^?DHIceRC~0iMq)SJ&?R&if{fsdIb>y;H@M4AE`z8~dvz)(e}BqUWK^U~ zFy`PX+z*Bmv9VxAN;%CvMk(#kGBEMP;a-GgGZf~r$(ei(%yGqHa2dS3hxdTT!r>La zUrW2dCTZ!SjD_D(?9$SK02e_#ZOxdAhO%hgVhq54U=2$Hm+1^O^nH<>wS|&<)2TtD zN_MN@O>?A@_&l;U)*GY*5F_a~cgQb_3p`#77ax1iRxIx!r0HkDnA2G*{l|*}g_yI% zZdHt2`Hx^MA#VH7@BEN68Y_;sAcCNgCY7S&dcQsp*$+uW7Dm@$Vl7!YA^51bi} z*Vy8uTj{neIhIL|PhditfC1Jeub(uy}w|wV5 zsQz)04y;BY2$7U4$~P{k)b`hZb>gv1RkD)L#g~$*N^1N1GfNMS)4r|pT*V<&KE1M9 zTh}rzSW#Kcci_#(^qf0gTW3&QN&zsW%VAQ+AZ%-3?E)kMdgL)kY~@mC>l?RH28u;Y zt-@_u^5(W>mDdtqoe){#t;3NA7c@{WoY9bYFNoq+sj&ru;Z`x>4ddY0y*`HRtHFEN% z@mFkp=x0C6zDGgA0s|mP^WNEwE4O}S?%DOtce3At%?ThxRp@`zCH6MyzM)dA9C7IP zI}t;YUV(Jcnw$4LoD4H(EM#!{L-Z|&fhNYnBlKcQ$UScR#HH>scYBTf2u|7Fd8q$R zy5Cbt=Pvf^e}m4?VVL@#Pi3z*q-Q0MG8pGTcbS|eeW%R5bRzKsHSH#G(#$9hj9}0O7lXsC zbZ7#UjJM^FcvdKK3MOEl+Pb-93Px}F$ID&jcvZdJ{d(D)x|*`=vi%1hdg(dd-1E>& zoB4U&a${9!xyxoT%$7gFp{M<_q z9oVnk*Dcp$k#jA#7-pZbXd=L8nDhe<*t_*%gj^Vx>(~KyEY~i&(?@R~L_e^txnUyh z64-dU=Lc;eQ}vPX;g{GitTVZben7||wttapene^dB|oSGB~tmAGqE^`1Jxt$4uXUL zz5?7GEqvmLa{#mgN6la^gYO#}`eXyUJ)lFyTO8*iL~P z$A`A_X^V#!SJyU8Dl%J*6&s9;Jl54CiyfA`ExxmjrZ1P8E%rJ7hFCFo6%{5mRa|LY zk^x76W8M0tQBa1Q(&L`|!e zrczv>+#&b2bt zuD1Bfoe>oW0&!ju$-LI)$URptI!inJ^Dz|<@S1hk+!(n2PWfi-AMb5*F03&_^29MB zgJP7yn#Fw4n&Rod*>LlF+qPx5ZT$80;+m*0X5ffa3d-;F72#5un;L$}RfmR5&xbOf(KNeD|gT1x6bw5t;~j}(oMHcSzkCgcpbd>5UN z7e8CV*di9kpyJAo1YyE9XtfV1Q8^?ViwrKgtK$H60 z%~xgAifVV#>j>4SN10>bP9OV9m`EA-H{bzMimEQ_3@VZH%@KZzjDu` zRCG*Ax6B^%%dyLs2Cw{bePFWM9750@SIoZoff4mJvyxIeIjeZ{tYpbmTk4_{wy!_uygk4J;wwSiK&OpZWguG$O082g z^a3rw)F1Q!*)rNy!Sqz9bk0u-kftk^q{FPl4N+eS@0p1= zhaBFdyShSMz97B%x3GE|Sst~8Le6+?q@g6HwE1hJ#X)o^?{1!x-m`LlQ+4%?^IPIo zHATgqrm-s`+6SW3LjHB>=Pp{i<6FE#j+sX(Vl-kJt6sug<4UG9SH_|( zOb(+Vn|4R4lc8pHa-japR|c0ZAN$KOvzss6bKW^uPM$I$8eTr{EMN2N%{Yrl{Z`Y^ zaQ`-S_6omm((Fih26~Bjf^W$wm1J`8N+(=0ET@KFDy;S%{mF@!2&1UMxk>jTk49;@ z*g#0?*iga;P7abx1bh^d3MoAy*XQp{Hl*t(buU@DamDmvcc;5}`ihM!mvm36|GqRu zn*3}UmnOSUai6mM*y&f#XmqyBo>b=dmra`8;%uC8_33-RpM6;x`Rrc0RM~y9>y~ry zVnGanZLDD_lC%6!F%Jzk##j%?nW>JEaJ#U89t`?mGJS_kO5+5U1Gh;Lb3`{w<-DW; z;USPAm%*aQJ)UeYnLVb2V3MJ2vrxAZ@&#?W$vW)7$+L7~7HSzuF&0V95FC4H6Dy<( z!#o7mJKLMHTNn5)Lyn5l4oh2$s~VI~tlIjn09jE~8C#Ooei=J?K;D+-<8Cb>8RPx8 z-~O0ST{mOeXg+qjG~?}E8@JAo-j?OJjgF3nb^K5v>$yq#-Ybd8lM^jdru2WE-*V6W z>sL(7?%-Qu?&?wZNmmqdn?$FXlE!>2BAa^bWfD69lP0?L3kopYkc4>{m#H6t2dLIEE47|jcI$tEuWzwjmRgqBPkzk zM+(?6)=);W6q<2z95fHMDFKxbhPD-r0IjdX_3EH*BFL|t3))c7d~8v;{wU5p8nHUz9I?>l zVfn$bENo_I3JOh1^^ z+un~MSwCyixbj%C?y{G@G7mSZg_cf~&@djVX_vn8;IF&q?ESd=*AJHOJ(!-hbKPlb zYi-r+me!ezr_eCiQ&SetY;BocRokkbwr=ONGzW2U@X=AUvS^E9eM^w~aztd4h$Q&kF;6EJ1O*M7tJfFi}R1 z6X@asDjL5w+#QEKQE5V48#ASm?H7u5j%nDqi)iO@a1@F z*^R+bGpEOs#pRx9CBZQ}#uQa|dCH5EW%a3Xv1;ye-}5|Yh4g~YH5gI1(b#B|6_ZI; zMkxwTjmkKoZIp~AqhXp+k&SSQ)9C=jCWTKCM?(&MUHex;c3Knl(A%3UgJT_BEixIE zQh!;Q(J<0)C`q0-^|UdaGYzFqr^{vZR~Tk?jyY}gf@H+0RHkZ{OID|x;6>6+g)|BK zs6zLY0U>bcbRd6kU;cgkomCZdBSC8$a1H`pcu;XqH=5 z+$oO3i&T_WpcYnVu*lchi>wxt#iE!!bG#kzjIFqb)`s?|OclRAnzUyW5*Py!P@srDXI}&s2lVYf2ZCG`F`H-9;60 zb<=6weckNk=DC&Q6QxU*uJ9FkaT>}qb##eRS8n%qG`G9WrS>Xm+w)!AXSASfd%5fg z#fqxk(5L9@fM};~Gk^Sgb;7|krF-an$kIROPt4HLqq6+EL+62d@~4Hsy9nIU?=Ue4 zJ69;q+5+73nU|TQu}$>#v(M&Vx1RD=6Lu`d?>zHN?P7J&XWwsvwJt|rr?CZu+l>m4 zTi^VLh6Uu2s392u(5DLaM%)Dr$%h3hRB>V7a9XG`B{ZsWgh4IyTO9R~TAR^h^~>ko z(k|Hy#@bP}7OyN92TKE%qNZfyWL32p-BJf1{jj0QU0V`yj=tRospvSewxGxoC=C|N zve$zAMuSaiyY)QTk9!VmwUK&<#b2fxMl_DX|5x$dKH3>6sdYCQ9@c)^A-Rn9vG?s)0)lCR76kgoR>S;B=kl(v zzM}o+G41dh)%9=ezv$7*a9Mrb+S@13nK-B6D!%vy(}5dzbg$`-UUZJKa`_Z{*$rCu zga2G}o3dTHW|>+P_>c8UOm4Vk-ojaTeAg0-+<4#u-{>pGTYz(%ojZ`0e*nHo=)XZS zpp=$zi4|RBMGJDX{Db?>>fq71rX3t$122E;cJ(9elj+kBXs>3?(tq=s*PeL^<(M$8 zUl;u9e6|EP5Us-A>Lzvr+ln|?*}wt;+gUmd>%?@Wl@m%Qm{>Q0JqTcxtB`ROhd6TB z$VY<7t$^N6IC(s*Z@x2?Gi%eB8%(hYaC zKfY5M-9MeR-@5h zZ?V`qr%%FlPQlW5v_Bp^Q?^)S*%Y#Z$|{!Lpju=$s702T z(P}foXu(uuHN!cJRK*W-8=F*QlYB*zT#WI-SmQ_VYEgKw+>wHhm`ECQS`r3VKw`wi zxlcnn26L*U;F-BC9u{Csy#e%+2uD$He5?mc55)ot>1w`?lr$J zsrI^qGB@!5dglADaHlvWto@|S>kF5>#i#hCNXbp*ZkO$*%P-Sjf3Vc+tuFaJ-^|Ou zW8=}1TOlafUitnrTA2D0<3}&zZz^%y5+t2`Tk`vBI93FqU`W!zY;M%AUoN1V1-I2I zPTVFqaw3Pr-`5HcEFWuD?!8Ybw)Y>g7c0tt=soTHiEBxlY;RlQ`iYY-qdd94zWjyD zFcskM^S{_!E?f3mEh9waR7tb6G&yl%GW%e&Sc5i;y@N)U5ZFLcAsma^K?Cg^%d{PO z=SHQq4a|l`AakzEY;A{n6Rn1u`7v~#ufV*6GZ$`Ef)d2%6apsU6^>QJl0@U& zq|wIBlBAgf0j!YaozAgmhAy0uy;AjRA2%(!`#&e>`V` zg`MfSf5gWvJY#?8%&|`Aj0<@aZ;-q#tCx=-zkGE|_C4)TqKjr-SE6po?cX?Z^B%62 zdA!75;$my<*q)n@eB<^dfFGwRaWB25UL#~PNEV>F^c+e2Be*Df(-rIVBJo2o*an$1*1 zD$bsUC-BvObdmkKlhW<59G9{d=@bAu8a05VWCO=@_~oP=G3SmO91AK_F`#5 zwXLRVay<~JYok|rdQM-~C?dcq?Yfz_*)fIte zkE_g4CeLj1oza=9zH!s!4k%H@-n{6aB&Z;Cs8MK?#Jxl`?wD>^{fTL&eQHAQFtJ_% zNEfs|gGYh+39S{-@#MrPA!XpgWD;NLlne0-Vey1n0?=ww18{L)7G|$1kjI(sjs z@|alUMcx*04*>=BWHv_W-t=rCAy0q6&*;kW&ImkwWTe$lzHJRZJ{-{ zl-mK6+j}V`wobm^^B&2Tl?1r=yWbz;v-F<#y!(CT?-4K(($wWtmD631MN9?trDG zMI7;9U7|UsC;urLP%eH1h%U`LJxT3oM4=gpi%X@lpVR9N6Q(uhJ00RWXeL-Z*V(O8 zsIyyVUvf=RXLBKX`!peifjIMvMs1YT0n$0*B;K^yZf&HN8$N%e=EgOejqihLPBT|< zs)z`nNU}BOdT7wYLy}R10eXUksn9o)jG)&=qteGc|XNI~h5R6UBfaPeIHbA32@*>orZsCB4`Q79}A=z@najfekt-_eTg7a}Mcas^D1ELlN6(y28c{ur|tmueFvIDOQxXs1)_lKrA`L2-^^VNC#miFvO%l6w5uK2bFyu?hyNLCjTCNRRVW^i+GX``giwc&TpV~OHu(yN&o)r2$K$1kjh@>iP z^&`?sCk#?xdFX+ilAb(;I7<$BQ#6j*jKsu%LEhQKe=>ki^ZICepr3#_2#pE`32i4Z zu%eXsgL)3x3Q-^OPPRhm<^!TEPoek6?O^j+qLQ*~#TBw4Aq~M2>U{>{jfojVPADAi zurKpW{7Ii5yqy6_1iXw3$aa!GLn|$~cnvQnv7{LMIFn!&d6K=3kH8+e90Zq5K%6YfdLv}ZdQmTk7SZ7}>rJ9TW)6>NY{uEZ zY^9PI1UqUFm|h0Vqe60Ny=wCFBtKb zXtqOa3M?2OEN=zDX7z}2$Y{2@WJjr?N`auMDVG9kSH~FjfJRNfsR@yJQp4cQ8zaFkT4>5XQqSVt5c}`-A#Z=3-_mGZ^)Hqayei zhJ}wgZ5UDln%)!;Wz@u=m(6C_P@r9*IMPe7Db`CSqad3ky-5-EcG=*v8J&{RtLJ(E zw2h-ghGYcDtqj4Z^nU7ChgEXO0kox=oGaY;0EPqeW89T6htbZg4z!uU1hi;omVj+3 z0B%$+k$`oH5*SeoG`Ay&BAA%nAUjQxsMlNdq8%;SbEAPVC#qm!r7j75W=A)&a6)3% zdQq$fCN;@RqI!KPfl9l=vmBFSFpD1cAxb@~K-$ZIlIL3W}?#3+|2p{|vZVq`YA zMbx|Xl57kJVwoetAo+opiewCkCIO=uBLEaG+!0U$MRdReNsx>+PIJWN6dW)pfeZ(u zQ8ei-Ht69)ZV`qv=vmorhOkF)Squ;)8AUfh<7A_xI8FGHMRW>~%o`1Wt3|8IMrM%& z8)|@=#ssro9=f9HtN0F#O085{Bf6PJnurfzS_yg?qqszmnQIYDP{N=xqPfvl;VNsK^qpoy2&App~Fe(MB7KCI)$p1!&YEB&%$9gTk zmvlt?t7!>_paNt_fYJvw^~LCqX{4opLy!n)md7}<_s?`gytfSAdoScQWTy&Tbr&~( zg9myGVv)l|4-umFBL0)Y(d}Rvt11)(O4ij#zeao~K$vh~JDn0_@3RjP2M0|79T&9+ z?>Vx&M30Sb15&<{RtpeYUf|n7n5GHyc+-FtA=7H$p6Mh=&M0O!so)tze7#WT>pp|x zfWae>0++DfscU2%>|@oiCQj+6O827)1}KsN^a>NSI*4?#ylfG-{q?3MMXX$dUH^S6Ni=Ve1d0(janpz@WqGJ?cG&sewpq294Qa zL{huwuoARdt5F4Dbh#?<2ruzSS{VeDAOtY+52t^xJW=!(0f3P&G3Cs^%~Q~~Wq{YA z!QrEk#>oXK{sc&Z7VB1_>fA1^#YyU1Ff<^9G(!V0!JW`n@EDdj$$2SVK6*7$!BvXP zmAC;h-W75(Nnzpro3CE9eV=~Lp7yS(vXnk@$g3{R`!(UG013==W*Hj{-*F!ujl+np%IX?E0*I&-K^u zY1z1I!`iOu+Ll`UtL|F6Vb?~vk=x9w6}eE^*<)O?pZQ#8YKE#b($x>w$3E*F0Kfk zfnyCo#zOpX1(P2yeHG@fP7}}~GB|&S27%6=@G^V=rmeTB$(w9rC6J@uQmcAMq zQ=Ce?Z0RkF_gu30<;5#jEW32il2?}$-6PZ?au16Y)?kUFy3L?ia1A@%S3G-M`{qn8 ze+|6jh0vqfkhdSb0MvIr!;;*AL}QX^gkc+q0RJ4i9IyOo+qAyHblI+$VuZ3UT7&iIG7640a)fe&>NOVU@xZ*YE`oy!JGMY%j}bGq!= z`R5xY(8TK&AH4b6WoKCo>lPh6vbfu1yYy02g^t9bDbexN!A`*$M5`u&}WqF?+*m?ZoW85&MFmXqQ1J{i;_Oz>3*#0?lWa zf?{tv`_JzP7D3x2gX&ICRn(aR$#>;ciH#pO?<*}!<}cYh_r{hb6*kkXSteV>l9n6i zwx63=u%!9MdE>@2X)3$YXh=DuRh~mN2bQFEH&_nHWfU{q+4=t07pt+Jfj90Or;6JX{BCQrE8bZe&wi3fwEXHRp zz8{VAmxsWU)3nT;;77X7@GCm7_fL1p_xKEG&6G~luO;Bc3ZIa?2b(*uH7qJ!es71c z{Buj4(;Jds$o78u<3df_2~DLq`e9*$SGmrR9p2OoVB5Q(KL3M{1>eq+;+lHK9N?xvyBPHni<#j$sZK{QrKEcdR9+eQD0V? zGPaq!#<-c#a>t4bt+R#Hu_|}dlIGeve@SR!d((u)Ga45+BuhHfA88G0cPrw>>(`ID zZ;aIyn|qmhuDXBthoW{J(WN+`Yud=y(wvd0rm&1*4>6?#8&)Fz z&@V=a0w4)F{^!&W_l6<5xg|-0F!~>aCALbeVsZTd*)M*^tr*!)O8w)mzKThWyQW@X zw%BFs5_@CIic5EPcTJu8=CmynV;``)3}gJ`Vl#VY_3Yib@P-KvBk_%!9OVu#8tG|Nc4I~A>8ch-~X%M@!>yk~ERI|QEcwzgI66IaaY>gx0~lm<@f z5-k^OY#SGC80Yr-tDRP(-FEJ{@_4LHsGJ=)PKZ@`eW75-r0ylN%0Q>&*M;@uZLdJ$ z)rw7Dt5ajr;P;~1P>jID!><(7R;w|Yf}qI&8klT?1dTfc@us5mKEe;qw;YKR(cp-D z6NmUMP8x7cM%~ytE@l*Mp^oN*mCF`gRNhw3gpO1PVi_^JzCJo>#mX(q+iJ(Ts$5=! z13b45gILEULS!=)SmZ{qsC1)$8-4eADGR?v z>~4k_SvdvPHAC}=4(!I^OLgQ@9EMDE7d$PvJbi+K%-HTh`P0#Ea|Jm6zj> z?R)(YWtZoIRx>AqzlG1UjT@6ba>yE z{Wf<5moh^-hu;ptAtPG}`h$4PWcOn>vy`#bH#Ss>OoAEE1gIbQwH#eG8+RHG0~TJ$ z>`C`c7KyM^gqsVNDXxT|1s;nTR&cCg6kd<-msrdE5Ofk=1BGDMlP2!93%0c@rg~4` zq)UFVW%s|`xb>;aR@L^*D>nkSLGNmM?cv)WzHZy3*>+*xAJSX;>))*XRT0r9<#zIpug(}{rSC9T$42@gb zy8eb6)~}wl<=or)2L}4T{vum>-g)QaKjtnp5fyd^;|BxHtx~2W^YbKq1HfB7@>Hw@U5)?b^H=uNOpli?w6O#~V`eG;`irLcC(&Uxz`L_Cl zS8r24e*U71o@dV6Soupo-}Ttu*Dk&EwY`h4KdY-k55DSqR&o7nufO)%>%s-Es^5Q_ z60#cReEy=$4|nW)bLh=|4bxW4j}A?qOle+wjn88oAeYb~!eA+EQ;8Ggp-UldAt$3M z7*E590amz>YB9L(z?Xx&?I37XYw?Os-t+05x6Z4vkzBE6-hrbB=GAB?p{DQXV4CKg zls@_wh*&XC<3R(CEZxg8*Y(6a>cIOq9Nss7{=UQ7Nv%O_WxSyBqnH{@(<>A&2on@z zn57W4Dh*E)o#rJ2#tyxV2;C5#rl8%%As$4qB=IbMt-z|jnWi>>7Ymq37;AW!6Y4nx z1Ogx#!WVdA92mEipgUxzy_?ddg|x)KOCyK)P5v@usc;0sN3{=0slt4CuwaxK@20eO zhdp~Z8iJ7GWrkq_-X`~(eBpthn9|`tZEUCIGiFpJjjxPVE9I)#z3Q$3tw`a69qxjuf+~ z*?v>d5~pcH-AQ~0)8PyIjumD^?SM8!Wb>KZoD7hOlc2nA0_(eG!in>}Ru}>6)>5 z@*}T`Hw{I^-?PS9>(#UFBQpW72* zsfj(2+_9@5x+57aN!`e`f(Mp_I(D>}p8)@&g^g+X1%d{ z%X5boE?hEoj0CiwTh9)#8^?~;|wgor_=Z1BI9_dI{ z&t*f95n?ZgZ5CnQa!v(p|JT?y0%KKgi`Smi9k5r!+!Mkz=&Z$%CFl;?AOzV`YBKrY z0#Y6~J6&dA=m>T@TYb8ukaV4z^Z?VX*MCKcp13-ye1*`gAj_Tm@r{fpm?K!U@Xg2AfndEo6jZN} z=XK0GRNXVLW2c?}B)rH^yR>u}b?|p(W$!TkQTAgu1AIG>MFfNchMQB_^-AQxRE$Th5-E_tBP@v(Cy|ojjP5LEU|JrM8 zVF5;$>Hl^jlHWDPChrTH(vh%bARyj5#TPb>omAs-)4zN z9?9(wybd0$Z5s+}Fiytv}-8U`IC<{6U2_NqEAkv;7lys5Qcq3EKt z0-!^Xy3idllgZ~qX^QTe=i*oGUCJNk>Y26?+9U(Ks|C81S{-v+6ebc`c(yibQbuB% zxM7mk>}dI-TfUi5Jqdu6b`4SqF)y5humuCaHhssdcR(jKf5ZGprx;Oe7VG#G6TA1+ z8oZLl<+ey(L+$Qsck^4fi{I|)p15MX73gHFUU!l${lN{)Ht_Wb%j#UE6cZ9}Wq^>+1wz z9TBA@%f~tby^0YWafmn&8Ppjn1Ng{d;S01WImtMzV<`!zU7;+8e-Xko>qM^OfOZ`Y zEZG#vcm>EGF??&G6+v(3l`X(xMn8ESv=@LdMfdcxFi%g1?0HDPG>blldR`OLlWN80 zz<$t+MM9%1K~JT@#aBZjOu9*G{W$u7cqTM|&a1)0wR8R^*r$<&AhuCq1Z{-aUhc5P zdyaaK{$P=Y6R{40FrWmLbDOCijqB(1PrKlnL)Tm|t=l}toVLAZOXJ*~-dx|_A&o65 zskcpT@bs+d@ia`f)t8ivl{(t%H?O?;=^s3O^GXqopx7E3kz06f^UQq<>gyNmo4Ij; zrOxuzn{WOqP75~PwPXC;3mZ#YW1xy&DEXsl~)u4`-v_{*B%R6xNH3* zJElz8@d#i4`#JV(ko%x;u{LMqLEEDmwD*(ccB9Wp;u*9I?=sC7g>%L{%$4m#zhbjm z)gK{LWQvE1>_yl|4T$nYKNVZ<)vza7FKU5*W~4)KNgN@;SA<9&ERxIfA&UZnB=r%N z5YD4fY$9Mkzy}!G+`KUy>3l(FSi1 zw)t)*w$E4#ZSxfm3cZLC(o3aQQ7uHk>_@fMTHoM0=quh%mfN6%{`O($pyzg0kPf=2 zjA%M7bRl4BhV5{{d4HbnTh`HM&YKw@N~47e7NFGr*9Yzi(7XQl-FJb4hPEKOC!K2x$nWy>8=PJYE)T$=Cqe(n*ChZE zklF{Ms}h0Jd|@o;Gz(~b;9d&c#0O^j{1?tF5dtMj9dG`|j0qZi^aF1r{<7KC5hZ`E zNX2nxJYEr@>u86|tPjTDet;fLn1R+IOm6&3b*}TOyNpIaid@W9c9!jIfiJOgK-aw=xb5Kpb)`E9x%CU82 zEQg_v`e+tWYClJHl=_EsSW?LZO3)o#ox(#2UW9|V7I8fYnz5fRtph`u)dywWL9}UV z*hdU9-BBK5G&}j~O6&dSdWDIpFX;&Or5wNbm^Y+A-x6(K$$Of6JTVl9n0gFY&=T5p zZX?pCxA&w{J)eDSfb?Zh*LT#AdiPlB;A%p|-`Aw6RP2mYTh zLmL~zM^VS0V@*4LkOEG~nQR)HyRB+;*KWli%QqKt&%16HWyMXRhtwdCgyoTm*5#itgp(Wap66 zyr-dgKgjl&t?JLMuw}!Boz)TOa2|37p^FAcPmxX0apWmfp$B1WF_@-dsK+?1F6~yY zEwi!-))Q_CbOP%?p%bx|=d^nLBig-_$e!nh19^Ps`s{SNq{nnW)V-qnz3y+Ipd7HS zsb}z%!+}y8izoy>Nyyj4m_br&8TGFcze#gP4?v*NEdl zzGBLM4qpvdu;5vCFi9^zXU;sW`>pPi|NFD# ze=$xI@7q9B4WPsw4CAO~UJ(S)s@u41E>#9D>!?=*N5m$%^0E` z<0RjkAj02TN9RLX3Js+GArg=Nu>E5z zPa!vMuMV06#7$1dLbwv+VGT(5V_&A~Uy3T^+|y~Q2>lA|=hZZ)ex%G`rhkN54C5gq z>w?qN=A+LgB0-@s{OJs7Da|z%dK)uDH4?m5Y=K(N5KWL)uqDxwBt>QmOk(h~1u6_s z>9x>G_+@bJhBQ;(Rr?20>Tjn}^Y`|rQvI3Ua5$aGq{HFf4BhwAFVk2oHNbk)hmAri zjQ_!g*-c^AKM>A@je&H)i1PsJ5929F<8bLXvONK4;-n6d;Zm7Q=G|k6Fp*AY!b1a`eoS*c zF413z6`x;!NZV1k5)sv;-Dqjt?t&|JLNGSA2yWhU-RYC^oiWI1+idw;6*>m1&Io`^iPgF6c$sN zw9j3KFYs@%*HNz1Jr?F^RiLV%@DyQ^Dnc1h&59pWKhD#AMQV~3k7}>c@gdw=dyRf5 zHGNU7bA_hHWUnI-9SXtjM~LT>U5!uS#{ zKSOhB>l^nUa&S8kEFoAUIDG}(Lr#|uJCGb%29Xr>1S4yk0d)9hoJ7#4xNbi?5Dt?N zBp45evje1L)A;&Smy9J8MJe@1#HwBFoYPv$=k%GOaq!kd58)tzBI~EkGG3Rqy>GOTce-p>jH0rb~c(K z1|9q=$3)Vdgcwyvy&>S3p(f~O;~?XK{)Kch&2!gs=%kNH#-Ee-i}S+a@DNWR(Xnv< zv7kIUUD(c?RS|JmPeXBC6cbxUl6qRxl;fFAiK%!>EzFa zJ$-mz?G%WqC+P-l!DLX&nfxzGAnLaFsOg^Vq~gaW2QQ<(qixj#J=;Y{m`?kHkfO)i zdxQ*`2Jr3iXdj4QE%|AlQ;|Wx~pKrr7xuNnTe=t-AO)iha6xDYpH}>yZ z+FD^H2VS0x4us;Wo_95^kElZ$>j2HW@wyeLi3i%Q28NXxQT7V1{iHY}Llc~!Dkv8* zM><6X$}-pv0N#?+N%W`5%}K0Is%8kCOC~LuR6+;gtHYPi9=dqUoin~Q^MhE;TSIe$6dEI=Xs(`oTlj_C-3c4KT+wJvpu4Kkn_RZVg5jE+RF`XNx?0xmaV~bW?v}wVTXn4{5 zO&2X+*pF%!%qu@3SLRk-npU5?`f_cV9;|pa#ktlD9VuvRx;TK+fWUv_$vC8-@TcO4 zN_-D6?7|-4!VWMEgQ}TUe(c3w4{eyxe8C5t7pS0MFe;X@U&B?sVDIGR;u>?mPyb2F zV5WLiQ2mX&1v=E#B`oe9yk4Y2^CFRk8*rV6k1!uW{m47&7E!m%(ANz&+ixrB^ng(;#RLHnX%tfsjJWM- zyBo5Of=eNl8*;gm`ozE0weGdP7~Iz5$$pI`$C5 z`U46T|8cnpt;J+VO?%~H_`Ph??bcn%Jzu`2`z~tc^PoA?r znJlfFuxIeRC?a>J?C!EC2Bn;dnhn3XeZ}sbjb-10*a7A?aS00$P{m0wm zO_v_`nJOwO*k6S$tHR@xmt`N`;fR%l>^^ZvbfRm}PUBtryK5pTwRdIZgj<#_irORP zr7I?yj7m&+KkD(;PKtLXmF-s9=>`j_AFjI$YN7_w1g7hD(md1~ysZj9;u_Y4i3Ssz zgRH~g_UH9AHR4A!67Z@2zch=Odh*4WzWc2=ekK0-ueW&=xy{z7Gz9CSbv}Pk+4ST# z#ZxnW&!Z1tS0A}`@LT_*wh{sv=f-Dy+2cPoUi{nzYTGjx)eit9s#G5^D0+(|iNBlJ zV$vUX35MrZ8K19VAN|i75_}Z#DO`R~MZQy~2$6gqOvN0Js%d70SzJm|ER&Jy5k>-I z!fh9^fC*zr22w0EG6&Uqo`eqC7_L8gi(#?!A>;y86ak0F7|oHQIhmW!15hHkZ(*|o zF+vd5r!A(imA-b0}qc4-&FS58}j>!?PW$SEg*;W8H~a^e%b?2`O8 z*`i%!x17FmIo=X;^83K2Y3Hja(b_rMns6%ts^>=(bA-9V<9O1I>564?R3a}v1yYtH z*l6T7AY0T66-95WtZgaP8(}|MBGlfNdh@=~Y1m!IA7($BPUtE`qT@h@;M3Hd z;_dtQw^?1x7-WaPK4XDxuqd5+qVz|PQlALGw|x}&MFa4RtVSK`(e|RtFN=u%s&M?) z7+HD3$diG_iYZuX{0ijc(*2C7cTX)p*3LRRtn3r@wq>%<@A9jY)yX*dv zSq7pIH0)jCA$)wa^7RfPVlWXzzoH}vzHmu4?W&f|zEC#fi<;dYS!Z*G+=!O(wLx7} zkfS~!6{@R-(Uw86L(mJl7`6&&tfKDx<)c+WIlqL)3pSX=7*`N5ysyr`8ap$bd^E3w89)ZgPiCBi|f{Ji^U)|AMCk%95n_gVk3|_XmE_Z6(keo8NCgI|@0sfZs3_s1} z$KK|ZCF;AE#cQiOrv*z^HWTBHM`H8Hwdx20FDq8lu^{(Q!@5s%Urrmi_ZX=7)j%7* z2x#|wO+pMI^e#2DpLkU+erWUorFxiNlu1s>XIg^5wIEm|joek2Rd2IsPtNkBRLQTFsnoh4v_<(`f@uV0I_G*I9RD+?L~j{1bx`#0ta zEeZiTNBzhh^|GEN+1vl7{w)Wm!`yhLKAuC&Ve`GhjRo0c|E^`tZXfkQW;&_kBLS|M z7!XYb?!E&&=u`h5Ld{_dyivFMQHW{aI!yVS7oS=ttZ_4U4sb{P=wmO6wCrO3g8Cir zRxN0ht{}^=kNOy`2fdgiLzr_8?$^fWMSdbcHb<)&+4+$`i%$>mB*aF7fv0tiFWhcK zRThLy0Mtx?A6Q34Vn$tJOcHkv?-ldg8_%9Jr8YX#=C;}%u*pWq^?L5VVi61EUkC^@ zTi3LAgna%bC9aB?Qos0?XlUZtnp9cISx)1AbGeO~JGb1<*DpHId@iRrT4e7+!$h07 zWDZ4FAXQ;*hdB%9)8U`#Aq1XW1`G)sm$Ol@ZCv2#2r5~I^BXuYJm%NgOkCQOAufat z)Mo2&C`TDc7EDz1sE;V{`=Bx<#5gYrDb+@@FE3>Yx=pZB79-7UjD-g%Z#qc&td6cl zI`S1u2Q2b!m^1LOg{LEV_eV*@cFW|i{!+a94itA#8 z2;?I%3?C8LQn5B+Ac|?$1Ejde^`AH_B}3`>#H=np*@XDR^y^=fZDd~Fz;wS>e@!M7JaPvv zPU?=U|2$6iw_+;&j{0oiARgl1!2p}_PMTg!Yxs?H%{HmJgU62_ghA}_;}{7x*brZc z@>!rSz|M}1YPdKizI;?B3~2O%LY`8A1SF;-m z+Oxu{+PYOU-V9O}bVd$T!;AU2M<2*KtciMEC29!H9V-u9ZUJ$M-4#Nb$5QVy@LP8HyfiyK->WR(e1g77J;isq@ zxu$>@C(@*mf}RY@L8hJXBrWMOEKDqt3i8iwFSwpR$W>G_j=iMN>(!1>S7GdmXt%UH zpfdn%XxP3S<>d1=1{yBn9c@?(YZkyNN1 zQx^M4-32#mo8SKR;r8t_CV3=RwbSNzS!Jbd%GS0L=qT*0!ERw05x~DzSsUKHYQ||Y zuwKD!+2nux!l3~g>0-F=;qnW{w$F|jqXuhZz#N`4WtzLDj_MYvu(*X@fb3G;s!oPE z?QMW|e7J7#=?C#3QWQRp-~(1;_=?J(Y^}oNmHRoN$^y4Pv2Z8cL)EmwWVNJh@>2ER z)el6y-IQ`!2h2{kx3}jwTf$_!N75)(mi|n=?Ylj_>QzqjfMiO67Wc4{rOcF4JS+{j z&z%duf1`r(U@ZlI{F=sZFnCGJv}cN<(cA|5AP8m+HUK z@vG9%#_zOu)ChxFSxmKsBSSO9XX%g4SU79e4=G!|Cgo(;VeA8dsRxIZ$Eqhj(brh0 z>Jh)P2`<<#u_i^?L>%2jxXAxZX%?<7l073C+~1p!t{Dj_9ZxL$sz|_G{C#{Hv@t=B zP}EsMr62u$;U#=d%MRJHCiNv=5OI3(_o-A=G_9B~AsrRui@pzUDE@tHg#6PmWEuT^ ziPt|@8=kjTNmkqdOlyJS!m{E9I87hqn;%9rT0<0-L99QeURoyK-&OxH^mcao3^t~WeS^K zH`XC|VCLo6*duA78O!ugN@5Elxkhd!CmdSX&*f=utfmDFD9PkBHMk3&aFB&)R8NL4 zD&i)OQLO z(Z_o2Zs~o#^$zu`{XU~$I{T&vAH3;ofJ*ZpJ&JR~s{J0}8cw}`t#a3NvWA?#tMY67 zLG}{Q{#6^CipQ$*V2|W$g2v->Y9+4=(K+K`;I4$BFUb9!Nrk0B*fL+v z_lcdO1uEs@|8I@xoKCB{68@q=)}90JCVF33Lb?M@bC5mog<2~vPXXzk7B$|75Lya& zL)t=%E&Pk`S-PznN<)4iAI;NU!@f0_V&wOND{4!~b@1&pAN$Goqzvq>;o=lr=43Xx{tUtEaN3B>CWZ)Uac%%Y9--wFCA~Ek7aAC_APm}b zpXAnlNOIF+;t%pPlAxIkvv1neXa8*XxNLX6ZDDR(+U5bi-=^>US$+3TyUFaf{gSPI z&A@*!TUbRQ-p-3$KUDc=Hp9j|c+t%)Z{KNid2DyGia&p6lgtpOkDeM{Qy=)H&22V` zFBRKM=Etf98a&;o2pD`R2ctkyWxz`aTDZXBjY52aOspy*2=?xDIZi>&&))8y?Pe*( zt;DkFm|`@cFI!Kx=wFn7fh&cqy-f1RZb2KRCK7JNBsApYHWk=M5J&|wBQOdb+2_^g z*;b(s3o^wX$sWZHhUhNh^+UU2+hPaWw)eN~kHy66akHOp4#cDm_4zDetK1Mqx+sR1`nMz9wwQP*hL>=&Kei3+FtV>|yg%{T(6f`N5BR!MdXj8xHG^3) zqCJiEswQF>ZLP}3Hs3ciKciD63}0Z^MFL6+`V473sGm^=U1^Mx3`Y|Mrl>H0pEcT6 zg^H5MH*WeRUNMs9VN5fcZQ=>}GHBs};LS}+P-y~P#IlYJ0P8ym@R(0L;jYe*1D4ll zwDy~vES0HtyCCI2411OeiC>SA#1wX;8DRXzVihdy^T9BjrZUmN_=b)~n*!R4%Wps~ zkbFH!%W;I*pJZ#8%)c_#RUtKlOksrV!Y3i%vh>?b076sjL-)-NtH_t7E8;OBZOPa@ zAofQ3jdT&<%k!kzaG)7qW3j4HcvQe1&&jd+f8}J3!f+>UDx7H_B8^6hA&r*!PDQ-B za5jys`+BVIUd>7lmgi)Y&fyh!`yosPQAwyIh?7D-h2#b7);pTpdfDrCm->#&W_JPe zRvi?=>OgitOs_62y`!|JbhXf5STOdjJDPjj*#EK7D|Q>bl1&L=hPkN@2)(QE#vP@l zt9uJeTG&n{WG78N)aYu19%#`y%8i44oVsSwNLRxgR6hF`tsw;8VRy)COB4`B4i4SsLAa4`Y(WRazi3X`Vv!fMiDilJX?r1a{9%U3-*f6J-iKJh{i^La~ z$yJ?ASG(MP>=IKImh$g9bD7xJqR}YghlfIHszUwEmoF2yQ`Xet0HgZCGNmYge2TvH z+d^IF=q3{GD`-m8K+R-7AdPA64e{l|c4AofbmD)4hUvwM1bw^%@mXLok{H%R#q;qz z+gU3h@JZH-G^8$-2?T_&a!E51(fhSa5Q$w^j>=mA9b7)O1^G1VKyM1v8fOAgDLfFwlSN7aDkBbh=1Vofi; z{_|sQ`!zOY>fWC264~Y0Y;ZbE!j3Cqv4wlfV?E8SiTe3tr;ceTaXo*JV!Oufp0KT} z!>xB&7aARQo9It=F0Wa;$5j)X(=fKBtv5LhYKFC6eJA)BwZ>zny85O7zI6@a-&ln8 zLF2LorHz$i{9dO!8mb#Jp?&t4L$8*9&!)KTkLxQVHBP8FA!bZwX zC$1xtlqa{pU|8*e#v_V+#E4OT zjwi(7(vGZ$V!mG>tD`=FtRvSqWZ9$*B?GPmVd1ek!0@{$s=gg&_gx>I&W_E$e<7Y+ z5K(_sDS$qH^8rKPSita&*B->#;u88_rMf;Axsguitwh`|=XF8(EVlU^L*PKbu#TN~ zwj8|9X*SENE}$egSAG|3#!^5By}_`$$?RM3+{=QMMid7b`V01GIvvI+&E63R2wQNp zn}sc$*2c&2oUL%!tO4~7wk4n)tpFT)D3<_3R0r=|=}&0KCf!VqIpm|jC(z<~qb-#Q zZxk@2wJZtt%hiN1;J9w_Hzt9B+S-HzVkb8@NIl-+0XLm`=_dDWyDqXB zn&w}0*`hmpYVLH;R9>jKpbgr%Tssmku7 zB4?i;DJ=yE$6)n>a-tiWd=_(RksK=Y6Abz5;b5mLI|>)(FA9o zGzACes-Q@1Vend}5C)iY7*G)}1M%Udge?eW(1HnSXri;yq(~2bXQq`x;Yrz#0k&ke zS%JGlk~lDWC_ny*-Pvc@4#dzy&@`+2PkV%% zOIv<3)+u>drFF184*~^AoZL$_J<;#J>d$8hF1HEz)8d7HT$%mI=(a%Fw_CitukY~T zzCPh-wvU#V(e-YoddEiUO$O~Gr_8a91@$Jc+rpZOpW6;!qTct6s-1GiRv51Kzn!ku z>d;8_q{~ie0yF5Z-59^#vLXATUx*cq!zD=G$XZeu&u5Te*HqWE4IIDJ=3 z;X=s*MnE=AeJ9|E8#P5YEW>Y3>i7+gy{D`72zWgEJ6_;p$$k1u>hqEMJ4WhXT+1`J z2UoHdw1-mEKE?MEYBN#+HGKNk5c-SiJgPNDBrxIO3hq2zQ?Q-Gzn`%I_?VYp&dv2M zvIvf0jiNBnpf1lm=3_A6ApuPS)>4!*8O26GMgpxwaM6T-up7}x$fShgk;qe5v^RIo z>TaB#z4r{2{wUbivuj#sL%^MIIAif88=Zo8VO`(VhtJ#lK)G7`AVbhecjuza-rrB| zo4s>x>$20;IoY}UyhY=kM#Bz+WZSjeUwYHVtw){{#_rt79ybJJr`6`3xa`^N&f)n! zT=yimh90T==dW``)l)vNIle^QUoEWPPd=w1q+I0(zj?aa4;5EaZaQsy5FJ4LeF}5{ z$zg##sP#GwKG2!Ph}IYe2=jqBViZeEZy;=DiXR5O3_2O25Y~Q9y=cg)D}9l1=&&Xw&3l?g{8))$`(k@{a1p3a{ens7utuI^2=vshxrlD-kY-br`D+hAM=))3(PZ zpyB3*357l{^D%K-(OTUkjEoJ4X>x<^UfmPAA7hlXG?QgK21ybCZk1lxS0Sifv<291 zEjcA#Q%-#E!a(4PJtQIWk)#atL{s*GU*JZt07Zc#S!1%fwV7fXkwZu$LI=?Jii9b& z9N7&))d3Vh8fPHy4GD@Ijl7yD&?%NGuJ_OccYXkIaDN7{Ux?ntALbeUyb?sbz03s# zLfJD@r)GcJGkZS!PFErpG3low5RJ#jCL63{qLHqyaMc*AVNejQp_b+{ucvHN$a_^~ zK+n|6Qz^l#n5WiWi;#UEURyWC?C}74{5m0i9bm^jS=(82np)-?!p5j&Hj8-6#y5q$ z-cZx{GVhaJT^!E3OK(B$?9)Oq;h*nmgonr@l}$~5ny#*74^BUz-dtT@>WZ;S_3r_} zQNaQi9BKB}jHzND-dA1Yeacj3_qnU%q4vw$L-Baogt=3ig3Ri*h;4T_HQn8u6~D8% zu3dIGR>z7KUO$}07IDA zm>ULZ#zLtQpB=zl`Xly=k@2w#_&57?*Xi!kJ;wQT>Y(diU_s7c9> zJt9NLo6(QTdY?<&%(7s~gGuhxX6Ia@TxNd)1c%NSn z1vg!?!9F%t+BbteRT}T^ikFtgySn40Y{9CQ#s-^l6%*Z|a#r=PT|QRt>uzZ1KDuU2 z_UG&)_39e07-r|Hmy8d@CawADtYBN~ud`dnC6l4WwkC7cwB?%@#G0C73m(O(B@{A= zKYo4MwAZI+m;dFW_8z_0tM6&w{t;apJRSqCB|8-3|G^xy4{cteem4EFg?KyO^H>jM zvPiWhJ7a++c1XQBBKT_Aev;X1adZCx?O6i7i}=MPVM!{DFhM1no>Vgi=FJObSSzE4 z!cz06q4?jt9&?tl`>Ym||8Lbn@fQ|L_G8v#F`IpVs|l!&x&>B}_z$1B(XGyIsHAWY znA8qOJ=@^)4xPoaU-h^g^}_jK@kTQ7$?aFf|5I6D)sIC2%qiC(coF8shYu$ie*)ue ze%G2{U`NRIn<&=&^cNmI;H`MZjd~?#3I1s@KF{obqiu%g9@l{o^DS=Z{*u!j)-EktzHk%L~ zUeueNeuutfbuxAHnCfe9zB#!P8?xVF){CM-QK}``94{Bxq4Q=lI*@*(t$ z0*llTSuC3*FY_i0Esz=DU(#!`f?@wi{if=Z>r@~3asMrB8H6RvvkTcW)vbP8ZeWX4 zzxps+&i<@^TXl<*)K}C$u*vFs=c>O<uva_OepgZ3^mp(p%~u)K{5Z{k!@f>W^5N zctHJ;`gb-C%!>u<(kED#4A{XPx$+SHa}?%+(O6P8P)JhxL-2PKS-#1p!TbB=d;5nL zMMOs=yP`{Yvn%^wn}ki9e$C!VtI_NeVz`$Lz%L_RchA@F7J^6AM{gFM+M7MOSKOPu ztXH`F#C^w(VO);r;56Hd1-i|6n#b*T>ceqoYd9adu&Oc+x`?PF5k{oi7$_HEV@K2z zymA4)N+`DI{|3bN<-4D@&N)YxIVoqR5q@8N=Kc5COtz?XZfomYb%y==nU^drYn>b!5Ctr?PZ$sZJGC4(Lx<*GmYK3@9};69v2?xCz*86!x1fq z9-^Oe{|eU+0lSwM-%%oRlZiDYBcsgabpN8BFSM>vThx{{TLd#395z2-=dkJ; zUPumj_0A`QOXa%S$dG#HKaV)PHrXJUqTZlMEURp*D&K#c?PX)`>TojQ>yzh(U5ggE z+}3v2ww-mQmrPrgHX82`E)7LZ#9*S)OrYMVHZ2*%Ix2 z-f6n^R()lg_{@W9puD-%bs!$vZY>)VYBn{#u=iUtgZ1U*4oibOw!C4kr;~&cIo+d? zul5rmlh}%uY=)i|^mJ>IyR&mweFZIu_7x~{W-C@zr5Q1cK^!y+OU~frPEZqXZ04#L0$|tY}D-NPT^J>z!>2 zLk;VdDSg7vTYSmLjc%I1lCVSm>+G7BEY6w@(XH|*G{ zSt~)o`-!M-5J4aV2N@%gOd!0FRFIBn|vW}Drt z-eWVGJOi3H9hf$!nudR8+Nmhg011-@!@NC3DA2QVhVsnWtq@_vVUsn7Lgo{)!})lf zHnxUxXX|Z}q6~&9Cutz=WXN1iJCP;&D8)pBPR#N=xfBTp2pd7-lFF5XXBc!;f}%nR z1Ca6zjC^CAo!5Zpsbiu(lgpE2dZaZQmR3Pl1Nu#$p&}HOO1KhD0hr0cDxiUoC%PDR zz2y;b(?1FUenyXAUfrc`fgeIi%?Q>s#3O>1`S`d7)!ab-ztxcdp zi(oNgfzqrSy+Qa-h~$kCFl>tV#u zT0yo>Sj8|%X=Z5eLYl_j3H$wFA3GlQ`NIC8!J3ZtWgQ*Tf>iySj%6K(I%;b=*zAUs z@a=8sq4nu=XBezD!_2jBtet7FSqQn zIF@m`p^X#2_+Y@)f(;Nc7NdxOl%T-$NRFKpzZ*Diiyv-9$byI~Y_VA7@fF$z4H|Dx5g*3@-my-zW{NS^+s=4LU=S;5ULvFYRU7E$thNp8*A(h3CX5s zqQ~5@=c+ot#VX*Ndavjg1ef4*RI#r4+51F`-Xy>#L9~eMYl6w8mrb%>5bZT?ljVD6 ztEdNv0*uOqR@o*xU>7I~%q&O{-x-#ny*Sp3}O21M?Rd(O98C84<|F{P!iYQi+&Y*nsLu5^Ihu$V)k)=GECZL$l#xZCMb z%xz~?w@;eYGR~3+M_}0ce(?P zl902^TxqD4$DQx-Ouql3YC)>Mv?0+^0b7X9MdejK@03cTh{%+U%}ktHqQF-^C6`xw zO``FD0}P~L0z_&PDjancf@m?ZGR0TUYN{lM-RfudpltLzU;yJ{R+GzQ*P|q&zCuzY zP@pguLKr`*Q*oFilK?v&y$CF+j-b`jSz!_lC6mW>m+2px;ND~mcq=BCmMTz-PuXY< zOa5z2j)rQ{(LTN*&~0=Yh5whf_W+NhI=_eaPTAgjUu|FYx>|LuiX}^yT;wh{;oiU% z_p&Z@Y`}m`FN5C~v?rUXJU2@qOB4H#QH{+~N5*}@@#Jm2%V%+B2D zcW!yhdC$u$WMz8Y@Q7Sm;An!nZCaUSSuojY3}>m>9D|bq{)XtxPsx!lnpMKJ$>l0=VE#0Q${LhbVQ?(avB~M5H(A<6VIs~Hmen|XCr57cj;wDg~y7PjIZR* zau8CZLCaPfRJMsKeNi~1P;*LSAkgMF^Q=afBekooDqXYIppZJ`(kv}2%`0n&8lEg` z4=C(+1ET{^|A%kM#z zXK7m|9Wcfc3=~;>1jcJfX#rU|Ppz!j;7pMyJxd%-z##=(QTY&BIZl!@lVSAb*KE2t zsC)F&?X{LH;g7;@GHGHi9oIy36f@s3g3 zRt#I$TBG}b-9;4UrV$&5Ij9vP)Y;Np6VLT3k-c!=P<<;z&y-p^C+_T2?PjhnuA3&) zZg_w4iMx50MTey|GHd-~Qvv|JOonzEpncEx-PZbcYu(#|MF)Yep>~>mY?NK)j*MDlofYp2?IA zdWFjqQYB^@4u{F4kONMK_E=?Xxs$LThk3UpU19S{Nzmr?e_{2qb`9sV2yanqH0d@5 zKGJp8aZ;((RpJ-E(g5Ey-P)#3bab(6W+bgQb9J5E$fs<9fcfNuxIvFo=h1Dgwcy+w zPuTU(HesXi2ZPm;XEiGog3BROSUdQwi5UwQ_J3+1m1G-UYluB@01JOMr|AGf`7CDG z0ig`8Ee4)kL6qbPGy~CNdwL7bt`jNhr{b~f<0Mqx@25+$lS$DH(Vxp|&m0t?&qQTw z7?k*9V*W>p{DU=}4O&dJVTtJY(^>`^lPL~F6O|IFf&j!DWck6E9}tqnNz(gl(B;1+U04#Mx7H@PM!jr;8}`p8X5AFzRgZ z`H&lBbVagpDgs^cAL}3%1zD$XOne$PNmH;OFF;TKQt?TS2u1Xly;A5E%X>i&LS8)c z94WDnS|omqYiN=XeK3B}x+|c@HmfZ(WQ<~YG9AvJ!q|jbd#I*5WUrl&T>ys=H|eYa z=2P;fwY|sZguD`qxdX)M>uI;{{E0Cl55B`!K{}wLHeN|4VH*YnBfJf$tm5E77<2U`gq>@HG1qNC7Hcyb!M;d687pf$B(PUZ=T|xM7)L(EmRVw z;~E{-q~ZvOOr2pdE3KGuy*wmJ%9P@R0*A2yuAhIFS3E2{e{lXEPa&La>y?-W>-8zjMwKGjQ$BzcAdCp)p^-It?U!LP5Hxpchm^Keq$?$57$5a!Z+()BJRD{ z6WgCQN}23z-^iC&TytVqsnMs6p-*RQ(ixw2F8vzfP=&GB|8F?{vwhrLatNCSGk0hY z#-0-r+MT6XGIxqGf<)4vq(!0^mfU%UhXXyCkz}3fmG;0s&`8l>X!W^JfDuz9HUo@{ zuuFqpp>Uv)!psk76{RqQDF$&!v^n_ECT`}V@{zZoqC)oA7_w~`M~N|5Q|_k zJ;Up>vyh*=Kjn%>HQJW}(v6${w!9Z%lq8ZlF>@K=Ek<&|IT4DB~B~Y_O;v9%9bdID;FI$4}a;O}@l!+Yy zZ67)fU;`NEa8WOT7DH7N_&*q17&?q>qwQXMcFgOOnF<0N*-^sEWbzzvC)kr_vv+i5 zgPm2{O*$B>IAd@{>+WUK><(pc@%$Y%QkK)@5Tn}4^Ln|tOsDsh=f>O`Mru?jc?N+S zjv9?oZ;e0J6*s%IG6n*@)S#6c137i!nnDgDIU_YINmjH(${tUCloc<{sdVK)q-C~s z^SX%F!SQCb+A?8SAq-ab;ILesL&}?2F1w-0Zdb;3_7dq1y_J`mAZv20%2Kk(?Wvhm z?BgJojYahs`X@A7)HA9Qm5P}EkW30FIDr{C1ON{u z1g5dIMr=}b5GjQLE~kiOEsekhAqGW;iWew{c8QDP()f-j!!>b}0<_?aiq6~yI>*3B zi`CdXW~Cg76+JS8SL=N!|F26HjVUaAW#N(;&=GruQ@h?1{-Ra%60++(*a{-;SN={& z3m*yJzP9zU)P6F#y&<2IYIRcSWv>_H=QF%ksji&bymFkwB+s?s!OWBD?KvFpwAYaF z6HB9tl5(fq9jdFlXQI1E?Q^gHxncuVOg#lH7*|HYd$Tnnm)HD6gV_v+Ekb4 zp_-m+TC}!*?8^M?Y`$XK{JN&qk1Sq6xYYg&+mlym)o2Awb#46$jTWSN#;OI(jOptu zaCbaIeUAorw`cR3Q9bDuE~l}?)pf9WSllS}RTN5{AmKP8TP%l##64O+ z<9w~)>KD$L^#-v&PKLdn&JjL-V;0%hPd@a%E}(nDen@49b&%5#O-QsX6;-7Ym_{)3 zVl37&u%3X?ma&!7b)K&CFgV2vcWds-QvlU}1h5qyxV^(mlpUfHjzhVqKa?A?iY8<~>_=ad! zk8dO`rvOwQj>Y9oP2*Ot9wKK_hBC~WVtf!r`yU%(p%oD8e+cg4QUi%h2a{}O5}EG* zZ-HLS&Y#FkWd<|*0G}o#4taLmE^k0-iGxUlg8Xl6I@jpH*%~?tx@JuRJn#pu1 z@%_I=rNM%Y&`YFTCG|8jY9=GAaO%H4EqhwG9gJlaZKg1oi{db>rau>VdE^b)^5%>b8}?cL9itw!Y(Bor%WpI?%Pj4J{j!bwjl?n=A z?##%PqWmuA8zS)5vCxk(#bC(9jFU0xQk5C=7R7TRzMFn&JpLe}gI6mL{C!MbWW0*I zJeV8RWO=t%FK{h(m362pOLR55=AN7W`u2&T{v&qlpQUo)8&gl^+xyG^_=H+E&E8{g zDtj>Tm&AiGOuNYD{?mSBc+fDm!jX{TQ=#IZQaQll|>^G`1^D^SV zM+ZBRqk?)b(96%pKAv6kG#;Gx_9RUJOrL=Ch#REmXQRXa?RfD@|1DZPOH<>K-+Z~L-ZeSdCe_=8y zv$DFgjbD+f$Xn5p?QtF#T$_pgT|@$@QGPJGo8D>TeAt8fg6onA*w0M>p@iDdM_^a=-IIAa==ijmLcDs$P+!j}iuEj;;q_SK-hF(6t&u*(3 zU!LE)pqCz!$h##W9aWv*rYjeIUm+JxEFjgC8ezyBN-_G-vS}?09R$E(jR6BMU5U^@ z(V0P0B}3^eADjeW+@$S6T2jX+!gXXQh=c{DMBthD%*Muwk`k2(;0!J{>|O2$aekt_pC0cNlWBQj*NqU$H3%h)ui z?qoV$6o>@NL$D;;M02ATJ{}%ng;dfcXd{fw1p6fDH854f8 zL_5c+rAD;odO-?4m`z)jE@0QsIP#m%s{3yxi%G|qJ9mC592Bk*4$?J5vvrf&4==v> zL*Z%RPT^^~#-wiB-EW#fR>F=Qt#Nm25b;_CbGzR|l<+O7jV3LT3y%tNHaS?@`}o41 zF$uNZFw7Y~77Aa>jb2bAph2cqyb2hF{`0@kc^4I@JroH*5@Ck{3%HA7J ze{=QfTZrXPG(~C3e0zG=<=@}#yeD$(it9e|@}t3Eyl(l}7SBEY4FhdhBIcb^!*gCl znFlPvfq4vU4akQLkM!yPH0F@Xp4CK5WGsrIY#-Z~%66Yny0cS6LL^vZ{#CoPf547v zDOQeSMJf?e5Ldtea!LXg_#yu@^rU^*gZ%^VuaIC)(1`K^c$#TLNtk$0pons6AR0!$ zLUWQKxeJ{spst%xMbvmTKy*u_|1@&<2(Jsb3$Ne98JRk3nUx!DJ=x2tx%A513Tb^+ z6{A$>`g952ZR_y#^#BMQ;Q?NEWr8Kwqc!wGt6zh&EFKrvp{{ zN~{S=Y!iu^0Jos91XK~^De&WAO?3BQ!NF<=uyq~mg=ar(~#oOa0#k@s$PSzc6DGpZY zT%MiJKfg1}p{soS^vIIw;22}*cuMOjV++=yo`T|dD%z@Ov!(S!t0^oRsA=_x^+YR- zRun2H5=~%|fM4gQs|vMD>7n5f8#?tsN@5RaH1W^l8V#@Kb6(2f^@31PSCF5~CtaD} zHvqx#ExV!o0Lk}Jze|zj2?JMi!xC>^ZcUbx|8oD`UrHT5QaV&bC3|pDTvIB|$&v2% z6%>eP4*a&})c8hn-$b+WaF^U1-Y9%4?aZpl@s?;DwsrU3yUt6`1&HKhr(r4L3qt&ZY~Ue$d;q9YOJv}hM+5p1Omb%T%HEakh-=S^t}!cIW|NCt zvYY;N*Q~sC1sQXeEuA^!svEU*$tdANv&&^(v#x9Tve5*SsoPZk-nva@m)o@7>0Un? z!Atj^ZD6Nk^lh>fKMh(sMon0&1|FKqIv6qslh=z6Ed%72Dy!IIOJsI&k(zNe{r5j` zk_^X6`ZxFWKTWP6!%seNfB&|pQNmWNqVSmX-rpQQ`2bN0Cje~8WfmX!`rCUhuDV6| z?tzm(+(*>4Rl?Uf)zvuzW2UIDP+k<|WI}{Ib%x>RC*r31(n%p}+BT+-9GkW+IrRJX zl4DHYwrN6EI=PMW4E<6fuero2mvA4UMJq5i)7)epXyn;=e>z3@9f-LGcf5hMl*Uci zj^i)l8w{96&a4mrQ~GllC9!c~%TH#{M$B;EW?N3ttH6-F_R*bkE z%xs+9eK>1JJlEyUi3|T4SYbBZx6y2}B_?h-TH3hruKPE(H$8SVQM-|~4Xr_@In|BW zVgnhInnHim#YFuiJF;qqG`&6hB@?p%o1y+ku}Y5rxPFzA>{ANaiBNe-q$cmhZ(g6f}5CD+Sf>5JC1{YNhE(3F0!pqbX3(RwM@_N|c zFzw=ol!l+B7sM0Mdy|AsMx{HQl(76 z$#hO*p?1?0eXP0O(<)bIWm(nM?>D&fvK;|!P?al}G1;T~4{9s&3~cWA(L?15m&fK{ z)~>Hj3O^K`+eU6-gO#NfAS4*o;1-7UNR|0&(@~!?n_WwQKqAZxwyrJL|JM&?c06U%ORPS!-dO@oAf`H*?OVR=v)~F4S5z zN+5)YCd&}E8gy1RrguKlTO10oX1m^K%4>6G=~)DM_>yi%EXJsGuk#kUP6`2@0mFH& z*Y7NFja4Y}-Gp?I88a-Qs4d@6Y3k4^;uG$8HkVZ>6{d2Ts(+j_*H>Op!RM>kkox{2 z;Rsw5Iu&f8xr|1}tTY4tlHM>@EiDGFo?bbl;~Fu({1Z6Pa>+DgRgwURk+FuLorv&p zv=R76sC6XM%S1>W=qad%1G_wM3Sh6nDM0zsc0|E!6pSFE;zY!kd0?&wr8l1tn`~l0 zKjN<7P2T10Tav&7>10G6STwUFdt$Ckoo6!J;)Qlku~Vxs*jOESa`jr1$`w?}mAukM zx|OzkuRpal^rsm`;TczAm!Ag(3+p`9y^Z2s;Xjy+&E`xnc2|LnIxpPt&XsPg6uUf-7ft7w~JT& zfw+4o-?d@ch@?j;51V6l_vA4*Mm!^38vC%}t2Q0LXa*LS0U5%JS+ZNQ2IGMa4z4Ku z1XMXlM4({XWT3mXmejMX4KfvQpFUQG=p6zh1P(#hx0TaeK{z8y&FKjo3kEhe;iDcE zfcF9NrmRd+z#75I#zyOzI${$C4z8egkGJ98@%p80)mt99&dA=tEGF*_>L9oaR=CWYsR-P*G_o6S+z$z#(P~a{(6#ymX0~h z+zw|!lNvkPaUB%ja-FB?(Fv**Bgd~HFZW*OO%_;My4Q{$zEnTq*A43HRN?uNFg=hl z(mS>Jp)!boM~Ci|rMz6Z8QFl};xW z+VC;%K?kAOOY{Zm7ozQ4hK7!RFs`B9d6c9mQ-&9ZPv@IOdauhoi;5;SiiX_ zWHK;M)?aq=IP-A2oqKccL$m)pH~*+mz|;ySZZ3~)-BsluH|nc;xl+!#{ao9QcRBNG&Y@@wdtJbh8!GYyZ)Aw zzW!rQ{z;Ot{z+k{O^#r%wLyJLxwd z^XJOJx5eNf7|~5`*>4^z8HR_EXsbFq6_{Qh=&*U_cl%k zwM=iU2Q-PXbe70@^dA>Q@*j7JJAQ6|4-hly6bGu#Guf4I3#=NJmMq+jRMnDLMGTM8 z6FZqoQTr`j5OI0-s_>JgLyrB~1ISJSSW>S5iIM8Fd`kT8G)kmiG74kB5_qw%knBSo z@oyzBOWuPdb_$`9K7a)3Pq%~9W`D>*IUiM@0O!f@)4ww;cr6QD5gESP1B%!6;MicH!*-Y@P77+wB?U{(vm~ z0JN-bp*I7tds}$B|2Yv_ml9GUw621L=mG8zKA?tYOyL8Y$OA*gF20al| zE!BG;U}OpgXwsPQkfX7WgsEmUAWlI(Q%5G%c5JA@ zvU7cnaQC>*j%_XCf?T?a7#|JPH|92fQQw$ue`M)hN67HnNs*fMopiZ@%w_PtA1jc&hb32b{w#B}vxOro)&kk4QYrL#`LlzCOWDbu%nMm`flvZfG|KV$j$ z-FNRE&whE;GvWRhXt!eH;b*Q&eRI=I-{8}UJ`2g|xFh(1d6<`@`9woMA|kP%%i+S5 zK1F0WhSZW`Qt4EZc`V(MZsAXaeCedS(Vb5ELclEaS@QrmjTB5H)0hpPEE5EQNlSt? z21ITlh|EwEWF@giEs@COAQx(+_op}^iJXqHgKDa5asPlpLpVlbgj@6s?#6S zYL9`li=n^zx)AA&B=wJxE3xcTD*N=wh_LiAeKO-y5#$mc`A=Xw@xj(!AZfrCg?F2! z%%%|*5?(3e55O%Be>hdJWqz|Y>@NYc35+My#uxNsQ%rG0cZ281FRKs`l-S?BR7$Qh z-dVrO@Xl=E(CcZ!zjWz~bC~pbD^8Y^*o%J<{*O3DPI*%37d~UUCSH7g{XNT97LQ$? zYDwS3-Mc~fzXjb-ryofsKuafo;|MWb{O%5q#oGdD3s3+{Gu!C$mzxRqo(e`nj_uaPooI_7+V3f_n$&KXNEvegYzVOAmOI2;f z%Txl_vJgS~zx%NlOt`B5A1jvKoKv>6a#W5%cB9YQE}Ng#F-&RRe*ZmNFS`A= zffzY&T}2~NcH;d+T}$M2l)?WJg&c4iEkTi+0V>Z^9RNlas=*@uckms`6J|+}MwkVl zE*N-dTsD!&Rw6C9;`uACcs{*j*L;_2erJQvcU_02%bc~Ubv}FK!A+YVd~oxo2X_nq zIxLJ(Kec`BV~&r=1*4{GtdwIw_4r|;;(YY{D^5OnWS2C@x2K~s>682AHEryBn;yjZ z4?M8>3E?~8cUvB~Zsk;R?@dJv+4DFYRsX`H578avc%LRj22up7SnVaEaV$dP+@Mb2 zq4CIrhOkSI?M#gOW_%ee~$=YyOXUUtta- z@3Q5iMlTbdyK_ZVk=cxE)U2`ldFI@H5%zHXu&HYiR*LHY$S&l*@|^Pwk?pbS!QI|E{fuLT9l>Vn41g5I@&W>ri?f&GFo z2Mvui(Ha1iNH}VO&gaA?EjuED!@2g}wMSvNZckt@^ zbBcT{_aqY7%7ddWm!=M@i%rJXYvdmtmEHZ<%5=2wE#Ya?`{vOxdvUPHUc~Hq)u^&+ zVxd}piz@JUQn_L0+rqRxfv#aS1_Qa)SFTn?$r9m8tB0)&yDHj4Q)OzVO1NO^@T(S# zL(0QB&KiTUe&dAnr^5A~AR?Oh+sP8L@Ls*u%05spT>iM4%=WoC#%#@Vlnc)Y*M>(1 z%>k=bX=I0!#ZUiZtZ{s3P3^i(18oF$Y@`P&pb7q@ zvO&%Rinll&IO>Nvk;2BP83HY%nxOt@^RQ6}1388?OVhV+Wsgs0?25ERVP|+&EE0^` z9;D*zmtfJOHEx^cUSPX*CM%hFt8IaM+BUL@o;Mw^gE?}ONuG9OHsL}9goCExOl6k9 zcBF9hZPPbzo-Rz=Cbo417-4=XMb6q`w5^}k)dn8)rye-Nvy7(}Gh*3HgK@Lu%)3+n z3oI%!*v)_P(IJ#lCcqSZfges}9(VST_vZX!8Iyu_9WRljFOkeF&%DGjD#;zAuOeiL z)kL;tDxm*yaTD@D7Ic(j;`>P;SyBFLyqBneU^?`pM<(c}IK9OD2nZ!U*T9lL1{g;P zQHC5spChCsLWwhCBD+2mm(S2;iqgWTOcCcZWEYknl3hS(8+Jq-!Js3u!vGXFx%%`X z1GZyXL7}pT{gaax|rmpxnPf6C{R0 zTib|2S=j5#k%yaW)!9?dat0A=*X;8^v`SQ&KeDAp3DgrAcLuh@xA;PZBR zg`=d<4p03_tdo51mGomi;T*5W zBR30JjLniAk}JV|c8{b_@+!PN3ED$3pu<0a5gVJRMq0Nr)(md5j3YKqt%Cs={mM&V zt(QUujwTQ>MqnxgM4FbD0^omUM`j%X;ov|kMM@GAVteUvCTv*~XK!V8i8e-rGO=_w zoddypK}UkYEyU(oO|oKfA7hGR%Au_RIi%5mMX8P!NNn^DF#hO?MyUXe5YZ^CBuAyz zAaoLmQ4tEOMf%#4pPP{;jWHM)?Ifp@kt=LAg`7AKI~*z{W3ezw)pVPUQEMy~jk*Wh zTB*WpR!FsEi}0SsqLk?wqmj|el+#Tnl^ko>maAr>%xuC2=oZxEl4o@~9aI9XR%h1D z(rWcqJyENP-l}^|YjhfkRH_Dq0Csag*5}@Ne*Zr;M)&xhr-|1PuRQ|g&-ss8aV zHQ)cOM)PgI#`o!W$Vm6yr&5JrWzH40eATw{n%~Tk@(&l_f~OwphL< zCqVa}HZY$G%oj?XR`mrDRG?uJ%%7|Dde!ITbG2SC$p5Y}8a2z$XEq>ISjNkZ>1)ov zgE4B@ZHNjMe(1B_iMB^&AdI3IXEcx*Chj7 zB70ZAgoM~V!p$$OCVPKo`w;0RGhZ4!{v}p2VcgvrJjUJQ`tKgHL2`y{a5*?8l{pSS zVw`E_9ZV7@{DRZbcUGeBT!b+Rqb4RXao8LXXKXTqpXO606l_ghxNxwE%@d7RW#3 z3UEXjf7lI6*9ic+0Pae`^tPR>QL2SMsL3oEYnGOP$E&ou>S`~7xQVo(=)(GU4qQK3 zr?C@W$tk9f*D9E@M03cl(WrbDVpAIxG#Fl;5L{*BOWVj61YAL>qYM>lvf-j@87tpW z>ZJvtU!o^7M2?;aC>6H~*pz?_@A_f43oiSGu}SQ@oNif|jUiqc=UP!8 z=>_F32*pk3PFPZ*vcpA%CN-p;Wxmn4U-oTG7E0BO+K-oF$b+b15-I&yI4^>TevPA| z*`O%f1ySQ{Y5ZqvdO^$W`%*F%#Lt9hQ~Pdj5nk<{#WM`}1&EZna`}}EkJxL5;b(RK zf@)(^i_(k8hi0cS63J zs|Oki5QJx-ntFo~>>H%pY^E}xqM$b5MkoYvA@~kW?9WyLsNftU=J84%FU=uI1-qz& z1e^PwZW2CepU0^YenL2@YGH@)Zu1jQ{eo)vbm78VWF|Q$<=}w5W#K|%AkIaL_Q^~f zi|eTOp-#ROKBVnH#1e_)P3HY8s08{;dZ}0gP%Po!hLQr;BV~334uMWAl-Bd--#Lr4 zPP?Qdr)gAseNmTiQDw`*c6`PC1Bk z|3&YFAt(-S5J%N3gxme>D{!fPNgp+SjP6|uarzfLH$e)iK6*+D$1m-L*m8QjAGFH^ z!4#H29_}tYGe9>0-gpLnEkFNVf|O((Fhz0>mN{pkLJV{|+nAL!+nm@Nc5q(1;$0 zM^XlI4futW(0Z&+Dmx`;z%>=+F$`--08{c%b07caoO2rfcx&P4E_cI%*(-V`x`@j; zY3;gE`&aF}^~k{oo~)8NnyMR&zN(UV^8aqFW1e}|cCqmFEzbNRLwxxa?}InfKOla<+Aw3N@!C?SkfJo8^8o_ zI-fw6;_#rs8M>Q+4?{*lf6ip$gGD1_2)F*3nIb$OJoLNYv87o1MtGo;=rMVHc^Mg* zzJq)5cfvzNlfHv34fMZg$+Pso7znVXSU~|SIp>ji?}fH(>3^H-I{4m&4?q0ywD-t7 z&`*A`g)pImWS4M#Zu;G9Tl!s%h6&iR8RREo0+8h2rQ~oF4^Cf%UjrF-Vx~<}RSZ*I zE(2MIVn4)+wu!iV_&KCBJ7WozHtAvFJ})oAL?hICnfWHzmC33lUvkOkcX2xQWGg~> z@BaL}sp{L$pV2vjL?679*l!~z{`9L2m(0`GtD8C#ot^Q#F%1oEW0p0nz3W%&ub4Tl zv7>Bsdu8sZhQ_w8CH3p>X8H^MuC2*;raREK{(9zN$DD5BT3H_a=?1Nud0!pn*^pUZupA z00^Tj5tSm3ES7<&%$QX!=9c9_0)sU3X6E^ShyF8t!uA7Cb=}?d)XA@&a=V}EW*W(c zOu_RclPZ>-{Zx1NQ$Vf%1X5Uw9d3Fmy}|)ud-_SSfJENUoGgFpK<0AjCt1h|evE%Z z;>VXe18_1@Fu#N{v}Dy$lYcahh+FBgOa3nO3B5w!-!FNJjDG1I;T;eXh*@fdciwr4 zjDCtq-A8v`@^_NF?=`aGOWz0iLhnbEgMcy@d_;QkKk$7ipcWA}i23ZFsLEMr>E*^m zNiljMCxS`D0CtQRk`;cwZFtH2PC&AwZk-Esg4y{wTFw0ENVACmqI*lPKgx2}QEvCVye^Z; z7cdw4Cy!~hT58(tTvkqTwpOE+DP#Ggikowbz?sCpE1Y-gkZ|y`3z*$+64-JWdFkBM z*Ij#OYe`h^Gw4gVEuZc6IEwvFsdR;*#pxI9Sj47n+C_64wj)Xcy{3t;pT-^ zp1g)@-ZnI(|2o#{s+>8q(rfAp^75*M!p%o28Vqk=(~!6B6Rq}RU(=z=?xM1(WkubU zhnjpJYqg*F8xK`aD#}}&S2U^mP@|C3P(crm1S=Pk9!@{A(q$bR3U-;imDb8&gx;j0 z;T429XfFCd_&s7}e*eKm7kxl#5W7Zh_&9LS%OJK_PssaKWeGE7bk2mF(NjBbZ8CnPRDNY_y0vqvSTwEU)@I|E zO68Zv=36_MNF$?~kh8xcr^0{F%jpBc+=KqI8uz?&m(F%qRQMx)?AV_(LB-(KX^Hq` zc*ZkN%k29pbUyV*rbJ(s3^CW0uoy3ptf1(|FpOf9QHdS+wI<@yAcjwBu(VmQ6c=8m z6b?EH45R20DOnSoM;S*<`PnH@ znU-mbX3h<@cXoy%caE$qshO~gkdgW$q6rpc|}mM zfW4fn2@zHg?ak<`h$MyQiiQ`Lv=lS5hhmgJXsl0?YsZi4E)8$=c$QBnnXh9F&2c*$ zo}1qk)E{n2YI&bMPp&&}lpO)v=eQDNTY=41B&;b>thIE#&z#?7w)+at2l>OB;qvN; zop}qqD&bJPd~C*5L)|+2Gh=x(#-YO)hiLs$8|GplsgTtp7@+wT*fLZpU7J+vUEW}w38eItqmZNf`rIh|C45G*4gvtuv2ThuDXc4 z_`F(~o4xr#n>-TrA-kYAe{7|2#8J7Z{f-(gd;Ga>&c1)lWrqs;pUj`koHIS(pOU_D z^8LS$#%g*dRg)QD^LVnOJea-VNlv(W8>d}4abi{VBvc^g{(<%>=A~8;kSobx+W^dd z&`(FbE}}m!n<$swWH;yBxQ58)FmSG&`4)_se1oQtH6u;oagR#y4*UV% z$RlzEQQ?Bxx~KCmCdnIwnIbM2*apCK_K0`0o;qZC^gB zrnD~peLitnc+7HIOQfYaR@=5i$KjSiQ`sTL}ZLR4Z5zHCAtN>{bMsjN!6PEI-ku9@ESMg(;v}J0-^JMuS7w0b5 znX@cD7-?=8W)2tRaCYfAMyrX35sT!5f6!STjzv9;6_lBvK768%HD@<*NHttQXnIdk z?y7^F`IN{L?uU%rCUVHqK1zo@akLs-EoXkZnBZUz#7i_Tpn#3a5+TYeLYd_#dc{U1 z(h#`k#S*5uBs;gUF*loal*U~7`L0;$=f#;4=AN=BEs2&1-}$2Zg%57C1^v#VI#-t> zJzRMAY0~-3eWdazv*eQV6Mxve+y^*iS4kA#R|fn- zu&3e;qG3vLMn`=l-=NG{P!dW@q#yXDaL&2329-vr{@Uo%C`>lC=j2i0{4mP|q$wR{ zgn!v%CnO%Y0uBjp+Bjf5$TTk4KkHU)cFe@~QB_pz^SCGfJ*?JQKf0@!=#AcW;GQ7N zoi;maX8SBB zw0v&=GnX)%`~NoZ44HYcOdJ!a{DCi*(Pc}iWH`|I(H=k{g-Q{v<}ma?m=r%QWf!J} z8H0%E83q-u1cZqn?7c^L{#>B=FH!3BvbI-O&wt|5F=H-$V*bp7Etk-A)B;d}v8Z?J zB4WCFFCq`qCkDZL$3!R|>lU7)++0^}S32aEDj4OA`8fRuuF~3gDH32)EFsOzy=Bgl zbuV3)$8@b(Z6hmq6?u zdXVtQzxf91Fn&M9rzk%aFfXVsQ6;NGq(q#$=}<**)WJ{ZWib+A-;a)nqTVnf6_5cn z4t)>}4PzEXog;w~#$Z1ki{Lk<(qh}xw}&MofCb9!BjRB5?P=tIsR5L1!lWmvIA=!w|rhUdd}Y5$nj z@Zd2XuQLzdk4WtBzY3^hY>D1*R4J-QL@7{T4h1Gs&|F;1!b2qrcn-4Ri{yl`y@Yd0 z*^pzgBXmX3x!4)Jdgi9aQKc`rW~P=gL~>^9sMO=stc>u zp1E|DPH z1|+>G%%}<4&@;lb7~m`>2842kdFnKRX;3oaB^xJ=tNn^$zN#HJY2(KGHZfn-jm65O zv2|Y|sE=$MDk`P#+f=niuhp-qLb%_?NizMK%8mDJtX!j)P1?vF8!9)6SVmEIG{8bp z2aE9}WF=dHrxwk=qJ>vZKCOv%Yh zo)At7f2FjnBAx2PwiC{psVaa#f^a&N&m&A4FlmWM^^S9%ZFIKlfmIcYLA zle~cwab?#R3c6H?C69~O?j5+5(Ku}I{&=DcPF1X14!C@Ld06RKKXaA|hyZ9WLm+u1 zYU9HRsSL0LRFN&gn`8*8j+(;EIWTVc&J}Lr|J??}oqO%vFY7Pd{Y6}OUwA+M#qNvh zzMOllm$Y2A^8D}4UwIj6VU8R*BHYKNenP=LIsAo_?BrvlN&QmChJE`sbiAY%o;Ws{ zJ^8}+nDF|rXml9KiJ>Kc>Yu7U7@IPDQ1zHiY1R;GVYn5!>kiY=A@hYZ6D5!jXKm9F zjgDUbX@8jR^5dZ3&mH;m`~C4Uo)bA9>NwaLyc_};espuXotf1sT)&St6D)?TGRdDT zPCw<2Figb7ochV#|KTi>N(;hPVQX42l#brCNgD1 zvWp5s5{;f&-4$_d+2V?%|A$k^r5fdYhRjiF3}qc7I;+Crs?HH`C`>$a*KxQcE=)hS z=pzx^E@g3}=pCRZL~ZT#1ON~Xut5lx&eUcc*{uON08|U3d`6q&Pp<)B?F42E1NRRy zJM%GAHH^}96C?Sr?6UqhDb*1YaDnW1aE>TLszQtvMYxNSj>v)_3QAO@Im7ql1+=foE6>vkVT=e zML-E2DW}+g0qxjgNR(UI1)Cq(jDO_2P2H0>Z=T$}>HXxWlfN2Uojavei`8=j+%dd!-BCV*E({dFq=jrOQYQES*I7_41O!tkCj<#5M2QaG8ryvdqK7=gu9TZr8csspKTHAy4i_ol!q6 z<&!|m64QwpObHr;Z$XeC@yn?D)x@T*VtiL!l|DIvw7dzSd8F_dSYno+%Z(I9k_YJj zv|M0aC;$HDo7~;~Dq$pkFC_j<8=icM@OSfRWQ@v%95YffhmKT`I%QJSENWZSf?);l z!poo|oEX;_!8Rr%>f(a^n0^QrUm-z17`_DZ-=T;mxdE-G&1&Sa35xRsy&xnq5mJN0 zK!wb!qvfZ98jkQ>%^p&%D|XmjyV>G3!aoc_lNykvoS^23*1T~x2U{uIUmA95?=I9L z*Jlw~^}!~T5!peeSTkrd+Vf# zRppW?oSGxi$X>^L&`5?#8hsNQ=(QGe0tSE&-C`W$&(dQ$TdnBh+>We?VZv27Gv#S`x zZY2OyBt_P2SMC;6st1M5LWQvTL6yp|2gJf0<7BwUm3uT-o3rxrvdkMw@MpJCqwJhC zsZ*&j?k0Nqf?0WWb$PpuYUTD_yS6LUDAXx#+PCi}1wHVwKmF-3dLTu?Q9A&nV6oSo z@k-UhPdpYrmPL~F=$s-#*jh4}6K)VM{Y!r-HzX`A;+Gyg=WM=6{lGoW=DZ`R5fm3e zUJ!qT%nyqa{2SQ%$wGES$NUcb69&&849DX!S%_!9&{1|m^t$s{#zpXjSU!ThAZ`em zpMkBPEKH+)mURqx;F(k6X~?W8PDi4?A>1LBv62%KdYqIl(To)^r+k4rkHRibtuKrp z+A+}kFuI9BP}DF9=o3}v!~q124L~~#QGm2Yp#;K80}BN8x{HW(2&G>btrLYno+H9@ z35Jh4PFn1&B4`XL_{g>k=KW^r+_+su5K}zr`hwB#F1xI|d$y4oOH{&}z~X<*=X;n5 zfz3sWma*%`tr432PLpt_&gu7BDvm9EuOiIYq6=p1X{ncj7rFYuMO!}UiUBs)BTs*) z1o`Z5JrSoV`*u2pM+f-Tl<-D7;B|slWs{gddl4xwg@uU$RM2QL(h>#HgZf$A;YVLG zl0$wIQT7Opo4-^W&Ft;P9i#4#aYx_(jN}G|+H66>&7adGyzLmnne=3yCCIN}dz^55 z%q53NnLa4o_=l&E4%Pk62f{t%3gK|tBrIdDXQSypVUnQ#)ZYSK&Dbq7n*`JDF?m)27D?iLX(kMOA%T@ zfiG0Ffqf_p6^<=Uz=~9Qb}N=Wa;dfq39?xAiLF(tr0^|+?3lV+4bD}=FZvDP!*|ZV zleuo#==FO+)Lay)iB4#-+S-?Fy@|QJIIp+>9J{11)nNVZ*TGkL-3_oO9~YaG97`l8 z*{J|YePRu82%1q-h4#rUt33k4Y)Nlow(4E0rq3O23t7Bbe$|x$vS#+eW=Ftc^%IBu z#`5&R9&0=M)JgGTyx2DFr|X7BOXMQjAPG%>5=Me~z-OXC8J2#zo#gSvuEokmLq13>Ks;moLJ;z3yyYjIm? zg0+BGvYJ>*qa~#P6T$wBIE>PGX-G8vh!q|}3>8NeL~*NpU@c$^L@~tDK^DVraY>x& z?bc$O#cGkc2@KvrDU$WVlNFHR@nrPQ)cb{S2>N5OmC_7h^vhB+a6Q4DaVe_5(lU!# zw4+1&r_Wz*i%LbWS3HQz&{u#fCNW?^PSAZ(dZ*GecfnPx^t#xIhor9}Uia*q{^*2( zor4b~3k1>VM86!(%Z+PMc6V6DU}B5XdIGL@P}a@}*xZcN_4A&%c+8lK56{0owQc&0 z+cr&|vU&5AsnfR3n7%D_{rtmp-xKq$XXeNZGSNw8Bf?kHe2W-ikXB#O|-cKR7uZ5(TT(GVQ1;IKD*BA^?N;j z@0}ix!ATR1xOEQ{YHbdiSq;J%Z=uHSbC@*_zsJ8-uF;r^io9-jp=FLI67~A6TB9W( zn-kh*Q+vJO4pAtKQNPEeH5!aIo6)4#n%(}Fki*jDi6SSb_5z#QlcAS z@#%&1i23tyME{#Ci!?+UvreNCDv`Mgsb5hG8a^*#cNk6fiCMnPiX-Hp+aBztPl4Oh zyHn6D*0IHn$3DB=tiNbPC^UlpZ*J0?V|6jJJs@Q`rA}qn+Rc8tYS7vYi29IOYhBsd zuG*5FF<(~HWYziASy7zd5#-z)PSo2q#2&G$?fT0GFSTxP_hrrNTFu!t*=E!SBi0Cg z2=SRH$2YzncHm7u96A(;d=Z&(Qi-??nsK-hIGvf`4q1jA~oib#XKO7tb8)6w1$r@c;e$bb_`&F~Ni2jzvZn2Fw$ zz~B)d_)khjggJGS~kwcJ`S$EEhn$FG)b)C?Be?Rg4{?f);@1;dk*(~!#;TB_6ue~koujG{(Beh zUbt{KVXkcLp4__g$fK)QtXTahxoGr)j=G9-8WhCenK&*7rYIphp6F!0FZDa$cKI}A zbC$PH6CR9|P9~in$MVcdqgHQm<%JWmV76W(Ra?!jyjZd}yEEKSQq&abG|$;JC;bSc zi%r_Ko|C*fHU5MMZZ-d!_K;<@%9@Wx|6OFrky`ijgBLxNotf;yC;P z19KdM9L-wjp>Ck8BG5)h!T0r&0%+sf$hTN2Lv zkjxKXirD2~To#O4g3+K1RK6xdDPT%wEeGp9$`BglwrgN{jB|EL-iaRh)`YmW(^uJ7uLBa*m(&$7XGI-Ke zN;nA09{>_C7UNiom=;}hVi~*+tXPQjh2p-!$Alh2G7T7~LDWZk#B@Y`_||eS0j5c8 z+}MXS8)x<*jNC9-9f5cm&Im-bpfa@rDJ#}aeD&mfrlGy%ww*gk?W`wa$f&eubjT!agn2CWzTsF$9FQLv-MyCyzdwe%0(XgSv}M>Fy@F$&>plh^`XnrC<3lF=|wT zxwE#mprEjD7ST?yA%cmit*xpe>+d> ze4^cc(iT%F0-o}GzhxHDd0~0Nw%;391a(%WY$gC>p7cuGwE}l#_6uJTU3%q&Du-Sv z1BNQ6(xHc+GOV2wta51Ju2zM;w9pK?-$vo<7hb5Tx!}@jjIK(9#}tXZhOa3(4AZCt zeR8mWs=yNvM86y>IS;5hz*qP;0}qHi0D~PqBaSeil!iUQlCV3>8lbEi7?siLw38X7Ay0^wp7>Q~U9X90Kmz9u zGh;-Yf!@kam`UQaU~ zKC^g{E;aY>7jX`w7r}f$FY=D2T_qmcXkvb7<8v^QFe+0lBwIdIEMQiJi?iI}QvaG9 zFIlAGEc-(x;`Yw!xJj5VRhrI|!-jRvUkNW&`eTdRs$1-4wL%XTJcV-aZoPtMmT%{l z$~8)|v|`{C&B}j2h3Jt^>K>w12|Y-kXd!bQUbiuM2zE$ z5%+bOo?z+mdio*1I#~xKh1Nl9@bD{9rvijuq<*AxPY@W|#D%3Lf z|LDW95-oJ%uc7PzKjz*$Fsdr;AD?r})J$)wlbIwl6Vlsc5+KPWKp=z?2qjWO?+|(s zVdyBJ6hQ>RtcW5iifb1!x@%WfU2)a5#9eiDS6yFsbs@=IzMtn#5`yBo@BZFDewoaj z+wVE&p7WfiejXa4W`Z0o=tf#%Y#8W@tEJz+IKR>U~HRPH7}){FA_g z2@RTRpp84qzJ|6Tbl~m%2s1O8`iyqZ5(?E!d*MNCf_fBIp0pN>Y$)^p^{g6c-qdT) z2G|`q!rdp`_EOQ1xd-;oeZW1skI7UsOBvE8XfB>qbJ|9n@GEyp#)N$*zuR$;iHTMl zMb6o*mJJixJe)xE3Q6_4>)`+&0VYGZT=+r_+-_y*&qQ=9TDu^?KY|vD9{9zI3DK(5 zME=Du$arMS#9PPZ2`ya}-Oqi0SJ|R6){pAu>P}GuxC!H>S(E&)JRvc zK(%pLIt!%_Ggh;J!P3mN(C&zQ%b!{2zgdp>O3i+p(=nue_40cDaryCg10&jdx17tO z(^oG`_H-m)1cDqwb`64b;Smyx)_@t0hzGhdMCC4<9`|!TD8jm$rK?L{m%e7ES5xX| zjVv*(Fl`#N^Ymjk_TQ;du2gC}db*#$3;ZWOD(u{Xf?=5$H@|z8nKTK#24ycWnW{7M zAKQD&^LZK7DvgHE{3S1zo_>f1NH&P+M;%Csfl8EPu7x`aIkw>Sb*g?XAd3zsX^HUS z;UC1y6~<^aDLl9k{x&4~;8i-HtfOnX;mQ^KYx5>mteILiZ%SkHXs&4RwL5E-R@LO( zM6u}hNxwS1`A=KMZudb^r4d&kLjbo*jB_XUZm7xw()$Npp75WZModdD;0bDHwr`R1 z_{sVCpn^HUU7WwBZ2nzSn$~Q2(Y)xssf8Q^yiQfaGpCL)?csqTYl$*OC+Z@HVq^XB zOye(GF$~=Qgsvvqt>JX}F)?~g{W!WMD}jH~8i`yrp|6CFShk_1l1@(nOjnF*SpCVK zPZ>c(Klp(l_zKcZz|T@YCZ0yA0EZ^D{lW`$b84Z^U^;j-tpQBvB00=t(w>;jRGNw zHbmPcyBkeUMyN*Dp&<=!4Z*9_kr2sB-A2w*DIcMAtDSr>qu8;Cw5OT*sv9K9fcGOK zSm!4y(a2K=dfsK5;!ihJii?WuI$xqIGc`8d;YdoW%gL@wbJ?B#*wjo{qOWdT^k9m- zk==Ptc1~SdlEaZs=lt{%`6zA(m=DT}5dFZ2(yka(5~#H%rX*T@>g=_aAidv5RVz4Y)D3sGFSTS2r^}yJIAKH`4lg%ntx|R z@g|#cj@ugfX#OhfWp`jJqBtUbHkZ4DSHKDHin0O4ELt|2GH9gHaP!L}3}X%RMu9^v zuS(%Jt&VKN;Q3N&Y~gBXg}t%bWVW+k1Gq)5L#s5@ZkEsLIw^XNABqBodZ8Z+V-=0W zNfK@`WLS{B9Hl>p2R#J6Cms(mA4-IIVD5qlOg);Cpn%vztqY4NIw=`LQ{iB&^7#Wa z7a&uV)>V||WdnY{zt5auLkdb=`8s!>hE*dQPt81kI ziO)fk1BII*_SGJx{lTuOLY^sHz={3|Pb?n%Yie4$M&R<(ilKI}PV{R%0}AWba;7QM zlhO+kSbd)<)y`7?fZ^f#8IR88g^8yYJUP*(>zlFUnxzNtoZYl6N1f{El@=@+k}>b# z?4Dj;?9= zS6nw@ob*rWHR+$@M%;ibXjl5MM&Dm&83`?45etEsp3Zfah6&wn{SbZWiSl#g2s8QF z!b4X)kx8BIv0a|9d#)&qO#jKn1JeLSU&g}PO{iQL9$?_n`%N@9{Doli;kV#$3Nk1^ z#U4_1qX>;tNcxH3ovQtK_!)Q;noSJxssaap?qI9Elad>s5bi2j#ytCs3 za>OCS+>#mBw~`ecHs)WC{zzU^cx+5Je#R3lToHj6;g(tCOO%@6wkpq&GX4R1 zbtJ>0R7-sa=3topyX?tUg83mJE@(3F#$*?KY=Y=`;PXg{F}hsA=r60uXOmHR?c0m~v#F!u!V#*&AI! zFCAz1AzPG%yv`L)O!?wt1!(?ra)UJ3BIHo!{9Yy?_5{>Guyf`FChX$Fc_I zzkl<0r)IOI1!D?xv z|1Xy@#d)U%ppGeWtaJ{l2B)wBCoHNdN?uM*O~xylSFjm1X(4SGMWdi;NKxSuf(5t$ z(yq)xWA3qIH}GW;dPcJn8YKu5f;{oiO;wizg-JCFwS~i3j<8^y&6ATjN8`%xe@W3ZTPIsDF&xo?<=iJvK1bU>vQqQpAR2|98e;? zywn>Lli7c4!^k9)D%NBa68o3AL)UnD;d+hQ!;L5&d5@<^J+vey>4Buo;w7UeC9Ww; z>UC`7uuab)c08w7zw+VUfg^7(8}2hqI@xh>QPckSg{{)#cJ`ZoB^^z5>Wnx}rQ)|t zm9Bv?Y4QiD9p9(jwKLujJIq}-HB>Ae=~c1k&Xe~rE;Db4B|o4OT`5J0Rv@-mt!atz zj@X>-1Cp1zVgT55j#C)|HMfmO@q}V#n`2Twx+XYdZTw(Y`5GfTH>Yk!#zc-pZW=AdnU&ctSGLmPRA#Yl%*st2 zE5@3|99PQ)1!p??$QLg?_qS8cq3YGk^9J=x+wtQaLmvIzOJ(X93s+Gg81?GDFTVN4 zi)CtqLG-vQfkdF``vU)J8+thXfiD0dYXo1A1iUiY;}P;M1b7IG9)w;9FLlWY2N_j$6R}D_C#tuFLyR zQg?8Y>?h+f4n;=rDT>*O1&SreUa?-W86MDk6bIlb(X6-=xcVo7u>QE>DaBdEvx-;o zHejCOiI7E?piCY_R(m?>8YV(eH+fkc1o9v@DE}J~P!EEwJy^lDDl0jm&=M6(WjI1} zhsug1OnxZaJWem}2`>S^DmBPMa~QOGSg}|L3CHQ+J#ajM_k+p-7#qsBCaS65;S<0J2iW7)(J59wVcB6%k{?6%EJ!OsS@Utz_$(y8; zY_=t%V?5*DFrIlzZ{ki!YtM2>w{6Pe9$-Sq>~eHS?^dvtrb=lv8>;ST64@AOhk#MC zHzd7!sHq55P!v@j9C-9X0WZ0+LTk2bC|f@z1F_*7DLz zruI=vvH$QnNO|>oNZOsqiluu5BhEgp6xpgOR(aQlPoGxv0hs4a`qNCWlU_c;dVlqi zTDma!WiF=mlT6^9KFbP?yQEJ)%wpTyIW&YF?FBzULCQyRsUJR;KJU0*`iv#~`OnpC z4l-gG(E_)Pgd|FRRmT4(%sYi_RPEM6;$3%-Z%5%{n>c_iJhrLhpPL>N-gq#SBPHg9 zDzo{9P0z5IZB?7kp52`GFuR8^%q3e+zbL)g1bTBFEEJU4yBB)6py1I-C^!=N&1nNd zCbKBK(G8K1;))gUZ+7rVPAR3Vw7t$6-x$fJPaG&+8+m@w#PTMtSUR>8IWwlE8>A1U z(8^i-@18xi?eGFN_%(Z7r8sxBlq5ZS&Db~Cl-F;l9Je^~taR<5acm>kyS*=)&e>K> zn6*kON8)>1LFFjt>#TO+!OahJ(gx)D`j_ncOO%}4G{JPx7gXF@3{UmqLN~)yN9>Bc zpC>`rSsX-oGVPMHLph6`su_njt$XR&Kiz!upPqdwyjDEi%D68N9r}`S(*JBYcVz9o z&$k{p(E9wnYv-(faNH~R-S=Ja_ctH>=)vYCYu{Y{=JESp5mvRUOUK`Q^Y~KX!uq*$ z+wUr^XJ)0&pP$0-5Nl^v=I{ zJj$bjzVt*|k!cGIjUTvd6KyVeA${ty&7gHGB<#Q1y14zTyV}$4`fA-A?XMQk9G1;8 zp5EWF&#>*jJebfrN6kWh2{r0A9OgK6uv*5?N2oX#x;mx`pR@Uo*GrC8yA6OX273VP`NcBT5$Qr0j?G(M{{P7piqRt*) zN=el73s(VL`SV{oUT6>g%o)xA9Yvu3PritOk*PmT7!2X&#aO|Vk=pG~2a{1WGXR_p zgE>l4UMm$H7b0r$wzikJ{oJv(mqs9+QS`6EILDZbuS@=&Z5%$wIA;~Ut2=)?DwiM7V8y|a2de7gte_wyolz2Y5-{hoV zNoufec(7NxJ*CD7ZahunGQ>M#l7ayb)Ka^pQ*2}^2^dYOPAi<uj~;F1rK7F4-`>hvE3z-Vn_W?n%^t`Kao>fq*aO)WY&#u0N+&ig zJ}Q*7oyn@G$P)Y0@>jpY5>F&PG#&KoJ^YRX^+K*%Ss=<$$y_-}L{UXErgc(E5-&jp znr?_BbPwuI#L%IiL?tQGQxhLhEFNIO&2PPbbo8M$OJ>hnvg%;{q2Ii5`}B85i|$0V z!QOX<^!@rRpKN0Z=T@CRx@XJQI$o|_piwYoJ1MS+k z4@{;Nph^J0Rz&vw*R{6pWnO9y>5qG@xbr22mF}0)L#gr~)}4H_qp>6$<~$925GmFS z&0^K?9>3KCfKji9ml=9*)MPGa_6R~d<|%laTO_^BzGM?4)z`l!wMngf1bd$Dc#b>y zn)D5~h>eq4r8agA3&T>^5wi5Qbc9S$4}>iqA?)E5ky+fW9UZ(72IOS8<1gH;@(K&j zloXa+bBDra6BOoL3kUoHL_@>&^ECv-8f4FE#sp1A{n>?AMziib z$qd)|3UYAtV1Drc0u&k(6_1!N+06DIJd)YHfVjlPDl1-ccwBwGrPxwmkM*Bj&`JO9 zczs)T=dI|h&|7Ak>vWhY=o3EevYFqaC&{Tq z)3qak!8J0(ysUS8nYK5}M38q_I^SDc7B9UZ{n3JhIN{&iL_m^m`s*5hGQUi*X#Er` z6bg?OrWdP`5fltDi&4H2EUat@&_IR9LpUa5W4Rg%4tUpe(;Ger9WZ1j`qB}QTf#b^ z3yJPJRD~)R&xINrsUgCROu=#5G1XI4iK;2pV}O@}KOO%07*Vf-`?EeR$EwxqVsv_~ zH78B)v;dStjN$1NIP~7JcXh{s)q6EbIU@q&-f?ixy=5Md=FW1>?>pa>4E#k(Gs<^oc+1PZ8N16fN=wp54FANlzWFAaH=&b{ zfQAnN$J&Hh3yED}MWOIH7)ogV@}!cEsZ;SyN(m5WYD~`QDI`rOS`C|IRmP8uznuy3 z6YU4j3nT_Wj2)#Thq^tT0U!@=r>Blx9f|3`@u^wA`q~sTeE7h|h2DfqiUHkf@F7ED zuYDvW)BRyvr)4E^ilw7Jav_Gs7aQ@|s+U+3X3)W3FWt2JrdKY!z4Sq+^g^o5V&0dV z1qHkqhFbheojd#ItY@|lQRzNyUi9L?d3B#|Oz?MU#uKs^g5D++Bss#_E~hJT&JrXc zz?^emMMC_0k@h`{lHJLW=t%Jn&Ha_?_9*|MfFDXLc--MM6MEpA;3i*GXw={t1haxc zP`O~@;Da)-23idkDiZUq^f)0+6fq@S=PW6PuYLV{sqOpMudQ0PYG8bpASTE6ZY)hl zG*aHwjnBOO%*LsCJTs=3HujEB7KN<%fvc8PNnxb6k3uS-^=bnQO7TWH*Hy)gvgG8l z85Q}%i&JB8E8I|<5bHDvy5v-s&E`r=ju8y8&IB#)g!{#$77yo#OK1lAl0AaH(6h4> z(VSQ$yN2aB^90#@%0m!-u!JJq(ht2_FagGX;(L(h1it7V^eiZib?`=sRIu_INiKC4V|*i)2yOAx9uOS);1I@Ox3+wfauYF3K4 zOuA;4)LOn_QC(VE-J%WUtrDkDYIq@X0)YDCI7@<^#YJY=;(>PkSyL*zZ_nWm%{ET# zC5_}x+2RxIQr_V`A6&?+38kflYBDbn563}g9u_;~*cxbq6e@C1CRBO&B}a9MFmZHg z>&!U}3RApc!IDO{B7B9g^xk`|r1yg^5$eF`>Vbc3h|%r%WXnmGaS946*%m{#AHL;7 z=?R!_dYl?{EfP$pnC0-+&-WUwd!@fx$VwEwO6D^=?VyBEslcEkgpa6}lN3z`4yHZX z0PJK?bdvJ0Fj_W+No&{9n%>9*>{puinPiN$s+-au%71qGl-(Z(C}l zy-X=>xb4;D(X;8Ib!?q{o3`-fx)3Rmbs0h!^KMx*b`G$h3KiVGf3^t&K3Le`N(YJq z`T??m-Xc>Hm9neQeEFW!XjHi*jq+ootM5tgo!)c20)egr?CPwRuUfLyNo8iMvLbTl z7wD>#prGjauD7x7YW3UykBu=V=6-d>2Mvl# zTMd@Tw#(HL(Xa4!u(TMqUOM{n)hmcjWIp^F%XAv5s*(Aoy|L%plHZjaTRM->L;jn( z(Yu2hvm0`_bA)sevFNaIg4T5+6&Jg&Yy|O_8v!qQUC|6pyf#nEG;`oi7ov(2?tsOx zW$u{H1LI1Mvb{(D%T}Up@bb~XA}v#AsS~tIo6y!hUe3Hpod>3stXub!RwUgIXogZk z%z6oQ`n9kwl4ZuhA>I2=`@QF9hzRu%%$g3QTQ>nzmM@SQ5=@t%DGc~QxEVaeP4Jqc zE{Alb9FSjsl+J($zLMM^QvCIE_uhN%b>{Eb2iB!!>8wMCW-XNs%-qH6SFXIC z3q3(Y{R#O1|M$bvH>XTjkfI*9XHkN54q(mprAzIAYmU6KiOt`%2|=Delpg<6>)oYM zq5=0I!8m-lQR)EeDAT#pyIcQs9D(S9f?ZOoh&EIM?{pHpqp#BEz&v%nL&nrW6Gbh|z9nE=Zz&d4Rf@@`|1|q{5LbefQW~ z(y@Na-`H2D*4*%?Z7cqGjog2Fym_fl%A@S)Jyb3{)5Cj6+>5ufz_Gs;=VK3ci$ultSBF&OH3*5JvSrRY&ov&|RRcDKAZ z(cw&Ty~QfLtM*D4J5(^?V^3o8Thg=GgEmxl+BF8F4JW{^@$+qnKJ#x0Zx>;LPPL%3 zDdoN=vwA^5&Z75q_c;@~T)1b`pb6d5zaIJc$>lpxad^4*pst56UgwNs`X^hT+WSqu4jr1Y{0Y7^+WF+oE2$aU?qR7TA!Y3_<4M?r;FMCY> z>^ypYr$&JXSqv) zJkOTO`5Ya&wv_O*k&sroHp^$Wtud4XmQ7u&@r=;Yy;MG736DQB|-Wj=&+b6p7iRe>0zW&L)D!&`j4@G&%F8+)rOvC}XxURy=?4n#mJfM>!i*&PxL}F-W zkK9IO;HJ||)yaiLUj5NCL14o|7!omTpTvmD-|p^AUS5hQg_f_|cA5JFKL-naH`m7n zI=RB=4=O-BzC3o)xxBqV0Xqb!Tu66N_d)rAQ6f+M;=QQ_1*y{N7hRv__Fq%6 zbo;TFUW#~VpBOGkZ9AD-z}0_ob4dyNou+y3yBady!b zsk!m-lN*MHO8omWr)7?;DG;?sk|%t|#pff(gj0?OGPsDT8jDC;_neTvuR;&>6WRxhYVu;z}Q4(tjcOss|yB*Dg8?( z$7qdB>%TlPefo(nCH$-!{@qcKb>@6!)v8ydFK_+LNon%-`Kw;x3K}$`)|2TElxOd4 znm1NGzMq5F+ilxb_8P59T@woAsifhZH^I;PSC4-=bhbE?ZX%tNzIxlhm1xPGGD9ey)#?$3zhFH_?bxWu38Tp`)Pc?nRWaOu>(v7H@ zlDf9o9vj%k|G|rRTJ#G<8O$^XX>W<(?povI(@G+4a&HDuP4}|f?kLjO$)v~`g&X*S zz!hZRIEaPq;YHFl4|uw~M=0fi$Bt7-bx&?hoe~UINb3*u)8{@Rbbc6V9X8E&&~9{n*uB*L8l|I+P0y*hf| zNK4U>ZwhW$9hk9v`s9A;<}&=58;4Mm8R~;!)xYHW6)Fhbu&aL56A>mLqh-iT)S*Hi zVh9wVw0xuvlQ9-lBDsDgKH@D7cZu={LF`@K&_guDLmGUhP(n_=q-cY(TUG*b23?^S5*O33rKQWp`|kc5{)N;`2O~X&znq+_Ev|3VnupxP#M8lT)F{tXa(Ls#n=<(4Vni86uEij zxr*|XIyD@2Vjt;y08EWu4f$gMAVxChP$i+o2Wl3vT ze{-rKhD#EJ@$K`FxbsVGu2WcMOEg|m@UuFOGA&o#{-?NP{RjMKe8)2bxiy?IQ7L@~ zEfdOxcE*?_JT62j^u$+(_uY>$)saQ&N+fmRWYqgDRx#?5Qhg_K4@cvaa~1tzS?^#< zW`Xyt7j(Wa8^}hmNx-38$$rhAWADKLBXMvj6bUJf)Gkm>Ad7i46SLo^49e>yI{B2* zb1>K990uf+PH-K6bk+q9Dnu<+IR{;@1H7{%dPl))ptQ$`M*zGUTr;9ez`u}u>kM>G zdt?g*8%I+e)b4ngzX&&rURUgJB1?hOLAO9)H9pXprr|v~f`#QgMR(BzNda6c;P(@r z03L%p=H<{f(h)kKOoh=j`b@ino(y9E)c&-jn&BEcOpjEmQv41l;wO9}o`;I#a@++C zlTUGFbVU%HM*z_j)J`r69t!#tAQWWU3>5J`RR9)gdB0CAhvqY&gwCAycq!YK3^4~= zgvuc}i__2?MdiRTvCB_ZqTYCjI#r4M&?vJKP&BlM1bzo!Ovr*hl!mHR9HfHCSApxH z_%)>}6=iY?K;_1Ud`+soz)RIq6(jc}KB$j;D-mGp)GFlBi{i77)ILjGfMX*QP^lu7 z&l(5Uruqbjqf|dOC42C;y!70*CHgVZ)g10+)+;q3rPx=LC^ij82I1Ce|5%%_=(-gn zxbM_f6&oKe&TDW)Mnrz=9GeeJT~4&Bm2rjyl}4ACISiqiVXrP|R(u;|{6mGadqmF3^XjRN+iBC;*8a(j{I;}cU z@07mRjC2VJi8lAJ)Hr=VmtN#c3XOwZh76tEVRBtO>l&%?SQ8V{lltr9QoY8)prCou z(8rpVof99&zo$0yyxyFi#bTw_FYdbQi@S>F%w;NV(uQP>AWGk<0n_p}Cn%M=l&#W1 zQ?F8^1u*a8faiGcX6C%>K4w4c0nm)O${1f#2u;08%PBRg8040<3Uf<^7?%ksjlYiN zigUAK)MicZBsK!MG5oz&H;Abliwno-ox*RPpL%?X(#a)jVzRVWpmSMAb2e^;|)N>Gz+l?B(pIZGYpz!&J^?7uV3IA#fDWGz5!-lJEpLB;|`NorHQjTszjmC z-ebKXp;DtqKHLSOI69@rx=>|QXD6fq?ta z-5z8G>m>ry0eLfV$5^$`?5;@f6{yy5`LRZHqQn?YqRFDyXcJv_HU9u$kEVOCO|l9r zGPd;AyA6iW43kmImagUdZ_S_Xj!Uu#)}(89BpZ5f$xs?i(<{xDYZnP<%WLNGe%~&u zMWwcF>dSGPjxSq&{P^-^k`Em*VFd=2jvv(TNui+u&2AetQZ#Ze^;sFGR$5FqCvh8{ z`du#s^Pjs_ZwGu6VGOC*xC{(QwLV`|1K0^SVH%s+ssr4bxwJx~&e7|W($FlC%?8uJ z6}p(fyy8F|$MyZ7qGWMd(e^1woB-f1t5c`f)%Qzz-EQBPpX%Uwdt%=(%Pp?*dDze) z=s&SGi-0^1XD9X9Sv)Tgqgz>RGUTK9NQ_N9Lq83GlELp9$zvM%ysz-gU@o*P>@ot8 zBvrYXgP*h~k1U+C^6S?vCHzG9{bO7&w3J&?jaj zO`h0T?TZV?l6?;3_||BI3Sl44qHHcOwkQ$U=jhB-M2LSD|0j}cLI< z(l?ECuyNw1O%tPQd(WNgxDj3x#L3bUEsH+V89N2YUfIe7UX1~7qNg`14158Zng(zOWHZZB`0%GAORjEQ%lLEDZf_T|T3sl8!I;#U` zLC?`F!N%B3r}6U1%@mY$MVS)1%M?`#QxHb|q%`cV#bNea923nMVrzz3v?}Ns3Lcz1d|VaGZ6{zYv(1C0 z+pqM%ZPX1Mi9n&bNM3gq;|L#;TA-r{g+kJ|O$amzg;)r_FfI5sH8n9)NDQ}1jp0aZ zYk2S8a4Y8yvu1fU+MIZv9M{m5?SZ7OAgFjHo=>Bx?N1NlS0B$s*YYK&MZ+^&$qq(y;2J`Akhi`c2ew>|nRVJ|Sf!+aP6 z1uA_3C6dCF3pjd}fa9HiZMXut9k>Xpb%|a}7jksHyp5k|E3{*c{y2Oi_|PAG zh`OFh4RBc&G$TqC@@WrJis+;irPD*bRt2ROlCzhji^!QyY1+f=I%C1(1tSq(+8Eti zlHSo+GH4`rLZ(DJcgdJa%=4rhKoU48cD#7g_!Jcr?WTl_Jqf3{>OxY?6EV_v%-xQT zUBX^UPkbEd+B+0ok7kMsTAXo&M~7hU^b)=q#~N`GGPzUHO7LiUnVon@I@HOJ-Z=_6 zDirXC>;@!6f{D&`N1+2C+EK9_`LL3i+Z(_!_!&XEfd~XsfPsT%7pdMLl?I|2w}EMg zTKqJ4TXlP~Q?0%AR;}8pcRBf(9XpU=*4aMi(;@xluMTYQmB9vauS}aUf6bctGp6Ou zPE1_?*wn17sgJFn!PktbDh-XS0y`;{vcC6PhqjmsMA(v`xE#REiM-7hCt#Y66{;ft@pA0iz} zSjM^~tb=&Orj}C=FhH${=v%+Jm=XiYNEry&a0^Th zBfXyf>(lt}6&c)%y(v8>eTO@|xAJyoIC4Z9vg7-^8t;(adGcQAk0)o`^A)eWqB?S) zQ*`rc;4Q@;&B8y9Oe4?x%k#91=@+#jfR9jyt@?H-ORah#q_>7ARkh39fB@D3W3KC1 zv&<;a&PF<|bGI<`^2w7}d9$oZp~+O} zUY+{il&BYt2mU@3DjYROmt#gF2W44BEOhDDq81nEf`JhYWw1aXHH381y+hdo+Nrn* zGQlg@BZi7}u929YwicQ7X-uy$NOoFff3r_rJJrtqMjMfes@&YFTw(Xb8~1JAcjLtB zCDUgMmLV2l_Vgvy?TV}I6+)DKArj)lxMkb-GKVQIL>(R~uayoQSSqiWaPQozjwvmWi`5;Z$A2@%HvTz`RJQFbywZnQ^%PNos)tAUBF@Ka(SRW84X)B!CJ#z22<*6 zFILV6JQ&l^M}Q6(c)JH(8`__uVljNax%qswO+r-n#_nxVZllNzLw7H&?od=O-96Om zbXsXk=-Lv)$T_oU?p$e+)PA|jkP`P`MC@VW<$aO9N$Vf_Zu92v9$KHI@}zrIS8hh> zCproGM>Y@@;Nkzjs$nMc*boqi&}q(}iu(OxwOTtA8vYwi|HV6pd_H97;{N}6O{&Vv z+WKw$`|0(`$?H%5eIwCdqWzc4PO((~o43=5~p6-pOh*OVS)S?o$2~{+?jdTqg(ywmH0_V zD%`WDkb2Y=@4*P`b`9v^k4Q=o4#_!czsI0fAd?iXC@_o9#e0#hy+pL-V29`mXdqPPkfAXtkqjNQ(vnVrWf-TBTXy%VpThV+J86Ln zRRp#Xoy1s_v=%@m47R+Ohj8Q$<>ge#i&R$ZM_w6-#oGB=d2fN=puxe)0#QAxvb3tt z?34ue^qu+z%BH$Vc+`C9wIREv=|ts@$wfJXgfPG%Cg$}+WMsYTKKgCVO_kpDSCH5n z*DH-ZoYw0H+U>qBy;99p<%HK14i#CrAf-58b<^}83QMISvAK0k%SW;FnwhQBcCpDD z?E`46QTr&Aji3|xKw?*rVpx`w@f!#AEj1H04z&!L1u};mB|_q9*O}dIf%q}x+2Err znV;|_NIW5zU}}w{6RO-*6RHmRLV;Rx#SL)}rWC7&h}cK_-4AbHnrwAW+coDF^$^2# zBO-Nu7op@XQJ@X$hVgiuNT$^GE*c)VO9#;?@nOf$#J9K zcAdcO&UtQNnXqe`S-EqLWJu4H<`178%;gmQ$ILyD!XBEoODLoI%RG#1>xFj%ydpNI*<~C9GFl(tM$4k0N>uX1e^R$82$DfY?lLM-#^|M8<&5`68_?lI zW}+zONRW(_aFD}MYD}OJQ}BB<$_SQq*+!ufh5XaUDxBptqSQY3z=64ovj&epFgGWg zTZWn7!2B`N{S$6Fe9V^`4k@*!YL~GJViIz;0siMG!tc|X;FCr^q9f8_xFK39z z5-I2WGH22Jku|J7vluFZ*S4ooyO$OX$ni<9gm>i!MAz~GJ}qp4=EO~Pa}SvReqe57 zdczL;XeamLz`=%~C#On#NLyEMNr9EkdUd?r>nI3mnhinTd_i3sNUt)y6hfHK+!rb` zXLcy8qjdwaxZ47?>pc0=yE*06Id8mCouwWT$QWb>#q8{RvOJh3vil}EG_c8|{0VqtyR!Zfb$ zil#aV30s_eQu;?G-UNINjDl>lDw0u-0?ouQGHIr^Rfa<9+R@KVF55$ zL9={*3VN0oWRD^8lK`fee&v8#z7vuJ@%hSBp1jjjG5tlyuC>Q18Vqs$7|RH0l1ZNm zcn$F|c17tRF2fKn^08NkuC~t5i_27NCz>~nt>0*?pJm%vf6W%dgjK3*wLwQ-N`Bm& z1EmF$*nf1suS|32`aPO5UtWmc96wD{?#r#>m#GBxbaj!3do&}3wU^WuVW_?y8pI2s zTz{EnS^NRM;*w%=E!$ICnC)O6Cb%YU*N&b)YlL(syKls-rDL@>OpHyH6sk;-CEeXEy{d`^M~UA#LiWpps$zpKvy!{UCw86PWiw7no zP1=|^!8E%nQV=DC`{xYobKtLT=B9rU^MRz0!mkt$p_Ww?B37WOaq4@$`j(`Z(L4|u z7aU$2XykeahldZ(`+yr@AFJ9n>AhtOq}`zrQ8GB^mQ*fv?g2RGft&C8cD51mja~(1 zv7Mp-OGapv@?00KVgP|-Q5U9UB8o&0sS$u?X_TP|8;v#u+1bLLF4)iOV(`qOG z_+Z!c5$&Z+J^^45xIOwhq5%T9hKM7@C1MbZ>b|+VoTKeK8Y0u@9{9WYz}&h`iDnS0 z1p9#HPkMre!2^Q@b)ZdE4>-K`c(s1Bwkij^n>C^KO7(@AnH4X9D%FNwGE}8QZ=0Ak zKsVaD%RDF}FhZSG{l*(P)#W+TyZN4VwE=#$v*Ot4NfV^|$IL$frkh)qoiq2q_`z9= zi4aTeVofm3b?k6OJ{xI^&#BsGGG$s4rH^Pm&BYomHehAXa>Pbf3|N%&CFdmlC=^Bp zZ+30l--!od%UJJtpe*)(UenI&eMUaJ{~-y3b3542idFMO!6?b2KL*5!Ij$J_G7Sr+|rgT<=t zsL<=Q<``~>G#0^__eLIyF>AF3{@EC_HF6;~L6xdO(3hF2gbH=ySZWa2+&dbFKp^3e zwTe+xxh{U56e!Uk5YTuaB}C^z2aFt77)hW|=r)j$!9=k1^^Cgqj;cXLuOmT+^`K4t z++l9Xd(sZG!DMC& zq&w(71cMWseA~_!yk3%~qR#;naQ4Kj;5Z<%w`pUifwy#_ugmdESS=N;VdElD$UO9S3EG< z^u$wyF14y!M7QiyqR!sd&7JEVJjVu68>}5{r%k;7QkgHVkQADXZ z8=k=_bYU2mRIwLu>Hpw%&){~rumKQyKkbyHtNsA`x-_(n6?TPamdyb`avHBdMaWsO zt54Qu4p-qWPhP7B zf;c!c(gu=82Sjrs^=VKnkxz(6PJYhqfFn&1ZtFo|V{lk7IIP3JxOp-Dg$;}AhA&y% z+%e$T(q+f){QQ`(@z}DZ$FR}yvGhOBT=(|cwQpbd41cdAAGJjgY=W z7F48EVCw|7KC4`_@Q`%j@Rl#?a!2Y$yX(H(a#*@>XrZP&i!IpCZu?U!yMarHK0e6N z(~Bq3GZ!yrav56W2OndfA3OH>F)5v`W5%`T+s>~Qbc+^_KlJwUrEeab1kY#e#%sW1 z1)*?#;Vn+n&4y`=>8%LZ6ul2fRa=XEk^i@E2CN;a!ad zLb7BsK+ZYv2%?eA~Kv}WS~~$IVP{89HcxWKO`4m{y;*=fr#%bZI^yvS|Imm zr2~&|+VuD)mZcZ;>Dm6JFV!%e%N3J6Cb{2B()Y<@u$s(tgI-N9 zYAPLnm)GYB<)v}Ukzx7_?)1Z%r`X|56DMriG+|=o?u6{LUY@ub`ylx)dY7v|{EuBO zy=x5J&t4Pf>6Mn9U~?HP@q!^W-hrIw@fL$io(saV-c6`NQhcNa(eFK6<(5t8fviTe2ViJK=*+{_BKX?>ElzO@@yBqSvF zNz*#g`_dQso>?*!OO31{6cAu<(q3FiE&KoQp620ZwB10gn54_f5&eGl37agIM_uR9RZ^068 zmiYOw@^LW?KR)u|lLbf_jS&FekOCpqT;|9%GQOuQbSsl8$8G;idiH?_rDs3iJ|VBZkLUMlL=mwS2y9+vhCwAg2mVXn)s30E_tpJkl$y z*fSu%FhyERIvs|x90U!RMSV_0WD!gih+;(WMJf=%Jaz-H^c2Xf2DK-8TR^l&9k}3@ za?<-kgq;!0Yef+X4#trn3C^E&f>#~#I zcUa#^@*U$?-+p$_eD}hN*#47Q==?rw`4Z20{bwrngkfNxc=j4&JIW*9d1i5sSO+*FW&%vPA*H>)gG#i^0hLJ*21Q<1YGUj9u$uxPlPzLa=~j;p(&6w0j|L+ zS^q(P!zq4BFh?|wXqPN68A-trBv@WZOt~0*LGpUX%neqUQlCHr0C5Y_z0Fa9fobB% z!=ooNa|I*AKjMjt_oWnoH<+YZzIDfBUOJ{)wRz_x?uOZXVw|AwGx)7Q(WgKmaY(sufE+i9hOTeI~Wzvk|}?8NQ&OYpx(+-~s6w>BC6< z76Z3v6RTLE#1*I8Xj~zV5_+VUWov?40ZdQ`)3ig zD>3e{*bD1=6;7)0mX&HCJ~?{D_r2%3!Ka(|&r8Tu_sbqTJ;Au=dIpjraHH>dSNigj zf@NRW#740JEOVmt7Xxn|v4qS1U0*eLL?(_%RXOvtPxs3lS_1FKLO&<;PUBP-y_%mq zLRXfVTr)E;{?$`HU;V(7Y}}%u(md(;^_LVM+&8V0#-aY0&r)I0R}c{s$Y&EKQGjz| zFc4@EU|0#>8?duTKq@c*n$yrK2BItHr(uKi#^;YecUbyrX6-eCa82z@W;^`c@zv7n z_aqq}kbe8=R^qWALW^|ox{6UHZ0e_fW>ZV+E3cF8L%B&lG2y*^3onlV>?GAh z6;vKl>Hz=(uK@)_A<5SwXz?m}ivrRK(C1|69|uod5tMf1oQo@D2Uq6FA=L|rV*7?a z-aPI80(N)FXVSS7Pu=tBU0-LLC%njPkN=|rsYT;lM#ZIvLbFHb)y}A%J8J&k)vpdH zy!gVDF-vb*^H|PQc7c0WeD|i^f8fTJra!*Haxu&~K& zd3Uj4$PD=Lq^=Jk;J18h({2%8Y6Ds~_sB6=z^7_BUrp?G6 zT%8{iUzO1R?6G4n4fFL1>0@-x+sQbsIx~uaN~w| zd9+gKA|&h41|$UX>Y>0*d5PJCqE~_#2Nb#j&t^)>Yal@%pFk=(qQm9f+!=92Mh841 zSWLm`=&O{olfYx_X7odvtfHF`HL0~aU!x5w1^AiMGf)EHb%IKE6_qZg`_Vx>e6@1% z-b2TZAG~?d;_{3bp{P(~mc)XYQ^T8g-?Sw>MX5E$*wZ9?RfRp#Y}9JXt3<8Q#97o; zRVJ53uT)i5T3iY2#hmOBb?B0DEpqtnIf zHLAHY!Z&Z(kYEAn({H@z&V$$Ml#9zlp^B!ay|cz7s?~{%A2(p_%&EmCB|(%};H_S6 zq+DWcS(Rwwj0TmqvdWZX5vwZAu7trW7S0(_H(^5E$k`rMg4vWftv{>hwl~f?w|Czg zCS5_Hn&*`_&6-g?ux?O;G_7CF)(0oQuxsbeKnjQS=W5Yucy7%YzsSdmLWT!Ev3+G(b#j%Fj>TBSu>f^ zpw__F0smj++=867(&hxO&!GQv`Y@|iXYj4uzI)T`@{)$@R_&ZtU{4vVwD&FQYmwg1 z8n^EB%;|Sbsf>#>R#(-GavA!}UQpRrsZ6q(f+PCnmycgQv6sdOggjw+{)1!E-!je1 zukU5hTC;C;s5Cr)iK5A3InI=)RK>7+lB)_bbh=jWP@7HX=rcB5nOA?)_)$A2*7Qo$ zaO*4G0nXta8BFNAV*bedf|`lLQzA#lGi!P#y-z zl9w(wls=@q58ZI?bE1^#wBlgX7XKVt@AV>*=n26tghev}h|K z49Acbsu>qTZYYI_ssb#nyBT=J<#h&UrmM7CxM&D##>LSSBX0?cmY>wwAlHA`)f=OXtB?`4oRisQZ4=|BwuRxG^w2{Z{!MGYh`{_h${bV>?josn9j zE%O13HdTA$f7dKrUr7PbWp}i_aX0z4k>3ABV~{Kz<$04j=?Dpb;8r?+FhzHU z-72GEc6M{Q9QHYionTo|*EUFRa|#+Hd(T-CE%&e%V`MQsn!8EJj~<3v{KOC(JGYlk zTS+PlJll(L@ke=%@=}~dR0Y*tAx}4P1V41{3Y zb3@UnR7HAX#~FtDqpEy}jiG8i15RE?NGR0)(x9MQ3GA`4H;@>?i%F*Q6un*M8VW`$=60JJjrr3({3V6f+6E?_ zXIK%zv(tMgdB_cUh$2^v;LFJ&wo?b(l~JYZ7aDC@IueOP0qa<er^N)+%bc*@!y_d=@)A1hV&Y`*M#|WlEr?!!7C(z4)c>-EE zpq9Zhrvcs%0%=!;NKYN`75gBWmy6Ja!2^<^UM_akntdtFmX5r6)5ft0u{j5?%`6>I z_8Ob^=9_E;Rk*tL1*t8+QZ&X2yojLM7*3UE?-lFP9eL!k$%uQTM~$PkXW<=RUElQT z;DW~SBP!~LDB9cdLiEuuqtzg9Xc{ra;Tr)D(_ z8f{rHH1A@gRZ519o0R9v4Ahw=+5h5r*Q^hr$K^pAYa45O%)_JW!dBpq#2?hMh1s_ zNS)-d1Kf}l;-q2RVAu!lE@1XRlIuK=%E9l9sZEZXH!m)^HfD0b9gq&V#`}VRPuER2}!z+-;9AM#K$N(^$dr~Cf#Vz za2h}+P~E4?x|v+~@r{7BhipAjgAC%wWFrj7Ir%bpVMBI`Q1V6Rmv&2a(w_6W!t!PHqx-(kdM)E)4Q#Px zP-b~U!`iXZL$g`dAA66kU)FZV*tHD}#*n6!@*Q>d?xtGqR)#);Cnba`p7RTDL z4Q1sG+(W%5$K@2jXmcy{0MJ0?lQJ~u#~R3rEIzM7x^I# zQlrkL(`qx)(=)VMZL%)2K%*(RKo1+c7JY+ElPhpPBBke;u550~+o(>)t6n8i#jmf8nW1XBHhB>5lJLC~XT4=89`r<8QxX zqo(%VG->F%p(XKvpA?60yrrwZ%D(kcH2MUE0zD1Ak!E1(kZ^knV785N)rA@bqOc%O zP!I=&sVE@{{0sZsTw|meq5(^x*bM>FMr&&o+{dHyl3e#>)E@J@7ph2zpCI6rl)!;} zbZJoGMHSW{k6`f>o*oHDoqQ^Sg`fw6_kl9+{lVYw+IM01=shnk-1Oy;KP;4Pf8|%w z`){vX_crtW>O5O4g}6tS!BGCqqg|HrN0IE}_;t7Y8@Ic&W3<^nELwHL?hAVtzPM-f z>iO5*)3WYu>3vWS+~OUsT566+u-JE**QM{jl$JF!1d)`aqi?&xr?lc75>`tm9zoE< z{APq=n1Sfb#C?%N6Zo-hk325iZrd06icOGWI__c90jj(4mX42>@#7+Kjgvd>V#B%h z9UpOM3VF^}hM^NAd+v4UC~`(}NOzE4kg^8SU36W<8;LqX;upt~5M_!Mid`J8y?hPsg=j2!n+uy7P56f~wevR;29`yHc6Wcp z7?p{+Jy{-iw$DD)WbUgnRVP?#tmy^Jq>2%{&!hX8T1}V#BPJFihc&5%`_^P?;+n9K zze*Ja{BAR*{=e$p13ZrE>KosCXJ&hocD1XnRa^D8+FcdfvYO>?%e`AxSrw~V#f@Tt zu?;rW*bdEw&|3&4)Iba*Ku9Pdv_L|PA%!HAkP5cO-|x(fY}t^!$@f0r^MC%fcIM8V z+veVL&pr3tQ@lQ(H{B5hU3cf}4x7V@V;L~v)I?6_*wq6t@dtRqF(&Zxdh`_-87jFo zg{9(bQc^a6km*oxBtb82j0+|3Gt$9d#X?J%2b?W%t;(wOlfeAIqtZ25;A4nbqKVe@ z8qq%asL^OLI8WZ5S?G*P@uv8q)`9n^>;UDX_ULuK%KXB_tZ0`vF~1;IzRt6IISK77 z-|gv)Eyz#wx}viZ3-c>|-7zgy^wCu`W4o?X0{{rKZ1(}3OoJ%xgbRfJ&Tt)B>$;bt~Ya)oH02^A> z?zHL{FI=YWUC4L_u%Zs96<+WowQSBTzrv!*aGs7Lwv$2y=zHr!2B#q>)@n^jG<&zc ze%{XG;hsiMezkXY7Y&E#ncsi?kFPxOhr2$1aeo!7dhU;Gm3R31ubRC%u~1x$o<2R= z8k`#4%yc`wIbK)1ExM;C+7=&Q70n)*)D%-t6q_iRE0U+rIPYg$_ijm?=dI57%-;XT z{{DGazWCW)*MH=B>?8TP-^D$-<^HQvZBbL>I~nhcugb8+Us*55zK~{%u8P0)+2_6; zKQ$`angE(21O97%3H)Kw^?{5e3Q?J>K!-R4#1|JrMzTtP{cS}&H-*?hL0I&l<9B)i z6o@xu<10Ov6^e?+7tRS`%uDbl8>L@f`0%!E4`2B4(2c2kKkj|(ycU=)HYFA;TE8$q z!RSrw$;uu&5M2;nyJlvhWBAIBoSaoVU)Z|&#fw(@lk>v)QC#ne4`vi5x*f|iGwWM( z&Hnlem(96g&CKF7mzmpEY}>YC<+g1 z-E18(f+jMBv@km*uT?$Ws`}>>XgO8h2Io!Cra!F>uk%$gXCXL2%;_N?C)hp_*NI3p zLO*9c^P;nL+SwtN{ng&RU&-&_%08v`D05%sR4GB}+=id{&fc$1=bESTv%dZrXyY0B zl{^}LttWv8RCRvzoLD`v1a|b__0`w<=ggRC@<{)xcgob>IE|eDZEy5ZXQ)H;UvvRJ zdjbx$K;{Ty_n9R3hq1t>(ZxW(1Ldb;KSs(Ir|$s|xUMuAwG~zi!?c^=p=Xxp=9N5eEhR^|KX^olF;(A#aC4bl_-Q$^6);{6eB9CdQM8S1*_Np2I_X^o_%P!ZYABl3X2mGHCDR>zQW zM&Suv;SA%DgXBtCBtD({cutV6nQ`n0z7>Datx)gle30qL!MpT$DK7KGg=;Q}xGrCL zhbpgr$I8oHkxSNCrWGK9?4#dNFioHy99v&Fd2%5?fZ)kv93s_6;?u<(n9`0*t40`| zB(GDt>P$EW@i}5Ty~yEd;=6Jidwh96CF)-;PiHsfms7YL@Sh4?@@vou0_@DgLsq&# zhhK2HffFY(<(4WC=bWG-{d9<+MByX3&V*<_x!eGAnboY! zVK$59QoQ{50z>REr`aUTlM(s=hgAsum~KePrdLx~Ny(-!FvJ~G-=7XqIVNI9;pqII z$6`h} zUU)nZq6Cr^WSIYowj~UDC{{Lwnfvzd-?yE;CcnZ0a`CA(tXe+0Mt6$8THSy5Gk<^P z?*8iW0Q+#?e&O={`%X5q*H{4mUmH89JGBO)3O_&wHUI?r!jI1{DLMbgtO5wHLJg~P zGaEJlV5LoKmoBp`3*P!%#3>-bN!W00}QqoFh(U5 z_I3)fCvSpLkO+H)?~@-H`}}!1@Vqe~6-Nv>$hb*}RUVB()kzcIXv>RX!ILKas?#Y8)jb>rWA^~=6v($U zWv7;bzCwQyw=J5D9yuaR>)f;J%XMt|KlfcEXDhZ1Mq5|NV~=fprP4LWRr$)+$KUT=ltlgu{Ty{aMm#cPR0)3*R$@YWTsR5O zIA6&3uq7mxJGM^9vKoEz&eva;clwN0t5JN%h%MXW@_N4KSGXKsT6H43YU$D{@tvxr ze8cFd?$owzGFd;+so|5iQjSx)d+x!UG@i&t8RFUl2M)N;WFt$Gv>s#A2-r`dRf$Bi z>AxOF>X6ofSS6jCQVeH>63_Bk5f4s)J_ddop~SgAl^4$0uxL_c;p{9-qi0y?N@4$dG>VPyZ;IP+7B1L zH0+AXb|$CfMJ`#pILf$q_uUtd_-ge+T1HGIX8whfFFttPFP~?DOJ@u`aOZFC{&3Uc z#a=jNOyaR{(}54sc%S$VvZg_HCpz$Th0GxOa8#?DCEGdhE2#WZ5~D0D1?v+*oGL@y z5~4St@wFK#p0gJL8!tbqFgW?1{-==hxP0QN{{E++Ft;7OwL)25*Re+~}0H_}6{CX*0oRXs#@+*Y&tIGCWw(8|;cD7%( z`BrA!|Gm`Zm6GqX`1)k_`wVMT-pgz#XJ2RMzOIw+u3x!l?^F9u>>b`S`DOn1hN7`w zU@^4~_>H@!av%5N}n6I9m zvS)bjSNp!dZ_o1HYhK1z(VlUf-X{s&m6#W&542T6n!zXlB-zx%Zsmv@<^mME79>ML zJ3cXrLWL~$buQ;TKC1C5o*G0`w)>7%&%^hp`% zPFq|?O75ft_f)HXp&{OU^dVM<;wBa=KYGqq1O1V8N|07y+)a?xn6F!hKB9F>;pTuu zgG6>AWXypxT=3$F|H{5PfuwtsIfqT6p!g_fblgBT7%}xo@&{5J>HaLZjs@h9%YqV%e4vbA=;aBYfUvbgnw@=pZFuUNz%ud1nDwW_*iEIp78 zsneHMX_ zOssGM6bn=xAm$numq;aA5H6YM&=B$gPUVSqYj_0A35IkspBaRNOlh)^@*l)_*+1`L z!t%(vaBx-6*t5)Kf5+~Ue^q9Vmj4#xvhjRVG@E003zJT~Ab(+ZyY0;SBD;<`5~t*q z`YYmL8HL&7%l&ydRY_6&al}`hiH{qPhcZr+qvu&HZRLV_`A)#~k&iZ*wwh>!m-}4xID_ zG^|!*hXR=*3CtZ5mh)o)CdLgc0m4fdEPG&&LCBw^P{FgO_mH~-?9zsr#KP#mvO2hc zvxrHAjG%kK*wcGJjUx&SASDKl6_f~UxKWN0g>ATjcg2IUFv4DDhIegjnoVz(j4U&g z86~scmKM9#o8d5-jErZ*FY~#vuc(+mH7P|el=%H6I9dNlEq>- zCKQOK&1)^5DOO{2RMC>MI;)}kUHOZ5ySHYo%3v(oXq_V50rfescC*N3;p{hNyS_($ z<_6j1L5esaFF)`iMXdS*)BRx;MfGCI`>FhUYz4v5ql z6V~H?*!H|}6V`n|7DZcb6R+jmIa+B5D*-w%hIi}vUr*BND`6?@Q1GX~hzUw=5E#tG_8d-|q?Y7r{^tJ9yvIzVGg7UAc>DpVJI{$37J zKpTy)c84=_2JI+igw)j%EJDmdjF=*-sZBi{Y5Ne1L-ndKJ{HihqBxqi+G{X96iGlL z|G{@8Be)RJB-ucc0UeJ}_x-rqMQFffI}}py(;M-K+BG>`$TJwnFg_$_(V_dU zLeDGQZ8H51d)NtVcac%BMhudDsp>4h$Wvc*%4@ zB_<3{JjklBxfQ`oWI|$avv5WXcfRUy;5Gb@BO}I239C$V8ZsbNLdEKfQiTN%)(V`vnnc%4~>T=X>a7EQFGF(W|S5SHevO_?5Ko{=$M%3jD)D{ zgRAvU=plb*cVtH$vDiI7+ZVNeOUnF!A*G?{ysNXPic)d*;@O3vp^l7r;epdB;?oO~ z;?y*vF{5l^s_1`H6|*O@bgGM2bJ)b59V$;XrevjsF4pc`iDl90@lh#JtZh-o>?o5d zYIeq=HqH|^8`4>|x5T!IS#D%eZE=RGdGV8`EsjD9(N1%LIS@VjeEBG)kpFh0{8^hP zJw;8yiZf29$oLm!1Gf?ltM2PuuqZx{B-E7iYs@JhQQXAA2mQw3r&xPZW+JwBFm*)p zlny~C5zSLD`3o7iGvs22^zN_>I^cC4q*_4q(FB3rQ`|0j?2=CMIf5W2Km3toWM!vi zlzI=WCm25bfy1AalAaOtuDWsT+2dnRS<|d{TCMtOTt1GUUVG81S8Zwhs0QwPHSlL2 zl6yOPQ0GZmbFeV0cu8}`dWEfdIH$JCpPo~+ymb<0&)DTuEJ{tY>h-wVK8~Ayeb=g2 z!F@Wz4|c=GODFXP0G$2^7||CBNkB(Kevkr?=O9%lQ26Ma(f}5Hq)bnvvkt6}G@~@5 zCpaQkML$Sj9Q}2!bu^*H27(Y&q1#d!Y^YE4CPuN}&a=hXR_)?K$rrKtYxmE(`Pw)p zdhD|ca$}N`J%-q6Dd`n)9m^K(T@j;qNrGi#Z}EI4NT$cmQqCJos0+Lpu)rd9YxVMb z{q|J3!hW7)oXb7OYd+RTUGx2>y@&KXZBekLD7MHKhskO1B-JlWTi&yNZ=+|0$Eu$k z%}m^J@+>tyP^pl4lir0r`Z&<3I4dJT5Q855Kx$qdKm#EG;>&`pqBlw}67LtCL#LKr zP^n6%fyx4~<*FiG1V-UfAAC0&yp#+mgZ~~%Q{JqsuAZojX+>h9)otd^YNv~T;V|kw zjnyf4Jm%1wlZ@WA+aFxF>u}bxu>V$;T3G1A0dHd{&m$Qi&%i$XYT9{E^}!V4#yOG@ zxn-#*#kEy@H8v^5;jNVaaasPNc}0*Xu$t$x(A-sHcNlC;aGKT_T^V~)Ry}at+B+@{ zjds-~GH+I3hCelX>Y9z~a!p)de>>iD{Mjp9Ci%J+`P&&nMU~C)1Hcf&Ir}!q*G++s zxLxQS5{1Pd?SfIV21sPH1yE61Ks!KUYfG?yMm_;z`P__1pOuD?$VxJ=s`*pE`x!CslJ5wr>oJ+y}lyT%s!BB_805*;dH&79sLC)5WEie6Y2K2gqSDZl`=kM z0*kfyQf4Jw$@R<^E!^f19mUqN^*m>9sQUf1+|tZH#@W+S=f*-K_N$nf%=FprKVRyI zNz0rU^-RQ=91A7V@|>)4p(%P_cE#O=ljT-lo>=ZH&xX9AZ*opnkX1|7Iq3zH*P5qh zW)$#snXJ%ufpGPsoaB|xGLx<#c9?O}`6n}NPQ^}BrYr$x(!G2%> zr!KVMK$Rp|rN>f;J5Bo(?6!P5qU|vT%3c)Pch0badE&A0SC%xadgP)DLtKPqj?|r8 z?o4ln3%Y;A8_*G&Kvo5>0)u2`c_B+7F1@WH1_DY3yFQvf#;ko&!`5i?`K#NYoc!vw zZuhEF-$IndWj?=Jt~XTX2><-lWSdk0{(V+nEIZ#~zf4?zEI*C=4Br)kB`oTJhvkp! zW~`O_65UI;CT1r-cp*$5nG6r}itnyY&N8{3ZmY-W6;2F3Z*!TeoxgF(pZq>$PRf

|iJ)rNwdGr)EOmirSOj@aI>%6ZNkal&y#akd%Z!h9PH=pX zunSE4#rHx6xEAD*#{#Db`j(nTHb$rq( z`SIDCw`IE4UK1Cdl({%QKiRpYvTI-Ol)2E3n83%6*X4lQTMw!im@x|=F;1LfZo~Bi zz8NanVFA(DOnN3USPvw4gNFtrRu0qgkpyHaDRvGISd351$@kpw`x|c>3KfXn$u&2; z`YH>)`XD!_1eR6A#F*dni;b15*+r!}i>5Wk&f1YAUQr*cES(1_$e9xt2lm;#X>q1N z^~f!^j11l7%FB=Wh5XVRZ?du2qN$s&8EW$xAD=en{wJ`EcLpk)nsQzwbcYS z`Gd1Uxu1V+O&I5g%~#~+ly9P;rmZu+8N?k8GcAjx>r1RXidKDjVTGVLT0Jn;=%&b4 z;Rg2DM0S{X%2U^#WXLMY%5+<^EuvA1%GkN&g*j1>MX_d^W76@)P`%T0883Go2a({ALKF?KFD>=KXUSYGYYJ3Q7Tk1Ni}n_TnL=PkP}eZH%SJ7V22 zNmh?T@7kRtc?vyJuFI61o{T@EJ6rOw6X){5n9c#d;0Ek*S7H2tlnGpED3z&Cv;vSa zF%Afdu{fd=#`T$~KS;8SP>%}g=rPh(qP!r9DH^uY8h5@~kzlghqids+!c%8YwPtRg zpBPMh53UQm?!}(WIA2w`YGpXMVoJCwB|bBDQB<7UXm}4v=IzL^PMtF~nB=H+N83#a z)$d57Y|nX>TZ*nWBxEG|@?BYpj>LtRrdlofq=r;Wd8SR0(sQyC60&pBCCQOlX-REJ z(p#*)-3yQ~%bk~!kQr~dvUqFdWm_=^&YauN$6lVGU&EvSYZy4!f`Oz{;h+$3V9B;B zaIj;o02H~N=!ESD}J8h-5^cocoYSL{%o5NvbyP58+$p9d*FRvk~X$=Ub z2Ipk}2>f&XbGS231p}FPi6cOn+?AjyX?&<~CXM`ez-!(c^n%-K7h6Hs)HHe)q>mS?`Y}S4F6yJZNv{ z{?h5q!P@gT)#`PHs~cwK7U`ouDNLH`&)28CXumgfp)=WFNSN)*w59lQ;%<@eNHWB( z;4HB)EeiZSeHrV6mm!lQtzc&11LE9u=UrX1aMP?*^-M*vpV|PLc`fWelWZH9{J`%M zerZ`{23RdQ^CPZ4aQlQG&?DU6o%IWH$X3#vA(W62?Na2jp^HF=uF6HqmHu?hmG#yG z`BM*eOqoC5?w{kg&zn`-ad1+}gKuTIj(s9YpMF3I3a1?EsGAAop5<3l9GX)2z?+#d zNRfO{{>!0F?;Kpc`rtd84l&!onPdH9{rnpK!?DR@lcgVy>BxTpA1z3+&zo7_acD}> zgKuYgKKfj*|Ma*k`|StwY7TWyn=#*>3&|$?{F!x~hbaXr|C3(-$p^0Nw;n8-a=5c< z{yck1;SuJ5q2+fsZ+e$3HamFo7?&?%+qlfOefbl1lTgOs9qiBK}bP zSV!N%Eo;293od`*1>x8KkdwXXWuZBXda7=zaJ%IXKYCJFdh$1!Mt*y1V_f6{$v@*z z-^sD2{Vr+7ijV`Y20{@JRSICq&Z6Yl^wHK%S;Vm{VXvZ4>(mBX$~nkA!t_dmJi_9%^0c(_i*qJt=OiWP z+?zc)Cnq^6=Q}yLPaeN9>tgwx`_Fsx>V+|#7jI6UQl9K9!>`YmT%K5B8@Tw&8Bxhi z;p54R9^BjCYLgqPTdJqFP30rAztuAL>ayZh?V%MJ5PlVBFJa!g$(8b_tHeopS^;G! zq^Nvl&&D<3;D%|wtQE757RN>x)b!L&^0>U*EtunDoy)$wG(BO`vPBh=)dq0!I}c{Z zr5BW~6n|e?R8(2?)#AbAyu9SWkZxNYBoUo{l-2Ltox2TJG9myfNxy{BQ);oi>mE`510-d+FPV88sw+UkSx zY%s4{&0kks-^g4k>kNfQ2g^GvF1zW%#X%hGK+&Mk@9w`utges@Qk28R^sz9avHSDn zlE#U9_&CUpkd#0$3$77pXRdG+A+HS>aAHI;VM6I}830cLF{KlU3}L@sKJW|c1&ytj zU*5WAa%a!}Bgc*%x$P%xMQ?8({;}wDNC>_uHRX~yE3SI}s!5SHlCOAu6Q%288_%T< z&>TfyjLy=t@Bnotz!;F60oD&mrd&BL(<{=?pc4Rg1Y{n)uH-wn&Xhk~a_cKcrp_6C zWOUBdr>}2qwLce}yWFzd9q)&}>f^=s;G|;tJJRyFf%;XWqpRu%;_CAqJSUoyvllx1 zUH}AA53Fm5s9PM$y8v{hG1t?dc1>}O1U%O@ z`h1N(y~$h=A4o6sT(IawV+E^xz*Cty$FjQi(2bJMnqZGHvYerTc|{fdQL{pBABPLm z`V_+@>((5s?YLt_#m^EG@^ayI-(yx(4*81yDu%FC@$8S$Z%8YhNJ zp`~;R4$V~dPG`0O5dH>X04mvw4)m}Lj1BP$Kwj7dAV=`I{a_A|5QCH~2C4)D)EmBn z%7evN71PkL^|n5#skpJSF|bBy8&r!3Er2im7X|g ziAS7ZSqK+sje&V{XU$zuyigcCSx8FM!s`x`p)9I0v}Q}AI3qPPGp#{t+_ENA8C7O5 zjotZ!DaJTU5QW~gK%lp&GlZSPC@W}*Gfw$|adKLL$5Z5+O6vvj-PCU_fxmO?zyV75 z8XTSrd1O{!wPc}r1WXntL63%)Wq{-1io(Zc7E&ro4K!}h1ZXDk*sy~@e<2g~7_2r) z&t@3~bKV^nidnhyXJs;$Icr|NU)p>}78;vrOt7qdLz;_UBRLp!(2j`r}o`(yqxwEOv*>ejs@{S*0p2Pb~@x^Hu zH48pp!0Qd9rig1UN>=(tG|jw4tV&5sOQ{l{&o>HVe&NWX@>##-waMw}$+i6U!zBT$ z;p9594|3nhbxNlnDfbVuW+^$nBsR7rJvrmvM-~#e;M_O{Jh?vtuZ+tb#p{w`2gr}T zXh63STn#UnT$x!C^9ork6B>4Sb`wJ$FeC|?tPIxED7q{QNAi%vD0A>E16flmB8hfr zD)>WLegPte{;ct9Sthtuo*0*+=pExF8yjV$%Sxs;Xd{cvY}QL@?|@MdZGj5yrymyo z4MgM=JJ>Q;H1Q7DE||B(Fg6u#apjN2cE@k|*avLHC9e=}a3AMa0Ho1%B?H(n@7TO|ErL3%|m{Y~T!xA+4+ zd+Sec%BAoA?QOR6O*Z|fW5?fOFvE6B<7e}k!z2V7^!(6^>}U6#c<2wee$F>M%O1bw zGKiT=^{mMt6|@=I>tls>ga$z-7bssm@rlIo6pf7EF({ zRm^N|<~R0ScU@2Sb=S%BkJ_V;QFaO0p(3RSeUEBa?L0yGMiV67R^ZeRI|1d44$B%a zmPiy9Ed-#WCc*z)pbEB)=qu0q7VWFFq!Yh9=3JS2QB*&zxNv5X&uN%nJ9e~oKC}iF zgd{^CrXVTDpOaJ&6W|ZIZ0l$ijbG2|1)J*>^ng!P(|ZxKSvVh`+Ko?^A4{7ubH$vT zx{i*z;#KSC2E`PM*MxswO9~S)?G-o8>UCnTP+^1?NR=2@%})+=u1CQyPX$d<1Kq+A z%vs`_k3#@g0Dx=aWuOH7=&5nj+~KJI;aOdBkq8SjGNqmgjW4?p6wyWJG*;+~6Y_I& zbMq65^%add(X*g29bUBK`#W}gUrd`QN+07Gd(jaSu_U1x;E<0H zEa(9dY{_VMYlWETaGOkSN1|BK+C932Po=_l$iJ;7aH9*0Mwu}Vx-iR`*m(q*>n6aY z3Z+oO14HrD=-2vh2YOHi5-^!cm8Gr>YIa=PT`1%{fNk6!M@R#{fA#FbPKml)6~P20 z1`0*f8q`8xKe-Wgv%<12JnQQnyXU{?Qb5p`3iPpcN(X5cJ;>$v=-S#Z(JNZ_zB#(& zYdy@KRJwO;-RX|}^mOn3?R4D907142$qzqz zTB}j9g!`i#Uv|z~v}l&|IamZg&|n@y+5C0C-@AF;Dly%K3Yn4d|@i} zw0S@>)vg&21d}bg6rRfie$4_Ve@V5ydj;9v-77!*8A=y>_n#4K++X|ocGk1~^SiVL z>vbec`N;R6hI!SMe`d3l>?fwb{MAjWtflFCm> zqdjdEvu9U88A1W&6Gxw%8{gnN#=VHsa?*bB4?V>_AimbaQ4Kn53gAksICqyTN5su zJD1&}$mz((kWj;@r>z00&nlWd6UqA4QPPQ1{onQD=~bGSDuBTM6;91O2d7F3(W2s9 zLYn8|T-Uz|(uGlC$j(HT1b)7sgrKj;IXEZj>WT+fM&LD1J_OR4Ls*l*q z(0*St?x?Cn66Xlq2=RBXfAIcmuf0F3!jl#b&CDrGE$O=Fk~`|^*v=7bS7u(Zditi- zwW-ZL2jmZbwQJY=ENTCiKfZAN(wlb|t*M++%RhlqRfYV#{G9wl`NvUtlN<7qoXx9x zBKzeX35|WLYW%Zc^=lYDzVEu5<-IgK1gx>U`KST(A29 z7zKa>5}U&3kmea3T`C7PP8?q(!vL&C%aPcrM^Mg1kzT=ZU_koGHY{==3Tvr$@}meu z(76{7H1?;&I71DJEHUJbY5U7kF&c?($w^%6EDR3)04!Cc>mjVaVxT%7K77Y zh?pqBk>{-y%(hC8Bnm!1{Hf0!vV!feb#LkwVyxaMx5<@y*LL}%dvho98^~G} zG!Mgm12%DxTp%-y23ElgP>F!e<8u@r#M`blW%*7XNs4jC{))30i@_o{144R^Rr8*2 z&`0p*=TzY~ufG2^DI z;q(2Q)BlV7uRm}~M}+kHr>C!dWnn&ErK*Cu zE0x>r%5_Y=!9E*3GS~n^U_5eSLiybZxnwPulF6?oQ?HO%i>G#=8S&=)RljeYeqj9x z@a&1IUpOl(sV3iSmhVvVt^C?Gs8pfKH-G)@yI)IBZS@Byro?W5#*eMGzbgOS`0-~wIj{%qH??L=S2NXR ztHxf1SHsRpw0yA>v zFz!3P#c0_0114N`D=T_$``GdAPi)`*1iPhsjS;ks*I=%!9eIAkj-xhnU5(igD{-f> zshbOzynpf4|Gb7RU)uk6%gU84Z}%;`lj%N}&tEE7O~uhZ@RAp>z+(@yf;-KIp8I}x z!DI5P^955(tf|OqvWk_zW+iuA#iVDpn#>zsli$mvI=7$FZGCgP-e?YHo6X_93;UmF zwmN>eWA&Yr&E}k-$*7<8?giVAU#2(g{Ie=s13AS}aA?3%B=_Db)9(y}j{!}bz<8*~ zJ?g%B6!NI+Chq$f<~O#PjBK3i&fUL_9~G&2j~%7mH(fB+3jam%K`7{~!1cNu7L~(+ zy=h;dw&bj>vBtMm9KnNrBUkX)?+a+$*pYEY0AHsXIp-+-6y9(hF$h$CqJVmdLqK&a zaz)CwldWB7-owEOwgIH1fMZBlS);Sa6aa|k1qDt}&g~oVTYJssk3Tk>_X4fr9*@9T z&wOZNx4r$Zl4;pQ*Tg=hzCoX2Y{;`c@qPYdySUmWO6x80W2*PAyVU04t~7VT^GVy+ zhnU@kPx*$lr}N4$i@LL5fcjI#@d_-FBkZq{^@S`jHYmR$t@{QVp0)EJjtpP>CVHKC zwK@aG`T{8vN%%r}=W%B$ z(_Hb|gBcG?AUFkN5Y~VkE(GrtKO*q7;wN+fJOUo29}*gAigXo;osss59xv!U`MCtT z0Y-7tL3UXoH<G9z{;ZqrR6sUVoNd1cHI&I+7p&q;$?!N3uAwtrmOGDX%no4MwBE zYcw26x2D_tR;zm3LQw{z$I14jT^sfninHcc`?<&9(%S_|Fgz!CeQEma<*PGWbp4^j|Y{)20DOhSxob0p(vRs8Wo6THMV&gai%S?{*q({Z?zGt@82bgi}jd`<0OI%h}?mLwImJ5vIN5RxqA_FrH zs@2572~8G=#8x69z5(NV=>~rmtP)1KN?i~;E|k*J)1YM>DD}XM1K28x)-O3(Ze>l-?J=9$=Cy(7F3C?I= zOiomcQC#KDxT_pC^QMT7w4}n6kv>CmQNZ``#3MQW;Ul8Q=rkAw7UD+1DS2AAFt5=8 zA(0!o*B50lJByg6e69S~^~sLO zw|{F_PIhXxNfa*p$t_zOL`Qkrd0#$!O=hMi9nQo;ugPP(9?98#=>=I?S8aao(^>ZT zhF`y0oHk=sMkaa7nFW=1eN=iTkVoP4?m&{jrHbrYIKMKwrruJ`EsJt?C59YnzC*C! zQE}jx$A82GV{%*XJUltl`DgiwiySp_^I88y9q~t86c=iP4J! zOUleNTViVGPR`iymr8w3ZGBv<)8vY4j&06#i|cM)Q)97u{jKbLX4*CPHTjQ2sg`&c zEnW%xe1QwPR>j9#8~m4DwLLeN$2j6+6B4ZEl*vZl{wrR(WvDeV%`t1Tf8LPXfbq*b zW!1kU{S_xw#h^f!DHf-&ED-(&wMYUV2B-?j z6~eSPWM;Y7&#Oer#)Pmg3sa{oS+olnaA``?^re-%BGFb@dQ7QI$e5a!8S92~PqrcW z%%9*w@2k%r?vR+n>=#QrVX2g@V=IT<{4WbG{r+p;zjT3mV*@q6gZa~+$nVMWBaO)= z(wr-w`rxy_AAe~0qngDl_DX%?Ehd@uOH~qD* zwHg;Z@OSyv7j9++e|`O1ksR-mTZaNy$`}2WEw7hQ^6Gt0{p{86?_I%@+xEVSsR4Ns z&@>7TC3|*7(9tHD?tbWIUj@DF`(gVBa;IdW66dL8xw72&(=`%gnh zzCs1%*%DQD!bmw$!sq|PoyLagim<*d!1{JI(VBo(P%#kG@j!@A$c(}>yt)?AcAAc2 z@J=zY5+y+c4O{4OQ9sO*D%dbC07Zs_2{OW>#H3(>#ID;VMJbP904q|7Nu-?yyrbMn~K9OnSo4Fk@c z)L8C(P5yJcZF;~~_JlV8LqFap?nsI^<-%FC;u!KJ(Ug!T#wSog@j;JP4s(1%Im~fR zISKJ%T7pTGUs8NphLdtl@$8n=Zd<7rjaq-iUuw=|`8UZgd>Wmb;xa~$zD2TtZ;eJ9 zT`9TIpR$UZaXdqZN7Igq5s^!a3Kj~lCj;(!JkeM~M1#cqv_}Ts%8;Hh zH12(EWcaYY~)7fzL!mxZ`r)XYE+ zt0PLtbgAx?I7Pm7M1JY^N97k^h`WTX8fIm;KgP;mi1REbqDk8un00no0QaC}BysLa zx3F|qR+-lT;-vs4*|IY6gBc`0&i*HwK019KPci|*!?%>)e^1Fn^I|@ak*BfZi{;nY zyPtP_#j9P|C%d zIzDS(x!~yqYn5Ecf2Jh9=^Lm*>{(AS!%FC^F4wi_dSGSZB6y*CRQIgzW!*cvk942n z8zGA2hoCFA71%OBmJ$;}uWT`($E@x(gc!ZDg-~`0;6^B1i7*L+hrI!1y{AYTqa2d@@6zTCo1Q!H`o@u428IC!p?{x+;^E?Y0l5?UBS4;X7dxD;~Fnwu*TU^wrhboN7w;8N~lBoLGfs-|Qr^6m6 z2+l;l%xXx>v088$i^-UZMLaqhS4nhP%WM4Bgv6RlriFS|_PQ@RG{wp~{yIG%EZUUo zugVZZ>+5|x4?i${#-&@97wLlyF}@Rnc9YvxVpFd7iqUC_a7yKjN)&H{44Es<7~^)Q zj`cVli3wAjPDi+ket?a>MUOv_72z=D&!M?0i14E< znc=Akr;1+YFkp|BV2duyO}yg#tJ$WZ$8Pq0S2##myV-&$Vlc3FA#2Kmc5Q-#L0 z5dz+Ga;S1VUEFbVF#@!6v5 zh!ce$wCeIJWPazJe&>?M~T7=80Km%%z<$p*1`g0SAVL7MV*HckBHJs zx(s}m8rCDeNedfv-)7sjuu&Jww`gIL&drZ#VT&%8Kcj{1y2*k7-b6p-jkmzhX%}o^ zbi&7&51O0JIJbx(G##NnXf$m>H~1emZ8;TqtN9^B958d9Djx*_BnRC2c=rLL}j zV9Q`vN9VAwzIkKBH@&&9ZHq5ZToNwy)%5iElvhK(!N^c#aATwm85+=@KD43+_=!sE z2Spn}bbsG)&8Emue=i;uBBlfKE3@Y{^Evd%Nyq}q^SR(#-++v4WW;ybv|7X-&TfSF~Z~hqFWjn z9O~-t^92jb3X7GG{Lcz+#D_%iDb#h;r4bw)Q78J)4gJcsQ+e}ELq&O7k#4+U?Z~0# zRP)d?btjcIh&tMkzE|nCZp1Ysmg2jxAdDb1UP>Qw(Nil@5796-_C%V8A{eLk$e?ey z-#6SD@tqmkp-Ag6eRz96UgAwV2Fo`**xVNBZ656QH4hIDcD0NsN&5PSyILbd+CUGY z76PVohI(+=cY3V92^Mu{U`eNd>@YyM5+r&NdQSb`=CjHyRK85tIXpZ7y&h^_vkFUv zUH$(}2}KwwwO9I-(JDgbZz{8>2Orrt6v2Ci#-ZE4`p2Kc8wN^9z$xJ#-EN#QU9GzY zwu1KRu406);cgXD1+m@36aLx@U1YH&13UfBU`{0vPIbGEn!R9GPWFkVOFwLY&BcM z*0Lt-|C(6~@Y!cN8*624EW+AZ2kT^AY(47+^Q{;9l>KagZGa7wAvO$?up8MXcq8A! zwzBiEF}?ueliS!RyNF%PwzEs%c5o-#1xb?2pt`z;UCypxSF)?v)$AI!mtD*DvHk1- z`xcC{UC(Y{H^N8IL0ITM%#N^|*|*s(>{fOgyPe$uPgi%byV*VLUUnb*4!fUymp#B9 zWDl{2+4tBZ>{0d@+^s&ro@C!=PqC-j57<#y<9wDq$9~9u#GYp_uou~n*-Pvv@Id`C zdxgCUBf39hud|=CH`tr(E%r8hhy8-R%id$ZWWQqXvtP4g>;rb3eaJpyzkxN?-@$Xy z$LtU6kL*wE6ZR?ljD61j%)VfMVSix4=7)jl*ytck(D6&0XBhW4MQVc`T3P@jQVi@+1y^3#>Y)@-&{#GdL_q z@GPFqb9gS#c`5L~KH}Q46nYZv( z-o_)m9ZCR% zG2hNF;XC+FzKdVVFXOxU9)3B$f?vt6;#WgcbuYh`@8kRV0sbw19lsuQ|Bd`6evlvH zhxrkHGygWfh2P3=F#jHZgg?q3=tm{3-r4{{cVBpW)B)=lBo#kNETa1^y!cF@K5wg#VPk%wOTJ^4Iv!`0M=V{0;sl ze~Z7(-{HUD@ACKfFZr+d`~27Z82^AD=O6Nq_;2`c`S1Ae`N#YZ{Ez%k{1g5u|BQdm z|IEMOf8l@Sf8&4W|KR`RU-GZ`34W48H>a)ewVPskSv z1n}a7VxdF`2&F<07AV6)nNTiN2$jMlVX`nqs1l|M)k2L>E7S?~!Ze{lm@do^W(u=} z*}@!Qt}suSFEk1ZgoVN)VX?48SSlMn~gl3^dXcgLoh|n%{ z2%SQguwLjEdW2q~Pv{p0gbl)=FeD5MBf>^uldxIXB5W1T6V4YdfD*|zVN|$CxLDXO zTq5icb_%a^VW$O5rNuYT+7TuW+rfPuMRU5WXc`CtNSwAlxY2BpehD z35SIv!p*|Bg2=@!$6&}#-lRA2uhlZryk)f_u z{ZOQNu(i_|>Dw6T=^uzlop>G=hlZO6&2(vs^bQPf5l29^i0xfHy~g3rCQu+95kA~$ zpm5jFFz@fy4@P?XH%1Iw`}=#Fy84XDy?8^<5?BLfsCb@jFMZ?+8dG;e8Y?HX+DiJ;Db zNb|4(OEsvfP9rr%DX^!%wOefOY3?xNW7-Bf`}-n8=8gS5BfXI(w8x?asREN09vRSY z7;Notix^ta9k>g_%^f0sLt;yRf47k?w8BdRgI#^Y`qt*&$Y8Tb%PZdZwCTHso3RjD zh9jGYn>r&z1)7!crmnW(PBY$h^fmQF+J~)b5KHE8WYD5MD3qa14X+;=8t!V}BGR{5 zy87CXPR*xW!>{q|sHvXV|f@z>l%BMx zL8TQ&H9Rt4Rs#w|C|yKwgysx&ZH+XwkM#6dweV1Hb5D;mvbnXVxwrXrv&4?B_F)l( zV>{-^V8j^N0zkuPm?+TN(?1lkqQCmO`Z|=hOX$zOh_SV~C(_r}Jg6VUR-wPw(AwYI zi}BX?Hh1(zhRx&sH8OCzAE|u+_u);E$gmBcJ}^Ku?5h8&g&CfB0W8p zR_fMvbnI}%+=*dqQlVQ3(tI~4p^*WTa;FZ7Qh~GS3`9ns6{8g3I4f#o;OtCP3~+dV zOGLkE5Ocm$8g3ry9?}D&qR&h%gI$sKR%~L-1i9)wkvazZM+Sga`nn|mS5 z$Z!*VDdq_UF-g?`b*n`UDt(1{1I*qxBo6ft0@QF(vKf>RCeQfFMj(PULWMOE?d}J_ zbO8R_uq3tgV~i~tI8#dNIB3%Y;rL;|>o9hC14cmlAjZBK7!f$n4BXxcq&d>lVgz2m zICn(sN*625pry;IKB|yvpry2_x6OjQ!=3#@==_LrXrybHM$AY+MK$VMu~0=KSYi5s zm1(6^mJ|AfmXWR=%$5!#G7r$YV`}b2?ah6y5q)o@t-EX3(oRi6E$bs_dIal0r_%3Y zdvSXts;z$n1J#6f;!2$veO8PLe`iGj{?2-)Q8Ay%Z&8CvMxz=gjH;ARNeyk0p>8Z2 z`kv+ix+#D%Z0+rDq3=>=qg8`<1>VdXM*4@ z*#IiVra)PRWx~p085+Ti#PsbN09cQ-s39aPFSQPgY~4zI*A;1vU;(89iOR8`2@;{B zAL{Ii^t9Q>7aFxSQM5!g0lfl-M!JSN(W8Svb`e^5Hn+9`L20YDf&ml&IV(m5kh7u) zK~2o0AgIpa-ky-yIy6+O2W$dmnpLby9jRc^A*_xrzrj<OOZWXSXNDEchhc(j6pqt1Gw_b9G3NSBax3s%#S zmWaBvX%FIN46}(YO7!V8)R~4hzzv9MpmY#`n|t-`plQ1Yh32+CvAv|M z#NN_1+ycZ7Y^)9gFk#Q2Wmvf>QI4K|RCI=zvQ2m%8JPH%;L17Stvbawfz0jSG-SXu z9qjLFlQ1zxHlvwcEwr`_b#EEKqSik$IJ98|ivq|2fJ(o<9cZ~HBGQEx@ZqijVQ7Sg zHXJt4=B8_7L}(f5;2XQ8O_8paerz22@P`Ct0lV_;m<}rDrnq2?`T^r>aF0rY)2pz( ztsnG&vi;CHzpUK45u`Y%Ql(8uRbFgUS2iW0sh^?(bSb3^ja7MwE@8Tq(WRU&6^4<% zu7;ADV)S)$31TWJQ$;B~Ql<*ZR6&_4C{qPxs;Cf~g2hUX778Ipuo%?@i-T%uwJ0c9 zj7-5|WC|7|Q?Qsal@!y3-j-0N63SG9YJw%GCRjo_N+?GOI4p?)>g>sZ?&8yc6tS?auu2)h})>5rX_)S#0r9Q0P zsqi3`5u{p!RBMoG4Jt1vYf#HNjVcaN#UUy-M43XADMXnfL=X`ohzJoxgo-PqjS=8d1PLTUR91*UB19k&B9I6XNQ4L^ zLIe__5~?IXl>{gU0Yiv@Aw<9sB47v+FoXygLIeyU0)`L)Lx_MOM8FUtU#BTP9k=(tdha0PlBIdGvI7<7av2Mv0N z20es9$AxmxpoeJCLp10i8uSnidWZ%+M1vlpK@ZWOhiK44H0U83^biethz31GgC3$m z4`I-8p&Wz>LWBuIzy$4qvWPN20_EzA3Q$d98u~B|eOSW>fpT>^1*pC-0YI1lAWSGB zOt2KD@ekAZhiUx7H2z^4|1gbzn8rU$;~%E+57YREY5c=9{$U#bFpYnh#y?EsAExmS z)A)x2>a+~hXf3Q!=X{_hptiiGRJ*GaE>NR2wML!!ftoVyeYtiYFRw;>uGQ{!+Pz-8 zPgC!;TD`Sey|r4swOYNkTD`Sey|r4swOYNkTD`Sey|r4swOYNkTD`Sey|r4s8qy5Z zY4z4=_10?v$(?k d0mRO}xo^G_%I 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 SdSQM28?+M|D(2r_;{O0|uQ74} diff --git a/doc/_static/fontawesome-webfont.woff2 b/doc/_static/fontawesome-webfont.woff2 deleted file mode 100644 index 4d13fc60404b91e398a37200c4a77b645cfd9586..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 77160 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 diff --git a/doc/_templates/autosummary/class.rst b/doc/_templates/autosummary/class.rst index e4adacd5..fe474401 100644 --- a/doc/_templates/autosummary/class.rst +++ b/doc/_templates/autosummary/class.rst @@ -1,12 +1,12 @@ -{{ fullname }} -{{ underline }} +{{ fullname | escape | underline }} .. currentmodule:: {{ module }} .. autoclass:: {{ objname }} - :special-members: __contains__,__getitem__,__iter__,__len__,__add__,__sub__,__mul__,__div__,__neg__,__hash__ + :special-members: __contains__,__getitem__,__iter__,__len__,__add__,__sub__,__mul__,__div__,__neg__ + :members: - {% block methods %} - {% endblock %} +.. _sphx_glr_backreferences_{{ fullname }}: -.. include:: {{module}}.{{objname}}.examples +.. minigallery:: {{ fullname }} + :add-heading: diff --git a/doc/_templates/autosummary/function.rst b/doc/_templates/autosummary/function.rst index bdde2420..bd78b8e8 100644 --- a/doc/_templates/autosummary/function.rst +++ b/doc/_templates/autosummary/function.rst @@ -1,12 +1,10 @@ -{{ fullname }} -{{ underline }} +{{ fullname | escape | underline }} .. currentmodule:: {{ module }} .. autofunction:: {{ objname }} -.. include:: {{module}}.{{objname}}.examples +.. _sphx_glr_backreferences_{{ fullname }}: -.. raw:: html - -

+.. minigallery:: {{ fullname }} + :add-heading: diff --git a/doc/conf.py b/doc/conf.py index 8abf8f39..4ae798d1 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Expyfun documentation build configuration file, created by # sphinx-quickstart on Fri Jun 11 10:45:48 2010. @@ -14,75 +13,75 @@ import inspect import os -from os.path import relpath, dirname import sys from datetime import date -import sphinx_gallery # noqa -import sphinx_bootstrap_theme # noqa -from numpydoc import numpydoc, docscrape # noqa +from os.path import dirname, relpath + +import sphinx # noqa +from numpydoc import docscrape, numpydoc # noqa # Work around Pyglet annoyingness -assert 'pyglet' not in sys.modules -if 'sphinx' in sys.modules: - s = sys.modules.pop('sphinx') - import pyglet # noqa - sys.modules['sphinx'] = s - del s +assert "pyglet" not in sys.modules +s = sys.modules.pop("sphinx") +import pyglet # noqa + +sys.modules["sphinx"] = s +del s + +import expyfun # noqa: E402 # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. curdir = os.path.dirname(__file__) -sys.path.append(os.path.abspath(os.path.join(curdir, '..', 'expyfun'))) -sys.path.append(os.path.abspath(os.path.join(curdir, 'sphinxext'))) +sys.path.append(os.path.abspath(os.path.join(curdir, "sphinxext"))) + -import expyfun -if not os.path.isdir('_images'): - os.mkdir('_images') +if not os.path.isdir("_images"): + os.mkdir("_images") # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. -needs_sphinx = '1.8' +needs_sphinx = "1.8" # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.autosummary', - 'sphinx.ext.coverage', - 'sphinx.ext.doctest', - 'sphinx.ext.intersphinx', - 'sphinx.ext.linkcode', - 'sphinx.ext.mathjax', - 'sphinx.ext.todo', - 'sphinx_gallery.gen_gallery', - 'sphinx_fontawesome', - 'numpydoc', - 'sphinx_bootstrap_theme', + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.coverage", + "sphinx.ext.doctest", + "sphinx.ext.intersphinx", + "sphinx.ext.linkcode", + "sphinx.ext.mathjax", + "sphinx.ext.todo", + "sphinx_gallery.gen_gallery", + "numpydoc", ] autosummary_generate = True -autodoc_default_options = {'inherited-members': None} +autodoc_default_options = {"inherited-members": None} # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'expyfun' +project = "expyfun" td = date.today() -copyright = u'2013-%s, expyfun developers. Last updated on %s' % (td.year, - td.isoformat()) +copyright = ( # noqa: A001 + f"2013-{td.year}, expyfun developers. Last updated on {td.isoformat()}" +) nitpicky = True # The version info for the project you're documenting, acts as replacement for @@ -96,81 +95,86 @@ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of documents that shouldn't be included in the build. unused_docs = [] # List of directories, relative to source directory, that shouldn't be searched # for source files. -exclude_trees = ['_build'] +exclude_trees = ["_build"] # The reST default role (used for this markup: `text`) to use for all # documents. default_role = "autolink" # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. -modindex_common_prefix = ['expyfun.'] +modindex_common_prefix = ["expyfun."] # If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# keep_warnings = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'bootstrap' +html_theme = "pydata_sphinx_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. html_theme_options = { - 'navbar_title': 'expyfun', - 'source_link_position': "nav", # default - 'bootswatch_theme': "yeti", - 'navbar_sidebarrel': False, # Render the next/prev links in navbar? - 'navbar_pagenav': True, - 'globaltoc_depth': 0, - 'navbar_class': "navbar", - 'bootstrap_version': "3", # default - 'navbar_links': [ - ("Getting started", "getting_started"), - ("Examples", "auto_examples/index"), - ("API reference", "python_reference"), + "logo": { + "text": "expyfun", + }, + "icon_links": [ + dict( + name="GitHub", + url="https://github.com/LABSN/expyfun", + icon="fa-brands fa-square-github", + ), ], - } + "icon_links_label": "External Links", # for screen reader + "use_edit_page_button": False, + "navigation_with_keys": False, + "show_toc_level": 1, + "article_header_start": [], # disable breadcrumbs + "navbar_end": ["theme-switcher", "navbar-icon-links"], + "footer_start": ["copyright"], + "secondary_sidebar_items": ["page-toc", "edit-this-page"], +} # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -html_logo = "_static/favicon.ico" +# html_logo = "_static/favicon.ico" # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 @@ -180,36 +184,36 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static', '_images'] +html_static_path = ["_static", "_images"] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. -#html_extra_path = [] +# html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +html_sidebars = {"getting_started": []} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. html_show_sourcelink = False @@ -219,52 +223,55 @@ html_show_sphinx = False # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # variables to pass to HTML templating engine -build_dev_html = bool(int(os.environ.get('BUILD_DEV_HTML', False))) +build_dev_html = bool(int(os.environ.get("BUILD_DEV_HTML", False))) -html_context = {'use_google_analytics': True, 'use_twitter': True, - 'use_media_buttons': True, 'build_dev_html': build_dev_html} +html_context = { + "use_google_analytics": True, + "use_twitter": True, + "use_media_buttons": True, + "build_dev_html": build_dev_html, +} # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'expyfun-doc' +htmlhelp_basename = "expyfun-doc" trim_doctests_flags = True # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { - 'python': ('https://docs.python.org/3', None), - 'numpy': ('https://numpy.org/devdocs', None), - 'scipy': ('https://scipy.github.io/devdocs', None), - 'matplotlib': ('https://matplotlib.org', None), - 'sklearn': ('https://scikit-learn.org/stable', None), - 'pandas': ('https://pandas.pydata.org/pandas-docs/stable', None), - 'sounddevice': ('https://python-sounddevice.readthedocs.io', None), - 'rtmixer': ('https://python-rtmixer.readthedocs.io/en/latest', None), - 'pyglet': ('https://pyglet.readthedocs.io/en/latest', None), - 'mne': ('https://mne-tools.github.io/dev', None), + "python": ("https://docs.python.org/3", None), + "numpy": ("https://numpy.org/devdocs", None), + "scipy": ("https://scipy.github.io/devdocs", None), + "matplotlib": ("https://matplotlib.org/stable", None), + "pandas": ("https://pandas.pydata.org/pandas-docs/stable", None), + "sounddevice": ("https://python-sounddevice.readthedocs.io", None), + "rtmixer": ("https://python-rtmixer.readthedocs.io/en/latest", None), + "pyglet": ("https://pyglet.readthedocs.io/en/latest", None), + "mne": ("https://mne.tools/dev", None), } -examples_dirs = ['../examples'] -gallery_dirs = ['auto_examples'] +examples_dirs = ["../examples"] +gallery_dirs = ["auto_examples"] sphinx_gallery_conf = { - 'doc_module': ('expyfun',), - 'examples_dirs': examples_dirs, - 'gallery_dirs': gallery_dirs, - 'backreferences_dir': 'generated', - 'plot_gallery': 'True', # Avoid annoying Unicode/bool default warning - 'filename_pattern': r'/.*(?>> expyfun.get_config() @@ -165,8 +163,7 @@ The fixed, hardware-dependent settings for a given system get written to an ``expyfun.json`` file. You can use :func:`expyfun.get_config_path` to get the path to your config file. Some sample configurations: -- A TDT-based M/EEG+pupillometry machine: - +A TDT-based M/EEG+pupillometry machine .. code-block:: JSON { @@ -182,8 +179,7 @@ get the path to your config file. Some sample configurations: "TRIGGER_CONTROLLER": "tdt" } -- A sound-card-based EEG system: - +A sound-card-based EEG system .. code-block:: JSON { diff --git a/doc/git_diagram.py b/doc/git_diagram.py index 003910f4..231f06ca 100644 --- a/doc/git_diagram.py +++ b/doc/git_diagram.py @@ -1,16 +1,13 @@ -# -*- coding: utf-8 -*- - -import os -from os import path as op +import pygraphviz as pgv -title = 'git flow diagram' +title = "git flow diagram" -font_face = 'Arial' +font_face = "Arial" node_size = 12 node_small_size = 9 edge_size = 9 -local_color = '#7bbeca' -remote_color = '#ff6347' +local_color = "#7bbeca" +remote_color = "#ff6347" legend = """ < @@ -20,102 +17,94 @@ Remote repositories >""" % (edge_size, local_color, remote_color) -legend = ''.join(legend.split('\n')) +legend = "".join(legend.split("\n")) nodes = dict( - upstream='LABSN/expyfun\n' - 'master\n' - ' ', - maint='Eric89GXL/expyfun\n' - 'master\n' - 'other_branch', - dev='rkmaddox/expyfun\n' - 'master\n' - 'fix_branch', - maint_clone='/home/larsoner/expyfun\n' - 'master (origin/master)\n' - 'other_branch (origin/other_branch)\n' - 'ross_branch (rkmaddox/fix_branch)', - dev_clone='/home/rkmaddox/expyfun\n' - 'master (origin/master)\n' - 'fix_branch (origin/fix_branch)\n' - ' ', - user_clone='/home/akclee/expyfun\n' - 'master (origin/master)\n' - ' \n' - ' ', + upstream="LABSN/expyfun\n" "master\n" " ", + maint="Eric89GXL/expyfun\n" "master\n" "other_branch", + dev="rkmaddox/expyfun\n" "master\n" "fix_branch", + maint_clone="/home/larsoner/expyfun\n" + "master (origin/master)\n" + "other_branch (origin/other_branch)\n" + "ross_branch (rkmaddox/fix_branch)", + dev_clone="/home/rkmaddox/expyfun\n" + "master (origin/master)\n" + "fix_branch (origin/fix_branch)\n" + " ", + user_clone="/home/akclee/expyfun\n" "master (origin/master)\n" " \n" " ", legend=legend, ) -remote_space = ('maint', 'dev', 'upstream') -local_space = ('maint_clone', 'dev_clone', 'user_clone') +remote_space = ("maint", "dev", "upstream") +local_space = ("maint_clone", "dev_clone", "user_clone") edges = ( - ('maint_clone', 'maint', 'origin'), - ('dev_clone', 'dev', 'origin'), - ('user_clone', 'upstream', 'origin'), - ('maint_clone', 'upstream', 'upstream'), - ('maint_clone', 'dev', 'rkmaddox'), - ('dev_clone', 'upstream', 'upstream'), + ("maint_clone", "maint", "origin"), + ("dev_clone", "dev", "origin"), + ("user_clone", "upstream", "origin"), + ("maint_clone", "upstream", "upstream"), + ("maint_clone", "dev", "rkmaddox"), + ("dev_clone", "upstream", "upstream"), ) subgraphs = ( - [('upstream', 'maint', 'dev'), ('GitHub')], - [('maint_clone'), ('Maintainer')], - [('dev_clone'), ("Developer")], - [('user_clone'), ("User")], + [("upstream", "maint", "dev"), ("GitHub")], + [("maint_clone"), ("Maintainer")], + [("dev_clone"), ("Developer")], + [("user_clone"), ("User")], ) -import pygraphviz as pgv g = pgv.AGraph(name=title, directed=True) for key, label in nodes.items(): - label = label.split('\n') + label = label.split("\n") if len(label) > 1: - label[0] = ('<' % node_size - + label[0] + '') + label[0] = '<' % node_size + label[0] + "" for li in range(1, len(label)): - label[li] = ('' % node_small_size - + label[li] + '') + label[li] = ( + '' % node_small_size + + label[li] + + "" + ) label[-1] = label[-1] + '
>' label = '
'.join(label) else: label = label[0] - g.add_node(key, shape='plaintext', label=label) + g.add_node(key, shape="plaintext", label=label) # Create and customize nodes and edges for edge in edges: g.add_edge(*edge[:2]) e = g.get_edge(*edge[:2]) if len(edge) > 2: - e.attr['label'] = ('<' + - '
'.join(edge[2].split('\n')) + - '
>') - e.attr['fontsize'] = edge_size + e.attr["label"] = ( + "<" + + '
'.join(edge[2].split("\n")) + + '
>' + ) + e.attr["fontsize"] = edge_size g.get_node # Change colors -for these_nodes, color in zip((local_space, remote_space), - (local_color, remote_color)): +for these_nodes, color in zip((local_space, remote_space), (local_color, remote_color)): for node in these_nodes: - g.get_node(node).attr['fillcolor'] = color - g.get_node(node).attr['style'] = 'filled' + g.get_node(node).attr["fillcolor"] = color + g.get_node(node).attr["style"] = "filled" # Create subgraphs for si, subgraph in enumerate(subgraphs): - g.add_subgraph(subgraph[0], 'cluster%s' % si, - label=subgraph[1], color='black') + g.add_subgraph(subgraph[0], "cluster%s" % si, label=subgraph[1], color="black") # Format (sub)graphs for gr in g.subgraphs() + [g]: for x in [gr.node_attr, gr.edge_attr]: - x['fontname'] = font_face -g.node_attr['shape'] = 'box' + x["fontname"] = font_face +g.node_attr["shape"] = "box" -g.get_node('legend').attr.update(shape='plaintext', margin=0, rank='sink') +g.get_node("legend").attr.update(shape="plaintext", margin=0, rank="sink") # put legend in same rank/level as inverse -l = g.add_subgraph(['legend', 'inv'], name='legendy') -l.graph_attr['rank'] = 'same' +ll = g.add_subgraph(["legend", "inv"], name="legendy") +ll.graph_attr["rank"] = "same" -g.layout('dot') -g.draw('git_flow.svg', format='svg') +g.layout("dot") +g.draw("git_flow.svg", format="svg") diff --git a/doc/index.rst b/doc/index.rst index 4332eabc..bc1f5195 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1,17 +1,11 @@ -.. raw:: html +======= +expyfun +======= -
+.. rst-class:: h4 font-weight-light my-4 -==================== -Expyfun |headphones| -==================== - -A high-precision auditory and visual stimulus delivery library for -psychoacoustics in Python. - -.. raw:: html - -
+ A high-precision auditory and visual stimulus delivery library for + psychoacoustics in Python. Purpose ------- @@ -49,3 +43,10 @@ Hardware support - Mouse responses - Cedrus response boxes - Joystick control / responses + +.. toctree:: + :hidden: + + getting_started.rst + python_reference.rst + auto_examples/index.rst diff --git a/doc/parallel_installation.rst b/doc/parallel_installation.rst index eb94bf7a..8b5be040 100644 --- a/doc/parallel_installation.rst +++ b/doc/parallel_installation.rst @@ -14,7 +14,7 @@ USB protocol itself, which is not designed for low-latency control. Instructions differ between Linux and Windows: -- |linux| Linux +Linux On Linux, you need ``pyparallel``:: $ pip install pyparallel @@ -28,7 +28,7 @@ Instructions differ between Linux and Windows: 5. ``$ ls /dev/parport*`` to get the parallel port address, e.g. ``'/dev/parport0'``, and set this as ``TRIGGER_ADDRESS`` in the config. -- |windows| Windows +Windows If you are on a modern Windows system (i.e., 64-bit), you'll need to: - Download the latest "binaries" archive from the `InpOut32 site`_ diff --git a/environment_test.yml b/environment_test.yml index 46337dc7..688ad5db 100644 --- a/environment_test.yml +++ b/environment_test.yml @@ -1,20 +1,19 @@ name: test channels: -- conda-forge + - conda-forge dependencies: -- scipy -- matplotlib -- pandas -- h5py -- coverage -- setuptools -- mne-base -- numpydoc -- pytest -- pytest-cov -- pytest-timeout -- pillow -- joblib -- ffmpeg<6 -- codecov + - scipy + - matplotlib + - pandas + - h5py + - coverage + - setuptools + - mne-base + - numpydoc + - pytest + - pytest-cov + - pytest-timeout + - pillow + - joblib + - ffmpeg<6 # Do pip separately diff --git a/examples/analysis/analysis_demo.py b/examples/analysis/analysis_demo.py index 768f8622..bd66f7b5 100644 --- a/examples/analysis/analysis_demo.py +++ b/examples/analysis/analysis_demo.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ ============= Analysis demo @@ -12,9 +11,9 @@ # # License: BSD (3-clause) +import matplotlib.pyplot as plt import numpy as np import pandas as pd -import matplotlib.pyplot as plt import expyfun.analyze as ea @@ -26,7 +25,7 @@ a_prob = 0.9 b_prob = 0.6 f_prob = 0.2 -subjs = ['a', 'b', 'c', 'd', 'e'] +subjs = ["a", "b", "c", "d", "e"] a_hit = np.random.binomial(targets, a_prob, len(subjs)) b_hit = np.random.binomial(targets, b_prob, len(subjs)) a_fa = np.random.binomial(foils, f_prob, len(subjs)) @@ -35,25 +34,43 @@ b_miss = targets - b_hit a_cr = foils - a_fa b_cr = foils - b_fa -data = pd.DataFrame(dict(a_hit=a_hit, a_miss=a_miss, a_fa=a_fa, a_cr=a_cr, - b_hit=b_hit, b_miss=b_miss, b_fa=b_fa, b_cr=b_cr), - index=subjs) +data = pd.DataFrame( + dict( + a_hit=a_hit, + a_miss=a_miss, + a_fa=a_fa, + a_cr=a_cr, + b_hit=b_hit, + b_miss=b_miss, + b_fa=b_fa, + b_cr=b_cr, + ), + index=subjs, +) # calculate dprimes -a_dprime = ea.dprime(data[['a_hit', 'a_miss', 'a_fa', 'a_cr']]) -b_dprime = ea.dprime(data[['b_hit', 'b_miss', 'b_fa', 'b_cr']]) +a_dprime = ea.dprime(data[["a_hit", "a_miss", "a_fa", "a_cr"]]) +b_dprime = ea.dprime(data[["b_hit", "b_miss", "b_fa", "b_cr"]]) results = pd.DataFrame(dict(ctrl=a_dprime, test=b_dprime)) # plot -subplt, barplt = ea.barplot(results, axis=0, err_bars='sd', lines=True, - brackets=[(0, 1)], bracket_text=[r'$p < 10^{-9}$']) -subplt.yaxis.set_label_text('d-prime +/- 1 s.d.') -subplt.set_title('Each line represents a different subject') +subplt, barplt = ea.barplot( + results, + axis=0, + err_bars="sd", + lines=True, + brackets=[(0, 1)], + bracket_text=[r"$p < 10^{-9}$"], +) +subplt.yaxis.set_label_text("d-prime +/- 1 s.d.") +subplt.set_title("Each line represents a different subject") # significance brackets example trials_per_cond = 100 -conds = ['ctrl', 'test'] -diffs = ['easy', 'hard'] -colnames = ['-'.join([x, y]) for x, y in zip(conds * 2, - np.tile(diffs, (2, 1)).T.ravel().tolist())] +conds = ["ctrl", "test"] +diffs = ["easy", "hard"] +colnames = [ + "-".join([x, y]) + for x, y in zip(conds * 2, np.tile(diffs, (2, 1)).T.ravel().tolist()) +] cond_prob = [0.9, 0.8] diff_prob = [0.9, 0.7] cond_block = np.tile(np.atleast_2d(cond_prob).T, (2, len(subjs))).T @@ -62,19 +79,26 @@ shape = (len(subjs), len(conds) * len(diffs)) rawscores_targ = np.random.binomial(trials_per_cond, probs, shape) rawscores_foil = np.random.binomial(trials_per_cond, probs, shape) -hmfc = np.c_[rawscores_targ.ravel(), - (trials_per_cond - rawscores_targ).ravel(), - (trials_per_cond - rawscores_foil).ravel(), - rawscores_foil.ravel()] +hmfc = np.c_[ + rawscores_targ.ravel(), + (trials_per_cond - rawscores_targ).ravel(), + (trials_per_cond - rawscores_foil).ravel(), + rawscores_foil.ravel(), +] dprimes = ea.dprime(hmfc).reshape(shape) results = pd.DataFrame(dprimes, index=subjs, columns=colnames) -subplt, barplt = ea.barplot(results, axis=0, err_bars='sd', lines=True, - groups=[(0, 1), (2, 3)], group_names=diffs, - bar_names=conds * 2, bracket_group_lines=True, - brackets=[(0, 1), (2, 3), (0, 2), (1, 3), - ([0, 1], 3)], # [2, 3] - bracket_text=['foo', 'bar', 'baz', 'snafu', - 'foobar']) -subplt.yaxis.set_label_text('d-prime +/- 1 s.d.') -subplt.set_title('Each line represents a different subject') +subplt, barplt = ea.barplot( + results, + axis=0, + err_bars="sd", + lines=True, + groups=[(0, 1), (2, 3)], + group_names=diffs, + bar_names=conds * 2, + bracket_group_lines=True, + brackets=[(0, 1), (2, 3), (0, 2), (1, 3), ([0, 1], 3)], # [2, 3] + bracket_text=["foo", "bar", "baz", "snafu", "foobar"], +) +subplt.yaxis.set_label_text("d-prime +/- 1 s.d.") +subplt.set_title("Each line represents a different subject") plt.show() diff --git a/examples/analysis/parse_demo.py b/examples/analysis/parse_demo.py index 1470b8ac..119e2e88 100644 --- a/examples/analysis/parse_demo.py +++ b/examples/analysis/parse_demo.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ ============ Parsing demo @@ -17,13 +16,13 @@ print(__doc__) -data = read_tab('sample.tab') # from simple_experiment -print('Number of trials: %s' % len(data)) +data = read_tab("sample.tab") # from simple_experiment +print("Number of trials: %s" % len(data)) keys = list(data[0].keys()) -print('Data keys: %s\n' % keys) +print("Data keys: %s\n" % keys) for di, d in enumerate(data): - if d['trial_id'][0][0] == 'multi-tone': - print('Trial %s multi-tone' % (di + 1)) - targs = ast.literal_eval(d['multi-tone trial'][0][0]) - presses = [int(k[0]) for k in d['keypress']] - print(' Targs: %s\n Press: %s' % (targs, presses)) + if d["trial_id"][0][0] == "multi-tone": + print("Trial %s multi-tone" % (di + 1)) + targs = ast.literal_eval(d["multi-tone trial"][0][0]) + presses = [int(k[0]) for k in d["keypress"]] + print(" Targs: %s\n Press: %s" % (targs, presses)) diff --git a/examples/basic_experiment.py b/examples/basic_experiment.py index 1c2d549b..e16243ef 100644 --- a/examples/basic_experiment.py +++ b/examples/basic_experiment.py @@ -18,26 +18,26 @@ print(__doc__) # set configuration -fs = 24414. # default for ExperimentController +fs = 24414.0 # default for ExperimentController dur = 1.0 tone = np.sin(2 * np.pi * 1000 * np.arange(int(fs * dur)) / float(fs)) tone *= 0.01 * np.sqrt(2) # Set RMS to 0.01 -max_wait = 1. if not building_doc else 0. +max_wait = 1.0 if not building_doc else 0.0 -with ExperimentController('testExp', participant='foo', session='001', - output_dir=None, version='dev') as ec: - ec.screen_prompt('Press a button when you hear the tone', - max_wait=max_wait) +with ExperimentController( + "testExp", participant="foo", session="001", output_dir=None, version="dev" +) as ec: + ec.screen_prompt("Press a button when you hear the tone", max_wait=max_wait) dot = FixationDot(ec) ec.load_buffer(tone) dot.draw() screenshot = ec.screenshot() # only because we want to show it in the docs - ec.identify_trial(ec_id='tone', ttl_id=[0, 0]) + ec.identify_trial(ec_id="tone", ttl_id=[0, 0]) ec.start_stimulus() - presses = ec.wait_for_presses(dur if not building_doc else 0.) + presses = ec.wait_for_presses(dur if not building_doc else 0.0) ec.trial_ok() - print('Presses:\n{}'.format(presses)) + print(f"Presses:\n{presses}") analyze.plot_screen(screenshot) diff --git a/examples/experiments/drawing_methods.py b/examples/experiments/drawing_methods.py index dbe85af1..4265b8a9 100644 --- a/examples/experiments/drawing_methods.py +++ b/examples/experiments/drawing_methods.py @@ -11,16 +11,22 @@ import numpy as np -from expyfun import visual, ExperimentController import expyfun.analyze as ea +from expyfun import ExperimentController, visual print(__doc__) -with ExperimentController('test', session='1', participant='2', - full_screen=False, window_size=[600, 600], - output_dir=None, version='dev') as ec: - ec.screen_text('hello') +with ExperimentController( + "test", + session="1", + participant="2", + full_screen=False, + window_size=[600, 600], + output_dir=None, + version="dev", +) as ec: + ec.screen_text("hello") # make an image with alpha the x-dimension (columns), RGB upward img_buffer = np.zeros((120, 100, 4)) @@ -28,17 +34,29 @@ img_buffer[:, 50:, 3] = 0.5 img_buffer[0] = 1 for ii in range(3): - img_buffer[ii * 40:(ii + 1) * 40, :, ii] = 1.0 - img = visual.RawImage(ec, img_buffer, scale=2.) + img_buffer[ii * 40 : (ii + 1) * 40, :, ii] = 1.0 + img = visual.RawImage(ec, img_buffer, scale=2.0) # make a line, rectangle, diamond, and circle - line = visual.Line(ec, [[-2, 2, 2, -2], [-2, 2, -2, -2]], units='deg', - line_color='w', line_width=2.0) - rect = visual.Rectangle(ec, [0, 0, 2, 2], units='deg', fill_color='y') - diamond = visual.Diamond(ec, [0, 0, 4, 4], units='deg', fill_color=None, - line_color='gray', line_width=2.0) - circle = visual.Circle(ec, 1, units='deg', line_color='w', fill_color='k', - line_width=2.0) + line = visual.Line( + ec, + [[-2, 2, 2, -2], [-2, 2, -2, -2]], + units="deg", + line_color="w", + line_width=2.0, + ) + rect = visual.Rectangle(ec, [0, 0, 2, 2], units="deg", fill_color="y") + diamond = visual.Diamond( + ec, + [0, 0, 4, 4], + units="deg", + fill_color=None, + line_color="gray", + line_width=2.0, + ) + circle = visual.Circle( + ec, 1, units="deg", line_color="w", fill_color="k", line_width=2.0 + ) # do the drawing, then flip for obj in [img, line, rect, diamond, circle]: diff --git a/examples/experiments/eyetracking_experiment_.py b/examples/experiments/eyetracking_experiment_.py index e1270600..3e2ff6d1 100644 --- a/examples/experiments/eyetracking_experiment_.py +++ b/examples/experiments/eyetracking_experiment_.py @@ -12,53 +12,70 @@ import numpy as np -from expyfun import ExperimentController, EyelinkController, visual import expyfun.analyze as ea +from expyfun import ExperimentController, EyelinkController, visual print(__doc__) -with ExperimentController('testExp', full_screen=True, participant='foo', - session='001', output_dir=None, version='dev') as ec: +with ExperimentController( + "testExp", + full_screen=True, + participant="foo", + session="001", + output_dir=None, + version="dev", +) as ec: el = EyelinkController(ec) - ec.screen_prompt('Welcome to the experiment!\n\nFirst, we will ' - 'perform a screen calibration.\n\nPress a button ' - 'to continue.') + ec.screen_prompt( + "Welcome to the experiment!\n\nFirst, we will " + "perform a screen calibration.\n\nPress a button " + "to continue." + ) el.calibrate() # by default this starts recording EyeLink data - ec.screen_prompt('Excellent! Now, follow the red circle around the edge ' - 'of the big white circle.\n\nPress a button to ' - 'continue') + ec.screen_prompt( + "Excellent! Now, follow the red circle around the edge " + "of the big white circle.\n\nPress a button to " + "continue" + ) # make some circles to be drawn radius = 7.5 # degrees targ_rad = 0.2 # degrees - theta = np.linspace(np.pi / 2., 2.5 * np.pi, 200) + theta = np.linspace(np.pi / 2.0, 2.5 * np.pi, 200) x_pos, y_pos = radius * np.cos(theta), radius * np.sin(theta) - big_circ = visual.Circle(ec, radius, (0, 0), units='deg', - fill_color=None, line_color='white', - line_width=3.0) - targ_circ = visual.Circle(ec, targ_rad, (x_pos[0], y_pos[0]), - units='deg', fill_color='red') + big_circ = visual.Circle( + ec, + radius, + (0, 0), + units="deg", + fill_color=None, + line_color="white", + line_width=3.0, + ) + targ_circ = visual.Circle( + ec, targ_rad, (x_pos[0], y_pos[0]), units="deg", fill_color="red" + ) fix_pos = (x_pos[0], y_pos[0]) # start out by waiting for a 1 sec fixation at the start big_circ.draw() targ_circ.draw() screenshot = ec.screenshot() - ec.identify_trial(ec_id='Circle', ttl_id=[0], el_id=[0]) + ec.identify_trial(ec_id="Circle", ttl_id=[0], el_id=[0]) ec.start_stimulus() # automatically stamps to EL - if not el.wait_for_fix(fix_pos, 1., max_wait=5., units='deg'): - print('Initial fixation failed') + if not el.wait_for_fix(fix_pos, 1.0, max_wait=5.0, units="deg"): + print("Initial fixation failed") for ii, (x, y) in enumerate(zip(x_pos[1:], y_pos[1:])): - targ_circ.set_pos((x, y), units='deg') + targ_circ.set_pos((x, y), units="deg") big_circ.draw() targ_circ.draw() ec.flip() - if not el.wait_for_fix([x, y], max_wait=5., units='deg'): - print('Fixation {0} failed'.format(ii + 1)) + if not el.wait_for_fix([x, y], max_wait=5.0, units="deg"): + print(f"Fixation {ii + 1} failed") ec.trial_ok() el.stop() # stop recording to save the file - ec.screen_prompt('All done!', max_wait=1.0) + ec.screen_prompt("All done!", max_wait=1.0) # eyelink auto-closes (el.close()) because it gets registered with EC ea.plot_screen(screenshot) diff --git a/examples/experiments/formatted_text.py b/examples/experiments/formatted_text.py index 4d3d7f66..783733c8 100644 --- a/examples/experiments/formatted_text.py +++ b/examples/experiments/formatted_text.py @@ -15,34 +15,42 @@ print(__doc__) # Colors -blue = _convert_color('#00CEE9') -pink = _convert_color('#FF97AF') +blue = _convert_color("#00CEE9") +pink = _convert_color("#FF97AF") white = (255, 255, 255, 255) # Text -one = ('This text can only have a single color, font, and size for the whole ' - 'sentence, because it is specified as attr=False') -two = ('Additional calls to ec.screen_text() can have different formatting,' - 'but have to be manually positioned.') -thr = ('This text can have {{color {0}}}different {{color {1}}}colors ' - 'speci{{color {2}}}fied inline, because its {{color {0}}}attr ' - '{{color {2}}}argument is {{color {1}}}True. {{color {2}}}' - 'Specifying different typefaces or sizes inline is buggy and ' - 'not recommended.').format(blue, pink, white) -fou = 'Press any key to change all the text to pink using .set_color().' -fiv = 'Press any key to quit.' -max_wait = float('inf') if not building_doc else 0. +one = ( + "This text can only have a single color, font, and size for the whole " + "sentence, because it is specified as attr=False" +) +two = ( + "Additional calls to ec.screen_text() can have different formatting," + "but have to be manually positioned." +) +thr = ( + f"This text can have {{color {blue}}}different {{color {pink}}}colors " + f"speci{{color {white}}}fied inline, because its {{color {blue}}}attr " + f"{{color {white}}}argument is {{color {pink}}}True. {{color {white}}}" + "Specifying different typefaces or sizes inline is buggy and " + "not recommended." +) +fou = "Press any key to change all the text to pink using .set_color()." +fiv = "Press any key to quit." +max_wait = float("inf") if not building_doc else 0.0 -with ExperimentController('textDemo', participant='foo', session='001', - output_dir=None, version='dev') as ec: +with ExperimentController( + "textDemo", participant="foo", session="001", output_dir=None, version="dev" +) as ec: ec.wait_secs(0.1) # without this, first flip doesn't show on some systems txt_one = ec.screen_text(one, pos=[0, 0.5], attr=False) - txt_two = ec.screen_text(two, pos=[0, 0.2], font_name='Times New Roman', - font_size=32, color='#00CEE9') + txt_two = ec.screen_text( + two, pos=[0, 0.2], font_name="Times New Roman", font_size=32, color="#00CEE9" + ) txt_thr = ec.screen_text(thr, pos=[0, -0.2]) screenshot = ec.screenshot() ec.screen_prompt(fou, pos=[0, -0.5], max_wait=max_wait) for txt in (txt_one, txt_two, txt_thr): - txt.set_color('#FF97AF') + txt.set_color("#FF97AF") txt.draw() ec.screen_prompt(fiv, pos=[0, -0.5], max_wait=max_wait) diff --git a/examples/experiments/joystick_experiment.py b/examples/experiments/joystick_experiment.py index 4feb5176..40e1a2a0 100644 --- a/examples/experiments/joystick_experiment.py +++ b/examples/experiments/joystick_experiment.py @@ -21,26 +21,31 @@ noise_thresh = 0.01 # permit slight miscalibration # on a Logitech Cordless Rumblepad, the right stick is the analog one, # and it has values stored in z and rz -joy_keys = ('z', 'rz') -with ExperimentController('joyExp', participant='foo', session='001', - output_dir=None, version='dev', - joystick=joystick) as ec: - circles = [Circle(ec, 0.5, units='deg', - fill_color=(1., 1., 1., 0.2), line_color='w')] +joy_keys = ("z", "rz") +with ExperimentController( + "joyExp", + participant="foo", + session="001", + output_dir=None, + version="dev", + joystick=joystick, +) as ec: + circles = [ + Circle(ec, 0.5, units="deg", fill_color=(1.0, 1.0, 1.0, 0.2), line_color="w") + ] # We use normalized units for "pos" so we need to compensate in movement # so that X/Y movement is even - ratios = [1., ec.window_size_pix[0] / float(ec.window_size_pix[1])] - pressed = '' + ratios = [1.0, ec.window_size_pix[0] / float(ec.window_size_pix[1])] + pressed = "" if not building_doc: ec.listen_joystick_button_presses() count = 0 screenshot = None - pos = [0., 0.] - while pressed != '2': # enable a clean quit (button number 3) + pos = [0.0, 0.0] + while pressed != "2": # enable a clean quit (button number 3) ####################################################################### # Draw things - Text(ec, str(count), pos=(1, -1), - anchor_x='right', anchor_y='bottom').draw() + Text(ec, str(count), pos=(1, -1), anchor_x="right", anchor_y="bottom").draw() for circle in circles[::-1]: circle.draw() screenshot = ec.screenshot() if screenshot is None else screenshot @@ -52,21 +57,21 @@ pressed = ec.get_joystick_button_presses() ec.listen_joystick_button_presses() # clear events else: - pressed = [('2',)] + pressed = [("2",)] count += len(pressed) ####################################################################### # Move the cursor for idx, (key, ratio) in enumerate(zip(joy_keys, ratios)): - delta = 0. if building_doc else ec.get_joystick_value(key) + delta = 0.0 if building_doc else ec.get_joystick_value(key) if abs(delta) > noise_thresh: # remove noise - pos[idx] = max(min( - pos[idx] + move_rate * ratio * delta, 1), -1) - circles[0].set_pos(pos, units='norm') + pos[idx] = max(min(pos[idx] + move_rate * ratio * delta, 1), -1) + circles[0].set_pos(pos, units="norm") if pressed: - circles.insert(1, Circle(ec, 1, units='deg', - fill_color='r', line_color='w')) - circles[1].set_pos(pos, units='norm') + circles.insert( + 1, Circle(ec, 1, units="deg", fill_color="r", line_color="w") + ) + circles[1].set_pos(pos, units="norm") if len(circles) > 5: circles.pop(-1) pressed = pressed[0][0] # for exit condition diff --git a/examples/experiments/keypress.py b/examples/experiments/keypress.py index c0f657aa..c5b28d18 100644 --- a/examples/experiments/keypress.py +++ b/examples/experiments/keypress.py @@ -10,104 +10,119 @@ # # License: BSD (3-clause) -from expyfun import ExperimentController, building_doc import expyfun.analyze as ea +from expyfun import ExperimentController, building_doc print(__doc__) isi = 0.5 -wait_dur = 3.0 if not building_doc else 0. -msg_dur = 3.0 if not building_doc else 0. +wait_dur = 3.0 if not building_doc else 0.0 +msg_dur = 3.0 if not building_doc else 0.0 -with ExperimentController('KeypressDemo', screen_num=0, - window_size=[640, 480], full_screen=False, - stim_db=0, noise_db=0, output_dir=None, - participant='foo', session='001', - version='dev') as ec: +with ExperimentController( + "KeypressDemo", + screen_num=0, + window_size=[640, 480], + full_screen=False, + stim_db=0, + noise_db=0, + output_dir=None, + participant="foo", + session="001", + version="dev", +) as ec: ec.wait_secs(isi) ############### # screen_prompt - pressed = ec.screen_prompt('press any key\n\nscreen_prompt(' - 'max_wait={})'.format(wait_dur), - max_wait=wait_dur, timestamp=True) - ec.write_data_line('screen_prompt', pressed) + pressed = ec.screen_prompt( + "press any key\n\nscreen_prompt(" f"max_wait={wait_dur})", + max_wait=wait_dur, + timestamp=True, + ) + ec.write_data_line("screen_prompt", pressed) if pressed[0] is None: - message = 'no keys pressed' + message = "no keys pressed" else: - message = '{} pressed after {} secs'.format(pressed[0], - round(pressed[1], 4)) + message = f"{pressed[0]} pressed after {round(pressed[1], 4)} secs" ec.screen_prompt(message, msg_dur) ec.wait_secs(isi) ################## # wait_for_presses - ec.screen_text('press some keys\n\nwait_for_presses(max_wait={})' - ''.format(wait_dur)) + ec.screen_text(f"press some keys\n\nwait_for_presses(max_wait={wait_dur})" "") screenshot = ec.screenshot() ec.flip() pressed = ec.wait_for_presses(wait_dur) - ec.write_data_line('wait_for_presses', pressed) + ec.write_data_line("wait_for_presses", pressed) if not len(pressed): - message = 'no keys pressed' + message = "no keys pressed" else: - message = ['{} pressed after {} secs\n' - ''.format(key, round(time, 4)) for key, time in pressed] - message = ''.join(message) + message = [ + f"{key} pressed after {round(time, 4)} secs\n" "" for key, time in pressed + ] + message = "".join(message) ec.screen_prompt(message, msg_dur) ec.wait_secs(isi) ############################################ # wait_for_presses, relative to master clock - ec.screen_text('press some keys\n\nwait_for_presses(max_wait={}, ' - 'relative_to=0.0)'.format(wait_dur)) + ec.screen_text( + f"press some keys\n\nwait_for_presses(max_wait={wait_dur}, " "relative_to=0.0)" + ) ec.flip() pressed = ec.wait_for_presses(wait_dur, relative_to=0.0) - ec.write_data_line('wait_for_presses relative_to 0.0', pressed) + ec.write_data_line("wait_for_presses relative_to 0.0", pressed) if not len(pressed): - message = 'no keys pressed' + message = "no keys pressed" else: - message = ['{} pressed at {} secs\n' - ''.format(key, round(time, 4)) for key, time in pressed] - message = ''.join(message) + message = [ + f"{key} pressed at {round(time, 4)} secs\n" "" for key, time in pressed + ] + message = "".join(message) ec.screen_prompt(message, msg_dur) ec.wait_secs(isi) ########################################## # listen_presses / wait_secs / get_presses - ec.screen_text('press some keys\n\nlisten_presses()\nwait_secs({0})' - '\nget_presses()'.format(wait_dur)) + ec.screen_text( + f"press some keys\n\nlisten_presses()\nwait_secs({wait_dur})" "\nget_presses()" + ) ec.flip() ec.listen_presses() ec.wait_secs(wait_dur) pressed = ec.get_presses() # relative_to=0.0 - ec.write_data_line('listen / wait / get_presses', pressed) + ec.write_data_line("listen / wait / get_presses", pressed) if not len(pressed): - message = 'no keys pressed' + message = "no keys pressed" else: - message = ['{} pressed after {} secs\n' - ''.format(key, round(time, 4)) for key, time in pressed] - message = ''.join(message) + message = [ + f"{key} pressed after {round(time, 4)} secs\n" "" for key, time in pressed + ] + message = "".join(message) ec.screen_prompt(message, msg_dur) ec.wait_secs(isi) #################################################################### # listen_presses / wait_secs / get_presses, relative to master clock - ec.screen_text('press a few keys\n\nlisten_presses()' - '\nwait_secs({0})\nget_presses(relative_to=0.0)' - ''.format(wait_dur)) + ec.screen_text( + "press a few keys\n\nlisten_presses()" + f"\nwait_secs({wait_dur})\nget_presses(relative_to=0.0)" + "" + ) ec.flip() ec.listen_presses() ec.wait_secs(wait_dur) pressed = ec.get_presses(relative_to=0.0) - ec.write_data_line('listen / wait / get_presses relative_to 0.0', pressed) + ec.write_data_line("listen / wait / get_presses relative_to 0.0", pressed) if not len(pressed): - message = 'no keys pressed' + message = "no keys pressed" else: - message = ['{} pressed at {} secs\n' - ''.format(key, round(time, 4)) for key, time in pressed] - message = ''.join(message) + message = [ + f"{key} pressed at {round(time, 4)} secs\n" "" for key, time in pressed + ] + message = "".join(message) ec.screen_prompt(message, msg_dur) ec.wait_secs(isi) @@ -116,25 +131,29 @@ disp_time = wait_dur countdown = ec.current_time + disp_time ec.call_on_next_flip(ec.listen_presses) - ec.screen_text('press some keys\n\nlisten_presses()' - '\nwhile loop {}\nget_presses()'.format(disp_time)) + ec.screen_text( + "press some keys\n\nlisten_presses()" f"\nwhile loop {disp_time}\nget_presses()" + ) ec.flip() while ec.current_time < countdown: cur_time = round(countdown - ec.current_time, 1) if cur_time != disp_time: disp_time = cur_time # redraw text with updated disp_time - ec.screen_text('press some keys\n\nlisten_presses() ' - '\nwhile loop {}\nget_presses()'.format(disp_time)) + ec.screen_text( + "press some keys\n\nlisten_presses() " + f"\nwhile loop {disp_time}\nget_presses()" + ) ec.flip() pressed = ec.get_presses() - ec.write_data_line('listen / while / get_presses', pressed) + ec.write_data_line("listen / while / get_presses", pressed) if not len(pressed): - message = 'no keys pressed' + message = "no keys pressed" else: - message = ['{} pressed after {} secs\n' - ''.format(key, round(time, 4)) for key, time in pressed] - message = ''.join(message) + message = [ + f"{key} pressed after {round(time, 4)} secs\n" "" for key, time in pressed + ] + message = "".join(message) ec.screen_prompt(message, msg_dur) ec.wait_secs(isi) @@ -143,26 +162,31 @@ disp_time = wait_dur countdown = ec.current_time + disp_time ec.call_on_next_flip(ec.listen_presses) - ec.screen_text('press some keys\n\nlisten_presses()\nwhile loop ' - '{}\nget_presses(relative_to=0.0)'.format(disp_time)) + ec.screen_text( + "press some keys\n\nlisten_presses()\nwhile loop " + f"{disp_time}\nget_presses(relative_to=0.0)" + ) ec.flip() while ec.current_time < countdown: cur_time = round(countdown - ec.current_time, 1) if cur_time != disp_time: disp_time = cur_time # redraw text with updated disp_time - ec.screen_text('press some keys\n\nlisten_presses()\nwhile ' - 'loop {}\nget_presses(relative_to=0.0)' - ''.format(disp_time)) + ec.screen_text( + "press some keys\n\nlisten_presses()\nwhile " + f"loop {disp_time}\nget_presses(relative_to=0.0)" + "" + ) ec.flip() pressed = ec.get_presses(relative_to=0.0) - ec.write_data_line('listen / while / get_presses relative_to 0.0', pressed) + ec.write_data_line("listen / while / get_presses relative_to 0.0", pressed) if not len(pressed): - message = 'no keys pressed' + message = "no keys pressed" else: - message = ['{} pressed at {} secs\n' - ''.format(key, round(time, 4)) for key, time in pressed] - message = ''.join(message) + message = [ + f"{key} pressed at {round(time, 4)} secs\n" "" for key, time in pressed + ] + message = "".join(message) ec.screen_prompt(message, msg_dur) ea.plot_screen(screenshot) diff --git a/examples/experiments/keyrelease.py b/examples/experiments/keyrelease.py index 88033e8a..2c9be464 100644 --- a/examples/experiments/keyrelease.py +++ b/examples/experiments/keyrelease.py @@ -20,26 +20,37 @@ # # License: BSD (3-clause) -from expyfun import ExperimentController, building_doc, analyze as ea +from expyfun import ExperimentController, building_doc +from expyfun import analyze as ea print(__doc__) isi = 0.5 -wait_dur = 3.0 if not building_doc else 0. -msg_dur = 3.0 if not building_doc else 0. +wait_dur = 3.0 if not building_doc else 0.0 +msg_dur = 3.0 if not building_doc else 0.0 -with ExperimentController('KeyPressAndReleaseDemo', screen_num=0, - window_size=[1280, 960], full_screen=False, - stim_db=0, noise_db=0, output_dir=None, - participant='foo', session='001', - version='dev', response_device='keyboard') as ec: +with ExperimentController( + "KeyPressAndReleaseDemo", + screen_num=0, + window_size=[1280, 960], + full_screen=False, + stim_db=0, + noise_db=0, + output_dir=None, + participant="foo", + session="001", + version="dev", + response_device="keyboard", +) as ec: ec.wait_secs(isi) ########################################### # listen_presses / while loop / get_presses(kind='both') - instruction = ("Press and release some keys\n\nlisten_presses()" - "\nwhile loop {}\n" - "get_presses(kind='both', return_kinds=True)") + instruction = ( + "Press and release some keys\n\nlisten_presses()" + "\nwhile loop {}\n" + "get_presses(kind='both', return_kinds=True)" + ) disp_time = wait_dur countdown = ec.current_time + disp_time ec.call_on_next_flip(ec.listen_presses) @@ -53,14 +64,13 @@ # redraw text with updated disp_time ec.screen_text(instruction.format(disp_time)) ec.flip() - events = ec.get_presses(kind='both', return_kinds=True) - ec.write_data_line('listen / while / get_presses', events) + events = ec.get_presses(kind="both", return_kinds=True) + ec.write_data_line("listen / while / get_presses", events) if not len(events): - message = 'no keys pressed' + message = "no keys pressed" else: - message = ['{} {} after {} secs\n' - ''.format(k, r, round(t, 4)) for k, t, r in events] - message = ''.join(message) + message = [f"{k} {r} after {round(t, 4)} secs\n" "" for k, t, r in events] + message = "".join(message) ec.screen_prompt(message, msg_dur) ec.wait_secs(isi) diff --git a/examples/experiments/level_test.py b/examples/experiments/level_test.py index 1f035878..e3cb616f 100644 --- a/examples/experiments/level_test.py +++ b/examples/experiments/level_test.py @@ -16,35 +16,47 @@ import numpy as np +import expyfun.analyze as ea from expyfun import ExperimentController, building_doc from expyfun.visual import Rectangle -import expyfun.analyze as ea print(__doc__) -with ExperimentController('LevelTest', full_screen=True, noise_db=-np.inf, - participant='s', session='0', output_dir=None, - suppress_resamp=True, check_rms=None, - stim_db=80, version='dev') as ec: - tone = (0.01 * np.sqrt(2.) * - np.sin(2 * np.pi * 1000. * np.arange(0, 10, 1. / ec.fs))) +with ExperimentController( + "LevelTest", + full_screen=True, + noise_db=-np.inf, + participant="s", + session="0", + output_dir=None, + suppress_resamp=True, + check_rms=None, + stim_db=80, + version="dev", +) as ec: + tone = ( + 0.01 * np.sqrt(2.0) * np.sin(2 * np.pi * 1000.0 * np.arange(0, 10, 1.0 / ec.fs)) + ) assert np.allclose(np.sqrt(np.mean(tone * tone)), 0.01) - square = Rectangle(ec, (0, 0, 10, 10), units='deg', fill_color='r') - cm = np.diff(ec._convert_units([[0, 5], [0, 5]], 'deg', 'pix'), - axis=-1)[0] / ec.dpi / 0.39370 + square = Rectangle(ec, (0, 0, 10, 10), units="deg", fill_color="r") + cm = ( + np.diff(ec._convert_units([[0, 5], [0, 5]], "deg", "pix"), axis=-1)[0] + / ec.dpi + / 0.39370 + ) ec.load_buffer(tone) # RMS == 0.01 pressed = None screenshot = None - while pressed != '8': # enable a clean quit if required + while pressed != "8": # enable a clean quit if required square.draw() - ec.screen_text('Width: {} cm'.format(np.round(2 * cm, 1)), wrap=False) - ec.screen_text('Output level: {} dB'.format(ec.stim_db), wrap=True) + ec.screen_text(f"Width: {np.round(2 * cm, 1)} cm", wrap=False) + ec.screen_text(f"Output level: {ec.stim_db} dB", wrap=True) screenshot = ec.screenshot() if screenshot is None else screenshot t1 = ec.start_stimulus(start_of_trial=False) # skip checks - pressed = ec.wait_one_press(10)[0] if not building_doc else '8' + pressed = ec.wait_one_press(10)[0] if not building_doc else "8" ec.flip() - ec.wait_one_press(0.5 if not building_doc else 0.) + ec.wait_one_press(0.5 if not building_doc else 0.0) ec.stop() ea.plot_screen(screenshot) diff --git a/examples/experiments/mouse.py b/examples/experiments/mouse.py index 619bef95..b5a6e194 100644 --- a/examples/experiments/mouse.py +++ b/examples/experiments/mouse.py @@ -10,65 +10,73 @@ # # License: BSD (3-clause) -from expyfun import ExperimentController, building_doc import expyfun.analyze as ea -from expyfun.visual import (Circle, Rectangle, Diamond, ConcentricCircles, - FixationDot) +from expyfun import ExperimentController, building_doc +from expyfun.visual import Circle, ConcentricCircles, Diamond, FixationDot, Rectangle print(__doc__) -wait_dur = 3.0 if not building_doc else 0. -msg_dur = 1.5 if not building_doc else 0. -max_wait = float('inf') if not building_doc else 0. +wait_dur = 3.0 if not building_doc else 0.0 +msg_dur = 1.5 if not building_doc else 0.0 +max_wait = float("inf") if not building_doc else 0.0 -with ExperimentController('MouseDemo', screen_num=0, - window_size=[640, 480], full_screen=False, - stim_db=0, noise_db=0, output_dir=None, - participant='foo', session='001', - version='dev') as ec: +with ExperimentController( + "MouseDemo", + screen_num=0, + window_size=[640, 480], + full_screen=False, + stim_db=0, + noise_db=0, + output_dir=None, + participant="foo", + session="001", + version="dev", +) as ec: ################################# # toggle_cursor and move_mouse_to ec.toggle_cursor(True) ec.move_mouse_to((0, 0)) - ec.screen_prompt('Now you see it (centered on the window).', - max_wait=msg_dur, wrap=False) + ec.screen_prompt( + "Now you see it (centered on the window).", max_wait=msg_dur, wrap=False + ) ec.toggle_cursor(False) - ec.screen_prompt("Now you don't (maybe--Windows is buggy)", - max_wait=msg_dur, wrap=False) + ec.screen_prompt( + "Now you don't (maybe--Windows is buggy)", max_wait=msg_dur, wrap=False + ) ec.toggle_cursor(True) ################ # wait_one_click - ec.screen_text('Press any mouse button.', wrap=False) + ec.screen_text("Press any mouse button.", wrap=False) ec.flip() ec.wait_one_click(max_wait=max_wait) ec.toggle_cursor(False) - ec.screen_text('Press the left button.', wrap=False) + ec.screen_text("Press the left button.", wrap=False) ec.flip() - ec.wait_one_click(live_buttons=['left'], visible=True, max_wait=max_wait) + ec.wait_one_click(live_buttons=["left"], visible=True, max_wait=max_wait) ec.wait_secs(0.5) ec.toggle_cursor(True) ########################### # listen_clicks, get_clicks - ec.screen_text('Press a few buttons in a row.', wrap=False) + ec.screen_text("Press a few buttons in a row.", wrap=False) ec.flip() ec.listen_clicks() ec.wait_secs(wait_dur) clicks = ec.get_clicks() - ec.screen_prompt('Your clicks:\n%s' % str(clicks), max_wait=msg_dur) + ec.screen_prompt("Your clicks:\n%s" % str(clicks), max_wait=msg_dur) #################### # get_mouse_position - ec.screen_prompt('Move the mouse around...', max_wait=msg_dur, wrap=False) + ec.screen_prompt("Move the mouse around...", max_wait=msg_dur, wrap=False) stop_time = ec.current_time + wait_dur while ec.current_time < stop_time: - ec.screen_text('%i, %i' % tuple([p for p in - ec.get_mouse_position()]), - wrap=False) + ec.screen_text( + "%i, %i" % tuple([p for p in ec.get_mouse_position()]), wrap=False + ) ec.check_force_quit() ec.flip() @@ -76,15 +84,16 @@ # wait_for_click_on ec.toggle_cursor(False) ec.wait_secs(1) - c = Circle(ec, 150, units='pix') - r = Rectangle(ec, (0.5, 0.5, 0.2, 0.2), units='norm', fill_color='r') - cc = ConcentricCircles(ec, pos=[0.6, -0.4], - colors=[[0.2, 0.2, 0.2], [0.6, 0.6, 0.6]]) - d = Diamond(ec, (-0.5, 0.5, 0.4, 0.25), fill_color='b') + c = Circle(ec, 150, units="pix") + r = Rectangle(ec, (0.5, 0.5, 0.2, 0.2), units="norm", fill_color="r") + cc = ConcentricCircles( + ec, pos=[0.6, -0.4], colors=[[0.2, 0.2, 0.2], [0.6, 0.6, 0.6]] + ) + d = Diamond(ec, (-0.5, 0.5, 0.4, 0.25), fill_color="b") dot = FixationDot(ec) objects = [c, r, cc, d, dot] - ec.screen_prompt('Click on some objects...', max_wait=msg_dur, wrap=False) + ec.screen_prompt("Click on some objects...", max_wait=msg_dur, wrap=False) for ti in range(3): for o in objects: o.draw() diff --git a/examples/experiments/progress_bar.py b/examples/experiments/progress_bar.py index a1e0c3f2..ec6c62c0 100644 --- a/examples/experiments/progress_bar.py +++ b/examples/experiments/progress_bar.py @@ -1,5 +1,4 @@ #!/usr/bin/env python2 -# -*- coding: utf-8 -*- """ ================ ProgressBar demo @@ -8,24 +7,34 @@ This example shows how to display progress between trials using :class:`expyfun.visual.ProgressBar`. """ + +import numpy as np + +import expyfun.analyze as ea from expyfun import ExperimentController, building_doc from expyfun.visual import ProgressBar -import expyfun.analyze as ea -import numpy as np n_trials = 6 max_wait = 0.1 if building_doc else np.inf wait_dur = 0.1 if building_doc else 0.5 -with ExperimentController('name', version='dev', window_size=[800, 600], - full_screen=False, session='foo', - participant='foo') as ec: - +with ExperimentController( + "name", + version="dev", + window_size=[800, 600], + full_screen=False, + session="foo", + participant="foo", +) as ec: # initialize the progress bar - pb = ProgressBar(ec, [0, -.1, 1.5, .1], units='norm') + pb = ProgressBar(ec, [0, -0.1, 1.5, 0.1], units="norm") - ec.screen_prompt('Press the number shown on the screen. Start by pressing' - ' 1.', font_size=16, live_keys=[1], max_wait=max_wait) + ec.screen_prompt( + "Press the number shown on the screen. Start by pressing" " 1.", + font_size=16, + live_keys=[1], + max_wait=max_wait, + ) for n in np.arange(n_trials) + 1: # subject does some task @@ -41,16 +50,19 @@ percent = int(n * 100 / n_trials) pb.update_bar(percent) # display the progress bar with some text - ec.screen_text('You\'ve completed {} %. Press any key to proceed.' - ''.format(percent), [0, .1], wrap=False, - font_size=16) + ec.screen_text( + f"You've completed {percent} %. Press any key to proceed." "", + [0, 0.1], + wrap=False, + font_size=16, + ) pb.draw() if n == 4: screenshot = ec.screenshot() ec.flip() # subject uses any key press to proceed ec.wait_one_press(max_wait=max_wait) - ec.screen_text('This example is complete.') + ec.screen_text("This example is complete.") ec.flip() ec.wait_secs(1) diff --git a/examples/experiments/pupillometry_experiment_.py b/examples/experiments/pupillometry_experiment_.py index 0d3c09e5..0ba3dc91 100644 --- a/examples/experiments/pupillometry_experiment_.py +++ b/examples/experiments/pupillometry_experiment_.py @@ -10,43 +10,50 @@ # # License: BSD (3-clause) -import numpy as np import matplotlib.pyplot as plt +import numpy as np from expyfun import ExperimentController, EyelinkController -from expyfun.codeblocks import (find_pupil_dynamic_range, - find_pupil_tone_impulse_response) +from expyfun.codeblocks import ( + find_pupil_dynamic_range, + find_pupil_tone_impulse_response, +) print(__doc__) -with ExperimentController('pupilExp', full_screen=True, participant='foo', - session='001', output_dir=None, version='dev') as ec: +with ExperimentController( + "pupilExp", + full_screen=True, + participant="foo", + session="001", + output_dir=None, + version="dev", +) as ec: el = EyelinkController(ec) bgcolor, fcolor, lev, resp = find_pupil_dynamic_range(ec, el) - prf, t_srf, e_prf = find_pupil_tone_impulse_response(ec, el, bgcolor, - fcolor) + prf, t_srf, e_prf = find_pupil_tone_impulse_response(ec, el, bgcolor, fcolor) uni_lev = np.unique(lev) uni_lev_label = (255 * uni_lev).astype(int) -uni_lev[uni_lev == 0] = np.sort(uni_lev)[1] / 2. +uni_lev[uni_lev == 0] = np.sort(uni_lev)[1] / 2.0 r = resp.reshape((len(lev) // len(uni_lev), len(uni_lev))) r_span = [r.min(), r.max()] # Grayscale responses -ax = plt.subplot(2, 1, 1, xlabel='Screen level', ylabel='Pupil dilation (AU)') -ax.plot([bgcolor, bgcolor], r_span, linestyle='--', color='r') -ax.fill_between(uni_lev, np.min(r, 0), np.max(r, 0), facecolor=(1, 1, 0), - edgecolor='none') -ax.semilogx(uni_lev, np.mean(r, 0), color='k') +ax = plt.subplot(2, 1, 1, xlabel="Screen level", ylabel="Pupil dilation (AU)") +ax.plot([bgcolor, bgcolor], r_span, linestyle="--", color="r") +ax.fill_between( + uni_lev, np.min(r, 0), np.max(r, 0), facecolor=(1, 1, 0), edgecolor="none" +) +ax.semilogx(uni_lev, np.mean(r, 0), color="k") ax.set_xlim(uni_lev[[0, -1]]) ax.set_ylim(r_span) plt.xticks(uni_lev, uni_lev_label) # PRF -ax = plt.subplot(2, 1, 2, xlabel='Time (s)', ylabel='Pupil response (AU)') -ax.fill_between(t_srf, prf - e_prf, prf + e_prf, facecolor=(1, 1, 0), - edgecolor='none') -ax.plot(t_srf, prf, color='k') +ax = plt.subplot(2, 1, 2, xlabel="Time (s)", ylabel="Pupil response (AU)") +ax.fill_between(t_srf, prf - e_prf, prf + e_prf, facecolor=(1, 1, 0), edgecolor="none") +ax.plot(t_srf, prf, color="k") ax.set_xlim(t_srf[[0, -1]]) plt.tight_layout() plt.show() diff --git a/examples/experiments/tracker_dealer.py b/examples/experiments/tracker_dealer.py index 174a143a..a64f3189 100644 --- a/examples/experiments/tracker_dealer.py +++ b/examples/experiments/tracker_dealer.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ ========================================================================== Adaptive tracking for two trial types and tracker reconstruction from .tab @@ -15,12 +14,13 @@ @author: maddycapp27 """ +import matplotlib.pyplot as plt import numpy as np + from expyfun import ExperimentController -from expyfun.stimuli import TrackerUD, TrackerDealer from expyfun.analyze import sigmoid from expyfun.io import reconstruct_dealer -import matplotlib.pyplot as plt +from expyfun.stimuli import TrackerDealer, TrackerUD # define parameters of modeled subject (using sigmoid probability) true_thresh = [30, 40] # true thresholds for trial types 1 and 2 @@ -45,13 +45,13 @@ stop_trials = np.inf start_value = 45 change_indices = [5] -change_rule = 'reversals' +change_rule = "reversals" x_min = 0 x_max = 90 # parameters for the tracker dealer max_lag = 2 -pace_rule = 'reversals' +pace_rule = "reversals" rng_dealer = np.random.RandomState(3) # random seed to select trial type ############################################################################### @@ -63,18 +63,37 @@ # for that trial can be acquired. :class:`expyfun.ExperimentController` is used # to generate log files with :class:`expyfun.stimuli.TrackerUD` and # :class:`expyfun.stimuli.TrackerDealer` information. -std_args = ['test'] # experiment name -std_kwargs = dict(full_screen=False, window_size=(1, 1), participant='foo', - session='01', stim_db=0.0, noise_db=0.0, verbose=True, - version='dev') +std_args = ["test"] # experiment name +std_kwargs = dict( + full_screen=False, + window_size=(1, 1), + participant="foo", + session="01", + stim_db=0.0, + noise_db=0.0, + verbose=True, + version="dev", +) with ExperimentController(*std_args, **std_kwargs) as ec: - # initialize two tracker objects--one for each trial type - tr_ud = [TrackerUD(ec, up, down, step_size_up, step_size_down, - stop_reversals, stop_trials, start_value, - change_indices, change_rule, x_min, - x_max) for _ in range(2)] + tr_ud = [ + TrackerUD( + ec, + up, + down, + step_size_up, + step_size_down, + stop_reversals, + stop_trials, + start_value, + change_indices, + change_rule, + x_min, + x_max, + ) + for _ in range(2) + ] # initialize TrackerDealer object td = TrackerDealer(ec, tr_ud, max_lag, pace_rule, rng_dealer) @@ -85,8 +104,10 @@ for ss, level in td: # Get information of which trial type is next and what the level is at # that time from TrackerDealer - td.respond(rng_human.rand() < sigmoid(level - true_thresh[sum(ss)], - lower=chance, slope=slope)) + td.respond( + rng_human.rand() + < sigmoid(level - true_thresh[sum(ss)], lower=chance, slope=slope) + ) ############################################################################### # Reconstructing the TrackerDealer Object @@ -107,7 +128,9 @@ for i in [0, 1]: fig, ax, lines = td_tab.trackers.ravel()[i].plot(ax=axes[i], n_skip=4) - ax.legend(loc='best') - ax.set_title('Adaptive track of model human trial type {} (true threshold ' - 'is {})'.format(i + 1, true_thresh[i])) + ax.legend(loc="best") + ax.set_title( + f"Adaptive track of model human trial type {i + 1} (true threshold " + f"is {true_thresh[i]})" + ) fig.tight_layout() diff --git a/examples/experiments/tracker_dealer_doublesided.py b/examples/experiments/tracker_dealer_doublesided.py index aed5a7a9..f3f04b10 100644 --- a/examples/experiments/tracker_dealer_doublesided.py +++ b/examples/experiments/tracker_dealer_doublesided.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ ====================================== Adaptive tracking from above and below @@ -11,10 +10,11 @@ @author: maddycapp27 """ +import matplotlib.pyplot as plt import numpy as np -from expyfun.stimuli import TrackerUD, TrackerDealer + from expyfun.analyze import sigmoid -import matplotlib.pyplot as plt +from expyfun.stimuli import TrackerDealer, TrackerUD # define parameters of modeled subject (using sigmoid probability) true_thresh = 30 # true thresholds for trial types 1 and 2 @@ -40,19 +40,19 @@ stop_trials = np.inf start_value = [15, 45] change_indices = [5] -change_rule = 'reversals' +change_rule = "reversals" x_min = 0 x_max = 90 # callback function that prints to console def callback(event_type, value=None, timestamp=None): - print((str(event_type) + ':').ljust(40) + str(value)) + print((str(event_type) + ":").ljust(40) + str(value)) # parameters for the tracker dealer max_lag = 2 -pace_rule = 'reversals' +pace_rule = "reversals" rng_dealer = np.random.RandomState(4) # random seed for selecting trial type ############################################################################## @@ -65,9 +65,23 @@ def callback(event_type, value=None, timestamp=None): # acquired. # initialize two tracker objects--one for each start value -tr_ud = [TrackerUD(callback, up, down, step_size_up, step_size_down, - stop_reversals, stop_trials, sv, change_indices, - change_rule, x_min, x_max) for sv in start_value] +tr_ud = [ + TrackerUD( + callback, + up, + down, + step_size_up, + step_size_down, + stop_reversals, + stop_trials, + sv, + change_indices, + change_rule, + x_min, + x_max, + ) + for sv in start_value +] # initialize TrackerDealer object td = TrackerDealer(callback, tr_ud, max_lag, pace_rule, rng_dealer) @@ -78,8 +92,9 @@ def callback(event_type, value=None, timestamp=None): for _, level in td: # Get information of which trial type is next and what the level is at # that time from TrackerDealer - td.respond(rng_human.rand() < sigmoid(level - true_thresh, - lower=chance, slope=slope)) + td.respond( + rng_human.rand() < sigmoid(level - true_thresh, lower=chance, slope=slope) + ) ############################################################################## # Plotting the Results @@ -88,7 +103,9 @@ def callback(event_type, value=None, timestamp=None): for i in [0, 1]: fig, ax, lines = td.trackers.ravel()[i].plot(ax=axes[i], n_skip=4) - ax.legend(loc='best') - ax.set_title('Adaptive track with start value {} (true threshold ' - 'is {})'.format(start_value[i], true_thresh)) + ax.legend(loc="best") + ax.set_title( + f"Adaptive track with start value {start_value[i]} (true threshold " + f"is {true_thresh})" + ) fig.tight_layout() diff --git a/examples/experiments/version_checking_.py b/examples/experiments/version_checking_.py index c7ed25d8..78df6e82 100644 --- a/examples/experiments/version_checking_.py +++ b/examples/experiments/version_checking_.py @@ -22,7 +22,7 @@ # directory so we don't break any other code examples, but usually you'd # want to do it in the experiment directory: temp_dir = tempfile.mkdtemp() -download_version('c18133c', temp_dir) +download_version("c18133c", temp_dir) # Now we would normally need to restart Python so the next ``import expyfun`` # call imported the proper version. We'd want to add an ``assert_version`` @@ -36,11 +36,11 @@ assert_version('c18133c') """ try: - run_subprocess(['python', '-c', cmd], cwd=temp_dir) + run_subprocess(["python", "-c", cmd], cwd=temp_dir) except Exception as exp: - print('Failure: {0}'.format(exp)) + print(f"Failure: {exp}") else: - print('Success!') + print("Success!") # Try modifying the commit number to something invalid, and you should # see a failure. diff --git a/examples/generate_simple_stimuli.py b/examples/generate_simple_stimuli.py index 8b4b8683..a3c20b79 100644 --- a/examples/generate_simple_stimuli.py +++ b/examples/generate_simple_stimuli.py @@ -8,8 +8,9 @@ """ from os import path as op -import numpy as np + import matplotlib.pyplot as plt +import numpy as np from expyfun.io import write_hdf5, write_wav from expyfun.stimuli import play_sound @@ -17,9 +18,18 @@ print(__doc__) -def generate_stimuli(num_trials=10, num_freqs=4, stim_dur=0.5, min_freq=500.0, - max_freq=4000.0, fs=24414.0625, rms=0.01, output_dir='.', - save_as='hdf5', rand_seed=0): +def generate_stimuli( + num_trials=10, + num_freqs=4, + stim_dur=0.5, + min_freq=500.0, + max_freq=4000.0, + fs=24414.0625, + rms=0.01, + output_dir=".", + save_as="hdf5", + rand_seed=0, +): """Make some sine waves and save in various formats. Optimized for saving as MAT files, but can also save directly as WAV files, or can return a python dictionary with sinewave data as values. @@ -65,42 +75,47 @@ def generate_stimuli(num_trials=10, num_freqs=4, stim_dur=0.5, min_freq=500.0, rng = np.random.RandomState(rand_seed) # check input arguments - if save_as is not None and save_as not in ['dict', 'wav', 'hdf5']: + if save_as is not None and save_as not in ["dict", "wav", "hdf5"]: raise ValueError('"save_as" must be "dict", "wav", or "hdf5"') fs = float(fs) t = np.arange(np.round(stim_dur * fs)) / fs # frequencies equally spaced on a log-2 scale - freqs = min_freq * np.logspace(0, np.log2(max_freq / float(min_freq)), - num_freqs, endpoint=True, base=2) + freqs = min_freq * np.logspace( + 0, np.log2(max_freq / float(min_freq)), num_freqs, endpoint=True, base=2 + ) # strings for the filenames / dictionary keys freq_names = [str(int(f)) for f in freqs] - names = ['stim_%s_%s' % (n, f) for n, f in enumerate(freq_names)] + names = ["stim_%s_%s" % (n, f) for n, f in enumerate(freq_names)] # generate sinewaves & RMS normalize wavs = [np.sin(2 * np.pi * f * t) for f in freqs] - wavs = [rms / np.sqrt(np.mean(w ** 2)) * w for w in wavs] + wavs = [rms / np.sqrt(np.mean(w**2)) * w for w in wavs] # collect into dictionary & save wav_dict = {n: w for (n, w) in zip(names, wavs)} - if save_as == 'hdf5': + if save_as == "hdf5": num_reps = num_trials // num_freqs + 1 trials = np.tile(range(num_freqs), num_reps) trial_order = rng.permutation(trials[0:num_trials]) - wav_dict.update({'trial_order': trial_order, 'freqs': freqs, 'fs': fs, - 'rms': rms}) - write_hdf5(op.join(output_dir, 'equally_spaced_sinewaves.hdf5'), - wav_dict, overwrite=True) - elif save_as == 'wav': + wav_dict.update( + {"trial_order": trial_order, "freqs": freqs, "fs": fs, "rms": rms} + ) + write_hdf5( + op.join(output_dir, "equally_spaced_sinewaves.hdf5"), + wav_dict, + overwrite=True, + ) + elif save_as == "wav": for n in names: - write_wav(op.join(output_dir, n + '.wav'), wav_dict[n], int(fs)) + write_wav(op.join(output_dir, n + ".wav"), wav_dict[n], int(fs)) return wav_dict -if __name__ == '__main__': +if __name__ == "__main__": wav_dict = generate_stimuli(save_as=None) - plt.plot(wav_dict['stim_0_500'][:1000]) - play_sound(wav_dict['stim_0_500']) + plt.plot(wav_dict["stim_0_500"][:1000]) + play_sound(wav_dict["stim_0_500"]) plt.show() diff --git a/examples/simple_experiment.py b/examples/simple_experiment.py index 5b8278e0..54804e8a 100644 --- a/examples/simple_experiment.py +++ b/examples/simple_experiment.py @@ -13,16 +13,21 @@ import os import sys from os import path as op + import numpy as np -from expyfun import (ExperimentController, get_keyboard_input, set_log_level, - building_doc) -from expyfun.io import read_hdf5 import expyfun.analyze as ea +from expyfun import ( + ExperimentController, + building_doc, + get_keyboard_input, + set_log_level, +) +from expyfun.io import read_hdf5 print(__doc__) -set_log_level('INFO') +set_log_level("INFO") # set configuration noise_db = 45 # dB for background noise @@ -35,41 +40,54 @@ running_total = 0 # make the stimuli if necessary and then load them -fname = 'equally_spaced_sinewaves.hdf5' +fname = "equally_spaced_sinewaves.hdf5" if not op.isfile(fname): # This sys.path wrangling is only necessary for Sphinx automatic # documentation building sys.path.insert(0, os.getcwd()) from generate_simple_stimuli import generate_stimuli + generate_stimuli() stims = read_hdf5(fname) -orig_rms = stims['rms'] -freqs = stims['freqs'] -fs = stims['fs'] -trial_order = stims['trial_order'] +orig_rms = stims["rms"] +freqs = stims["freqs"] +fs = stims["fs"] +trial_order = stims["trial_order"] num_trials = len(trial_order) num_freqs = len(freqs) if num_freqs > 8: - raise RuntimeError('Too many frequencies, not enough buttons.') + raise RuntimeError("Too many frequencies, not enough buttons.") # keep only sinusoids, order low-high, convert to list of arrays -wavs = [stims[k] for k in sorted(stims.keys()) if k.startswith('stim_')] +wavs = [stims[k] for k in sorted(stims.keys()) if k.startswith("stim_")] # instructions -instructions = ('You will hear tones at {0} different frequencies. Your job is' - ' to press the button corresponding to that frequency. Please ' - 'press buttons 1-{0} now to hear each tone.').format(num_freqs) - -instr_finished = ('Okay, now press any of those buttons to start the real ' - 'thing. There will be background noise.') - -with ExperimentController('testExp', verbose=True, screen_num=0, - window_size=[800, 600], full_screen=False, - stim_db=stim_db, noise_db=noise_db, stim_fs=fs, - participant='foo', session='001', - version='dev', output_dir=None) as ec: - +instructions = ( + f"You will hear tones at {num_freqs} different frequencies. Your job is" + " to press the button corresponding to that frequency. Please " + f"press buttons 1-{num_freqs} now to hear each tone." +) + +instr_finished = ( + "Okay, now press any of those buttons to start the real " + "thing. There will be background noise." +) + +with ExperimentController( + "testExp", + verbose=True, + screen_num=0, + window_size=[800, 600], + full_screen=False, + stim_db=stim_db, + noise_db=noise_db, + stim_fs=fs, + participant="foo", + session="001", + version="dev", + output_dir=None, +) as ec: # define usable buttons / keys live_keys = [x + 1 for x in range(num_freqs)] @@ -80,8 +98,7 @@ max_wait = max_resp_time = min_resp_time = train = feedback_dur = 0 long_resp_time = 0 else: - train = get_keyboard_input('Run training (0=no, 1=yes [default]): ', - 1, int) + train = get_keyboard_input("Run training (0=no, 1=yes [default]): ", 1, int) ec.set_visible(True) if train: @@ -108,72 +125,75 @@ ec.wait_secs(isi) ec.call_on_next_flip(ec.start_noise()) - ec.screen_text('OK, here we go!', wrap=False) + ec.screen_text("OK, here we go!", wrap=False) screenshot = ec.screenshot() ec.wait_one_press(max_wait=feedback_dur, live_keys=None) ec.wait_secs(isi) single_trial_order = trial_order[range(len(trial_order) // 2)] - mass_trial_order = trial_order[len(trial_order) // 2:] + mass_trial_order = trial_order[len(trial_order) // 2 :] # run the single-tone trials for stim_num in single_trial_order: ec.load_buffer(wavs[stim_num]) ec.identify_trial(ec_id=stim_num, ttl_id=[0, 0]) - ec.write_data_line('one-tone trial', stim_num + 1) + ec.write_data_line("one-tone trial", stim_num + 1) ec.start_stimulus() - pressed, timestamp = ec.wait_one_press(max_resp_time, min_resp_time, - live_keys) + pressed, timestamp = ec.wait_one_press(max_resp_time, min_resp_time, live_keys) ec.stop() # will stop stim playback as soon as response logged ec.trial_ok() # some feedback if pressed is None: - message = 'Too slow!' + message = "Too slow!" elif int(pressed) == stim_num + 1: running_total += 1 - message = ('Correct! Your reaction time was ' - '{}').format(round(timestamp, 3)) + message = "Correct! Your reaction time was " f"{round(timestamp, 3)}" else: - message = ('You pressed {0}, the correct answer was ' - '{1}.').format(pressed, stim_num + 1) + message = ( + f"You pressed {pressed}, the correct answer was " f"{stim_num + 1}." + ) ec.screen_prompt(message, max_wait=feedback_dur) ec.wait_secs(isi) # create 100 ms pause to play between stims and concatenate pause = np.zeros(int(ec.fs / 10)) concat_wavs = wavs[mass_trial_order[0]] - for num in mass_trial_order[1:len(mass_trial_order)]: + for num in mass_trial_order[1 : len(mass_trial_order)]: concat_wavs = np.r_[concat_wavs, pause, wavs[num]] concat_dur = len(concat_wavs) / float(ec.fs) # run mass trial - ec.screen_prompt('Now you will hear {0} tones in a row. After they stop, ' - 'wait for the "Go!" prompt, then you will have {1} ' - 'seconds to push the buttons in the order that the tones ' - 'played in. Press one of the buttons to begin.' - ''.format(len(mass_trial_order), max_resp_time), - live_keys=live_keys, max_wait=max_wait) + ec.screen_prompt( + f"Now you will hear {len(mass_trial_order)} tones in a row. After they stop, " + f'wait for the "Go!" prompt, then you will have {max_resp_time} ' + "seconds to push the buttons in the order that the tones " + "played in. Press one of the buttons to begin." + "", + live_keys=live_keys, + max_wait=max_wait, + ) ec.load_buffer(concat_wavs) - ec.identify_trial(ec_id='multi-tone', ttl_id=[0, 1]) - ec.write_data_line('multi-tone trial', [x + 1 for x in mass_trial_order]) + ec.identify_trial(ec_id="multi-tone", ttl_id=[0, 1]) + ec.write_data_line("multi-tone trial", [x + 1 for x in mass_trial_order]) ec.start_stimulus() - ec.wait_secs(len(concat_wavs) / float(ec.stim_fs) if not building_doc else - 0) - ec.screen_text('Go!', wrap=False) + ec.wait_secs(len(concat_wavs) / float(ec.stim_fs) if not building_doc else 0) + ec.screen_text("Go!", wrap=False) ec.flip() - pressed = ec.wait_for_presses(long_resp_time, min_resp_time, - live_keys, False) + pressed = ec.wait_for_presses(long_resp_time, min_resp_time, live_keys, False) answers = [str(x + 1) for x in mass_trial_order] correct = [press == ans for press, ans in zip(pressed, answers)] running_total += sum(correct) ec.call_on_next_flip(ec.stop_noise()) - ec.screen_prompt('You got {0} out of {1} correct.' - ''.format(sum(correct), len(answers)), - max_wait=feedback_dur) + ec.screen_prompt( + f"You got {sum(correct)} out of {len(answers)} correct." "", + max_wait=feedback_dur, + ) ec.trial_ok() # end experiment - ec.screen_prompt('All done! You got {0} correct out of {1} tones. Press ' - 'any key to close.'.format(running_total, num_trials), - max_wait=max_wait) + ec.screen_prompt( + f"All done! You got {running_total} correct out of {num_trials} tones. Press " + "any key to close.", + max_wait=max_wait, + ) ea.plot_screen(screenshot) diff --git a/examples/stimuli/advanced_stimuli.py b/examples/stimuli/advanced_stimuli.py index 15d79f37..31681976 100644 --- a/examples/stimuli/advanced_stimuli.py +++ b/examples/stimuli/advanced_stimuli.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ ======================================= Generate more advanced auditory stimuli @@ -8,28 +7,29 @@ of more advanced stimuli. """ -import numpy as np import matplotlib.pyplot as plt +import numpy as np from expyfun import building_doc from expyfun.stimuli import convolve_hrtf, play_sound, window_edges fs = 24414 dur = 0.5 -freq = 500. +freq = 500.0 # let's make a square wave sig = np.sin(freq * 2 * np.pi * np.arange(dur * fs, dtype=float) / fs) -sig = ((sig > 0) - 0.5) / 5. # make it reasonably quiet for play_sound +sig = ((sig > 0) - 0.5) / 5.0 # make it reasonably quiet for play_sound sig = window_edges(sig, fs) play_sound(sig, fs, norm=False, wait=True) -move_sig = np.concatenate([convolve_hrtf(sig, fs, ang) - for ang in range(-90, 91, 15)], axis=1) +move_sig = np.concatenate( + [convolve_hrtf(sig, fs, ang) for ang in range(-90, 91, 15)], axis=1 +) if not building_doc: play_sound(move_sig, fs, norm=False, wait=True) t = np.arange(move_sig.shape[1]) / float(fs) plt.plot(t, move_sig.T) -plt.xlabel('Time (sec)') +plt.xlabel("Time (sec)") plt.show() diff --git a/examples/stimuli/advanced_video.py b/examples/stimuli/advanced_video.py index f01aeef7..a0da5fdf 100644 --- a/examples/stimuli/advanced_video.py +++ b/examples/stimuli/advanced_video.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ ====================== Video property control @@ -10,36 +9,50 @@ """ import numpy as np -from expyfun import (ExperimentController, fetch_data_file, building_doc, - analyze as ea, visual) + +from expyfun import ( + ExperimentController, + building_doc, + fetch_data_file, + visual, +) +from expyfun import ( + analyze as ea, +) print(__doc__) -movie_path = fetch_data_file('video/example-video.mp4') +movie_path = fetch_data_file("video/example-video.mp4") -ec_args = dict(exp_name='advanced video example', window_size=(720, 480), - full_screen=False, participant='foo', session='foo', - version='dev', output_dir=None) -colors = [x for x in 'rgbcmyk'] +ec_args = dict( + exp_name="advanced video example", + window_size=(720, 480), + full_screen=False, + participant="foo", + session="foo", + version="dev", + output_dir=None, +) +colors = [x for x in "rgbcmyk"] with ExperimentController(**ec_args) as ec: - screen_period = 1. / ec.estimate_screen_fs() + screen_period = 1.0 / ec.estimate_screen_fs() all_presses = list() fix = visual.FixationDot(ec) - text = text = visual.Text(ec, "Running ...", (0, -0.1), 'k') + text = text = visual.Text(ec, "Running ...", (0, -0.1), "k") screenshot = None # don't have one yet ec.load_video(movie_path) - ec.video.set_scale('fill') - ec.screen_prompt('press 1 during video to toggle pause.', max_wait=1.) + ec.video.set_scale("fill") + ec.screen_prompt("press 1 during video to toggle pause.", max_wait=1.0) ec.listen_presses() # to catch presses on first pass of while loop t_zero = ec.video.play(auto_draw=False) - this_sec = 0. + this_sec = 0.0 while not ec.video.finished: if ec.video.playing: ec.video.draw() else: - ec.screen_text('paused!', color='y', font_size=32, wrap=False) + ec.screen_text("paused!", color="y", font_size=32, wrap=False) text.draw() fix.draw() if screenshot is None: @@ -50,8 +63,7 @@ # change the background color every 1 second if this_sec != int(ec.video.time): this_sec = int(ec.video.time) - text = visual.Text( - ec, str(colors[this_sec]), (0, -0.1), 'k') + text = visual.Text(ec, str(colors[this_sec]), (0, -0.1), "k") ec.set_background_color(colors[this_sec]) # shrink the video, then move it rightward if ec.video.playing: @@ -70,9 +82,9 @@ if building_doc: break ec.delete_video() - preamble = 'press times:' if len(all_presses) else 'no presses' - msg = ', '.join(['{0:.3f}'.format(x[1]) for x in all_presses]) + preamble = "press times:" if len(all_presses) else "no presses" + msg = ", ".join([f"{x[1]:.3f}" for x in all_presses]) ec.flip() - ec.screen_prompt('\n'.join([preamble, msg]), max_wait=1.) + ec.screen_prompt("\n".join([preamble, msg]), max_wait=1.0) ea.plot_screen(screenshot) diff --git a/examples/stimuli/crm_stimuli.py b/examples/stimuli/crm_stimuli.py index 49ad869b..263d3017 100644 --- a/examples/stimuli/crm_stimuli.py +++ b/examples/stimuli/crm_stimuli.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ ================== Use the CRM corpus @@ -9,13 +8,19 @@ @author: rkmaddox """ -from expyfun._utils import _TempDir -from expyfun import ExperimentController, analyze, building_doc -from expyfun.stimuli import (crm_prepare_corpus, crm_sentence, crm_info, - crm_response_menu, add_pad, CRMPreload) - import numpy as np +from expyfun import ExperimentController, analyze, building_doc +from expyfun._utils import _TempDir +from expyfun.stimuli import ( + CRMPreload, + add_pad, + crm_info, + crm_prepare_corpus, + crm_response_menu, + crm_sentence, +) + print(__doc__) crm_path = _TempDir() @@ -40,46 +45,56 @@ # >>> crm_prepare_corpus(24414) # -crm_prepare_corpus(fs, path_out=crm_path, overwrite=True, - talker_list=[dict(sex=0, talker_num=0), - dict(sex=1, talker_num=0)]) +crm_prepare_corpus( + fs, + path_out=crm_path, + overwrite=True, + talker_list=[dict(sex=0, talker_num=0), dict(sex=1, talker_num=0)], +) # print the valid callsigns -print('Valid callsigns are {0}'.format(crm_info()['callsign'])) +print(f'Valid callsigns are {crm_info()["callsign"]}') # read a sentence in from the hard drive -x1 = 0.5 * crm_sentence(fs, 'm', '0', 'c', 'r', '5', path=crm_path) +x1 = 0.5 * crm_sentence(fs, "m", "0", "c", "r", "5", path=crm_path) # preload all the talkers and get a second sentence from memory crm = CRMPreload(fs, path=crm_path) -x2 = crm.sentence('f', '0', 'ringo', 'green', '6') +x2 = crm.sentence("f", "0", "ringo", "green", "6") -x = add_pad([x1, x2], alignment='start') +x = add_pad([x1, x2], alignment="start") ############################################################################### # Now we actually run the experiment. max_wait = 0.01 if building_doc else 3 with ExperimentController( - exp_name='CRM corpus example', window_size=(720, 480), - full_screen=False, participant='foo', session='foo', version='dev', - output_dir=None, stim_fs=40000) as ec: - ec.screen_text('Report the color and number spoken by the female ' - 'talker.', wrap=True) + exp_name="CRM corpus example", + window_size=(720, 480), + full_screen=False, + participant="foo", + session="foo", + version="dev", + output_dir=None, + stim_fs=40000, +) as ec: + ec.screen_text( + "Report the color and number spoken by the female " "talker.", wrap=True + ) screenshot = ec.screenshot() ec.flip() ec.wait_secs(max_wait) ec.load_buffer(x) - ec.identify_trial(ec_id='', ttl_id=[]) + ec.identify_trial(ec_id="", ttl_id=[]) ec.start_stimulus() ec.wait_secs(x.shape[-1] / float(fs)) resp = crm_response_menu(ec, max_wait=0.01 if building_doc else np.inf) - if resp == ('g', '6'): - ec.screen_prompt('Correct!', max_wait=max_wait) + if resp == ("g", "6"): + ec.screen_prompt("Correct!", max_wait=max_wait) else: - ec.screen_prompt('Incorrect.', max_wait=max_wait) + ec.screen_prompt("Incorrect.", max_wait=max_wait) ec.trial_ok() analyze.plot_screen(screenshot) diff --git a/examples/stimuli/simple_video.py b/examples/stimuli/simple_video.py index 335da5bd..90dc1afc 100644 --- a/examples/stimuli/simple_video.py +++ b/examples/stimuli/simple_video.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ ========================= Video playing made simple @@ -10,21 +9,27 @@ @author: drmccloy """ -from expyfun import (ExperimentController, fetch_data_file, analyze as ea, - building_doc) +from expyfun import ExperimentController, building_doc, fetch_data_file +from expyfun import analyze as ea print(__doc__) -movie_path = fetch_data_file('video/example-video.mp4') - -ec_args = dict(exp_name='simple video example', window_size=(720, 480), - full_screen=False, participant='foo', session='foo', - version='dev', output_dir=None) +movie_path = fetch_data_file("video/example-video.mp4") + +ec_args = dict( + exp_name="simple video example", + window_size=(720, 480), + full_screen=False, + participant="foo", + session="foo", + version="dev", + output_dir=None, +) screenshot = None with ExperimentController(**ec_args) as ec: ec.load_video(movie_path) - ec.video.set_scale('fit') + ec.video.set_scale("fit") t_zero = ec.video.play() while not ec.video.finished: if ec.video.playing: @@ -36,6 +41,6 @@ ec.check_force_quit() ec.delete_video() ec.flip() - ec.screen_prompt('video over', max_wait=1.) + ec.screen_prompt("video over", max_wait=1.0) ea.plot_screen(screenshot) diff --git a/examples/stimuli/stimulus_power.py b/examples/stimuli/stimulus_power.py index f7ceab18..60bff2d1 100644 --- a/examples/stimuli/stimulus_power.py +++ b/examples/stimuli/stimulus_power.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ ===================================== Examine and manipulate stimulus power @@ -7,11 +6,11 @@ This shows how to make stimuli that play at different SNRs and db SPL. """ -import numpy as np import matplotlib.pyplot as plt +import numpy as np -from expyfun.stimuli import window_edges, read_wav, rms from expyfun import fetch_data_file +from expyfun.stimuli import read_wav, rms, window_edges print(__doc__) @@ -19,7 +18,7 @@ # Load data # --------- # Get 2 seconds of data -data_orig, fs = read_wav(fetch_data_file('audio/dream.wav')) +data_orig, fs = read_wav(fetch_data_file("audio/dream.wav")) stop = int(round(fs * 2)) data_orig = window_edges(data_orig[0, :stop], fs) t = np.arange(data_orig.size) / float(fs) @@ -27,8 +26,7 @@ # look at the waveform fig, ax = plt.subplots() ax.plot(t, data_orig) -ax.set(xlabel='Time (sec)', ylabel='Amplitude', title='Original', - xlim=t[[0, -1]]) +ax.set(xlabel="Time (sec)", ylabel="Amplitude", title="Original", xlim=t[[0, -1]]) fig.tight_layout() ############################################################################### @@ -45,7 +43,7 @@ target *= 0.01 # do manual calculation same as ``rms``, result should be 0.01 # (to numerical precision) -print(np.sqrt(np.mean(target ** 2))) +print(np.sqrt(np.mean(target**2))) ############################################################################### # One important thing to note about this stimulus is that its long-term RMS @@ -67,26 +65,25 @@ # during your experiment. # Good idea to use a seed for reproducibility! -ratio_dB = -6. # dB +ratio_dB = -6.0 # dB rng = np.random.RandomState(0) masker = rng.randn(len(target)) masker /= rms(masker) # now has unit RMS masker *= 0.01 # now has RMS=0.01, same as target -ratio_amplitude = 10 ** (ratio_dB / 20.) # conversion from dB to amplitude +ratio_amplitude = 10 ** (ratio_dB / 20.0) # conversion from dB to amplitude masker *= ratio_amplitude ############################################################################### # Looking at the overlaid traces, you can see that the resulting SNR varies as # a function of time. -colors = ['#4477AA', '#EE7733'] +colors = ["#4477AA", "#EE7733"] fig, ax = plt.subplots() -ax.plot(t, target, label='target', alpha=0.5, color=colors[0], lw=0.5) -ax.plot(t, masker, label='masker', alpha=0.5, color=colors[1], lw=0.5) -ax.axhline(0.01, label='target RMS', color=colors[0], lw=1) -ax.axhline(0.01 * ratio_amplitude, label='masker RMS', color=colors[1], lw=1) -ax.set(xlabel='Time (sec)', ylabel='Amplitude', title='Calibrated', - xlim=t[[0, -1]]) +ax.plot(t, target, label="target", alpha=0.5, color=colors[0], lw=0.5) +ax.plot(t, masker, label="masker", alpha=0.5, color=colors[1], lw=0.5) +ax.axhline(0.01, label="target RMS", color=colors[0], lw=1) +ax.axhline(0.01 * ratio_amplitude, label="masker RMS", color=colors[1], lw=1) +ax.set(xlabel="Time (sec)", ylabel="Amplitude", title="Calibrated", xlim=t[[0, -1]]) ax.legend() fig.tight_layout() @@ -97,19 +94,19 @@ # SNR varies as a function of frequency. from scipy.fft import rfft, rfftfreq # noqa -f = rfftfreq(len(target), 1. / fs) + +f = rfftfreq(len(target), 1.0 / fs) T = np.abs(rfft(target)) / np.sqrt(len(target)) # normalize the FFT properly M = np.abs(rfft(masker)) / np.sqrt(len(target)) fig, ax = plt.subplots() -ax.plot(f, T, label='target', alpha=0.5, color=colors[0], lw=0.5) -ax.plot(f, M, label='masker', alpha=0.5, color=colors[1], lw=0.5) +ax.plot(f, T, label="target", alpha=0.5, color=colors[0], lw=0.5) +ax.plot(f, M, label="masker", alpha=0.5, color=colors[1], lw=0.5) T_rms = rms(T) M_rms = rms(M) -print('Parseval\'s theorem: target RMS still %s' % (T_rms,)) -print('dB TMR is still %s' % (20 * np.log10(T_rms / M_rms),)) -ax.axhline(T_rms, label='target RMS', color=colors[0], lw=1) -ax.axhline(M_rms, label='masker RMS', color=colors[1], lw=1) -ax.set(xlabel='Freq (Hz)', ylabel='Amplitude', title='Spectrum', - xlim=f[[0, -1]]) +print("Parseval's theorem: target RMS still %s" % (T_rms,)) +print("dB TMR is still %s" % (20 * np.log10(T_rms / M_rms),)) +ax.axhline(T_rms, label="target RMS", color=colors[0], lw=1) +ax.axhline(M_rms, label="masker RMS", color=colors[1], lw=1) +ax.set(xlabel="Freq (Hz)", ylabel="Amplitude", title="Spectrum", xlim=f[[0, -1]]) ax.legend() fig.tight_layout() diff --git a/examples/stimuli/texture_stimuli.py b/examples/stimuli/texture_stimuli.py index 70e6cc6a..5d21b25a 100644 --- a/examples/stimuli/texture_stimuli.py +++ b/examples/stimuli/texture_stimuli.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ ======================== Generate texture stimuli @@ -7,25 +6,25 @@ This shows how to generate texture coherence stimuli. """ -import numpy as np import matplotlib.pyplot as plt +import numpy as np -from expyfun.stimuli import texture_ERB, play_sound +from expyfun.stimuli import play_sound, texture_ERB fs = 24414 n_freqs = 20 n_coh = 18 # very coherent example # let's make a textured stimilus and play it -sig = texture_ERB(n_freqs, n_coh, fs=fs, seq=('inc', 'nb', 'sam')) +sig = texture_ERB(n_freqs, n_coh, fs=fs, seq=("inc", "nb", "sam")) play_sound(sig, fs, norm=True, wait=True) ############################################################################### # Let's look at the time course t = np.arange(len(sig)) / float(fs) fig, ax = plt.subplots(1) -ax.plot(t, sig.T, color='k') -ax.set(xlabel='Time (sec)', ylabel='Amplitude (normalized)', xlim=t[[0, -1]]) +ax.plot(t, sig.T, color="k") +ax.set(xlabel="Time (sec)", ylabel="Amplitude (normalized)", xlim=t[[0, -1]]) fig.tight_layout() ############################################################################### @@ -33,17 +32,15 @@ fig, ax = plt.subplots(1, figsize=(8, 2)) img = ax.specgram(sig, NFFT=1024, Fs=fs, noverlap=800)[3] img.set_clim([img.get_clim()[1] - 50, img.get_clim()[1]]) -ax.set(xlim=t[[0, -1]], ylim=[0, 10000], xlabel='Time (sec)', - ylabel='Freq (Hz)') +ax.set(xlim=t[[0, -1]], ylim=[0, 10000], xlabel="Time (sec)", ylabel="Freq (Hz)") fig.tight_layout() ############################################################################### # And the long-term spectrum: fig, ax = plt.subplots(1) -ax.psd(sig, NFFT=16384, Fs=fs, color='k') +ax.psd(sig, NFFT=16384, Fs=fs, color="k") xticks = [250, 500, 1000, 2000, 4000, 8000] -ax.set(xlabel='Frequency (Hz)', ylabel='Power (dB)', xlim=[100, 10000], - xscale='log') +ax.set(xlabel="Frequency (Hz)", ylabel="Power (dB)", xlim=[100, 10000], xscale="log") ax.set(xticks=xticks) ax.set(xticklabels=xticks) fig.tight_layout() diff --git a/examples/stimuli/tracker_staircase.py b/examples/stimuli/tracker_staircase.py index b4d4ca09..1b6f9e56 100644 --- a/examples/stimuli/tracker_staircase.py +++ b/examples/stimuli/tracker_staircase.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ ============================== Do an adaptive track staircase @@ -12,15 +11,15 @@ import numpy as np -from expyfun.stimuli import TrackerUD from expyfun.analyze import sigmoid +from expyfun.stimuli import TrackerUD print(__doc__) # Make a callback function that prints to the console, rather than log file def callback(event_type, value=None, timestamp=None): - print((str(event_type) + ':').ljust(40) + str(value)) + print((str(event_type) + ":").ljust(40) + str(value)) # Define parameters for modeled human subject (sigmoid probability) @@ -36,12 +35,12 @@ def callback(event_type, value=None, timestamp=None): # Do the task until the tracker stops while not tr.stopped: - tr.respond(rng.rand() < sigmoid(tr.x_current - true_thresh, - lower=chance, slope=slope)) + tr.respond( + rng.rand() < sigmoid(tr.x_current - true_thresh, lower=chance, slope=slope) + ) # Plot the results fig, ax, lines = tr.plot() lines += tr.plot_thresh(4, ax=ax) -ax.set_title('Adaptive track of model human (true threshold is {})' - .format(true_thresh)) +ax.set_title(f"Adaptive track of model human (true threshold is {true_thresh})") diff --git a/examples/stimuli/tracker_staircase_MHW.py b/examples/stimuli/tracker_staircase_MHW.py index d38d271e..959c6cb9 100644 --- a/examples/stimuli/tracker_staircase_MHW.py +++ b/examples/stimuli/tracker_staircase_MHW.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ ================================================= Do an adaptive track staircase with MHW procedure @@ -12,13 +11,13 @@ import numpy as np -from expyfun.stimuli import TrackerMHW from expyfun.analyze import sigmoid +from expyfun.stimuli import TrackerMHW # Make a callback function that prints to the console, rather than log file def callback(event_type, value=None, timestamp=None): - print((str(event_type) + ':').ljust(40) + str(value)) + print((str(event_type) + ":").ljust(40) + str(value)) # Define parameters for modeled human subject (sigmoid probability) @@ -34,12 +33,12 @@ def callback(event_type, value=None, timestamp=None): # Do the task until the tracker stops while not tr.stopped: - tr.respond(rng.rand() < sigmoid(tr.x_current - true_thresh, - lower=chance, slope=slope)) + tr.respond( + rng.rand() < sigmoid(tr.x_current - true_thresh, lower=chance, slope=slope) + ) # Plot the results fig, ax, lines = tr.plot() lines += tr.plot_thresh() -ax.set_title('Adaptive track of model human (true threshold is {})' - .format(true_thresh)) +ax.set_title(f"Adaptive track of model human (true threshold is {true_thresh})") diff --git a/examples/stimuli/vocoded_stimuli.py b/examples/stimuli/vocoded_stimuli.py index 116ed06a..8db0ab09 100644 --- a/examples/stimuli/vocoded_stimuli.py +++ b/examples/stimuli/vocoded_stimuli.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ ======================== Generate vocoded stimuli @@ -9,33 +8,33 @@ @author: larsoner """ -import numpy as np import matplotlib.pyplot as plt +import numpy as np -from expyfun.stimuli import vocode, play_sound, window_edges, read_wav, rms from expyfun import fetch_data_file +from expyfun.stimuli import play_sound, read_wav, rms, vocode, window_edges print(__doc__) -data, fs = read_wav(fetch_data_file('audio/dream.wav')) +data, fs = read_wav(fetch_data_file("audio/dream.wav")) data = window_edges(data[0], fs) t = np.arange(data.size) / float(fs) # noise vocoder -data_noise = vocode(data, fs, mode='noise') +data_noise = vocode(data, fs, mode="noise") data_noise = data_noise * 0.01 / rms(data_noise) # sinewave vocoder -data_tone = vocode(data, fs, mode='tone') +data_tone = vocode(data, fs, mode="tone") data_tone = data_tone * 0.01 / rms(data_tone) # poisson vocoder -data_click = vocode(data, fs, mode='poisson', rate=400) +data_click = vocode(data, fs, mode="poisson", rate=400) data_click = data_click * 0.01 / rms(data_click) # combine all three cutoff = data.shape[-1] // 3 data_allthree = data_noise.copy() -data_allthree[cutoff:2 * cutoff] = data_tone[cutoff:2 * cutoff] -data_allthree[2 * cutoff:] = data_click[2 * cutoff:] +data_allthree[cutoff : 2 * cutoff] = data_tone[cutoff : 2 * cutoff] +data_allthree[2 * cutoff :] = data_click[2 * cutoff :] snd = play_sound(data_allthree, fs, norm=False, wait=False) # Uncomment this to play the original, too: @@ -43,18 +42,18 @@ ax1 = plt.subplot(3, 1, 1) ax1.plot(t, data) -ax1.set_title('Original') -ax1.set_ylabel('Amplitude') +ax1.set_title("Original") +ax1.set_ylabel("Amplitude") ax2 = plt.subplot(3, 1, 2, sharex=ax1, sharey=ax1) ax2.plot(t, data_noise) -ax2.set_title('Vocoded') +ax2.set_title("Vocoded") ax3 = plt.subplot(3, 1, 3, sharex=ax1) -ax2.set_title('Spectrogram') -ax2.set_ylabel('Amplitude') +ax2.set_title("Spectrogram") +ax2.set_ylabel("Amplitude") ax3.specgram(data_noise, Fs=fs) ax3.set_xlim(t[[0, -1]]) -ax3.set_ylim([0, fs / 2.]) -ax3.set_ylabel('Frequency (hz)') -ax3.set_xlabel('Time (sec)') +ax3.set_ylim([0, fs / 2.0]) +ax3.set_ylabel("Frequency (hz)") +ax3.set_xlabel("Time (sec)") plt.tight_layout() plt.show() diff --git a/examples/sync/sample_rate_test.py b/examples/sync/sample_rate_test.py index 07c9a091..a13e2c70 100644 --- a/examples/sync/sample_rate_test.py +++ b/examples/sync/sample_rate_test.py @@ -44,18 +44,25 @@ print(__doc__) stim = np.zeros(int(1e6) + 1) -stim[[0, -1]] = 1. -with ExperimentController('FsTest', full_screen=False, noise_db=-np.inf, - participant='s', session='0', output_dir=None, - suppress_resamp=True, check_rms=None, - version='dev') as ec: - ec.identify_trial(ec_id='', ttl_id=[0]) +stim[[0, -1]] = 1.0 +with ExperimentController( + "FsTest", + full_screen=False, + noise_db=-np.inf, + participant="s", + session="0", + output_dir=None, + suppress_resamp=True, + check_rms=None, + version="dev", +) as ec: + ec.identify_trial(ec_id="", ttl_id=[0]) ec.load_buffer(stim) - print('Starting stimulus.') + print("Starting stimulus.") ec.start_stimulus() - wait_dur = len(stim) / ec.fs + 1. - print('Stimulus started. Please wait %d seconds.' % wait_dur) + wait_dur = len(stim) / ec.fs + 1.0 + print("Stimulus started. Please wait %d seconds." % wait_dur) if not building_doc: ec.wait_secs(wait_dur) ec.stop() - print('Stimulus done.') + print("Stimulus done.") diff --git a/examples/sync/sync_test.py b/examples/sync/sync_test.py index f245ad83..f59ef418 100644 --- a/examples/sync/sync_test.py +++ b/examples/sync/sync_test.py @@ -36,16 +36,24 @@ import numpy as np +import expyfun.analyze as ea from expyfun import ExperimentController, building_doc from expyfun.visual import Circle, Rectangle -import expyfun.analyze as ea n_channels = 2 click_idx = [0] -with ExperimentController('SyncTest', full_screen=True, noise_db=-np.inf, - participant='s', session='0', output_dir=None, - suppress_resamp=True, check_rms=None, - n_channels=n_channels, version='dev') as ec: +with ExperimentController( + "SyncTest", + full_screen=True, + noise_db=-np.inf, + participant="s", + session="0", + output_dir=None, + suppress_resamp=True, + check_rms=None, + n_channels=n_channels, + version="dev", +) as ec: click = np.r_[0.1, np.zeros(99)] # RMS = 0.01 data = np.zeros((n_channels, len(click))) data[click_idx] = click @@ -53,23 +61,23 @@ pressed = None screenshot = None # Make a circle so that the photodiode can be centered on the screen - circle = Circle(ec, 1, units='deg', fill_color='k', line_color='w') + circle = Circle(ec, 1, units="deg", fill_color="k", line_color="w") # Make a rectangle that is the standard credit card size - rect = Rectangle(ec, [0, 0, 8.56, 5.398], 'cm', None, '#AA3377') - while pressed != '8': # enable a clean quit if required - ec.set_background_color('white') + rect = Rectangle(ec, [0, 0, 8.56, 5.398], "cm", None, "#AA3377") + while pressed != "8": # enable a clean quit if required + ec.set_background_color("white") t1 = ec.start_stimulus(start_of_trial=False) # skip checks - ec.set_background_color('black') + ec.set_background_color("black") t2 = ec.flip() diff = round(1000 * (t2 - t1), 2) - ec.screen_text('IFI (ms): {}'.format(diff), wrap=True) + ec.screen_text(f"IFI (ms): {diff}", wrap=True) circle.draw() rect.draw() screenshot = ec.screenshot() if screenshot is None else screenshot ec.flip() ec.stamp_triggers([2, 4, 8]) ec.refocus() - pressed = ec.wait_one_press(0.5)[0] if not building_doc else '8' + pressed = ec.wait_one_press(0.5)[0] if not building_doc else "8" ec.stop() ea.plot_screen(screenshot) diff --git a/expyfun/__init__.py b/expyfun/__init__.py index 76ceb486..105a8ed9 100644 --- a/expyfun/__init__.py +++ b/expyfun/__init__.py @@ -8,16 +8,28 @@ from ._version import __version__ # have to import verbose first since it's needed by many things -from ._utils import (set_log_level, set_log_file, set_config, check_units, - get_config, get_config_path, fetch_data_file, - run_subprocess, verbose_dec as verbose, building_doc, - known_config_types) +from ._utils import ( + set_log_level, + set_log_file, + set_config, + check_units, + get_config, + get_config_path, + fetch_data_file, + run_subprocess, + verbose_dec as verbose, + building_doc, + known_config_types, +) from ._git import assert_version, download_version from ._experiment_controller import ExperimentController, get_keyboard_input from ._eyelink_controller import EyelinkController from ._sound_controllers import SoundCardController -from ._trigger_controllers import (decimals_to_binary, binary_to_decimals, - ParallelTrigger) +from ._trigger_controllers import ( + decimals_to_binary, + binary_to_decimals, + ParallelTrigger, +) from ._tdt_controller import TDTController from . import analyze from . import codeblocks diff --git a/expyfun/_experiment_controller.py b/expyfun/_experiment_controller.py index de851619..2cbdb02c 100644 --- a/expyfun/_experiment_controller.py +++ b/expyfun/_experiment_controller.py @@ -6,31 +6,43 @@ # # License: BSD (3-clause) -from collections import OrderedDict import inspect import json import os +import string import sys +import traceback as tb import warnings -from os import path as op +from collections import OrderedDict from functools import partial -import traceback as tb -import string +from os import path as op import numpy as np -from ._utils import (get_config, verbose_dec, _check_pyglet_version, - running_rms, _sanitize, logger, ZeroClock, date_str, - check_units, set_log_file, flush_logger, _TempDir, - string_types, _fix_audio_dims, input, _get_args, - _get_display, _wait_secs) +from ._git import __version__, assert_version +from ._input_controllers import CedrusBox, Joystick, Keyboard, Mouse +from ._sound_controllers import _AUTO_BACKENDS, SoundCardController, SoundPlayer from ._tdt_controller import TDTController from ._trigger_controllers import ParallelTrigger -from ._sound_controllers import (SoundPlayer, SoundCardController, - _AUTO_BACKENDS) -from ._input_controllers import Keyboard, CedrusBox, Mouse, Joystick -from .visual import Text, Rectangle, Video, _convert_color -from ._git import assert_version, __version__ +from ._utils import ( + ZeroClock, + _check_pyglet_version, + _fix_audio_dims, + _get_args, + _get_display, + _sanitize, + _TempDir, + _wait_secs, + check_units, + date_str, + flush_logger, + get_config, + logger, + running_rms, + set_log_file, + verbose_dec, +) +from .visual import Rectangle, Text, Video, _convert_color # Note: ec._trial_progress has three values: # 1. 'stopped', which ec.identify_trial turns into... @@ -40,7 +52,7 @@ _SLOW_LIMIT = 10000000 -class ExperimentController(object): +class ExperimentController: """Interface for hardware control (audio, buttonbox, eye tracker, etc.) Parameters @@ -142,14 +154,33 @@ class ExperimentController(object): """ @verbose_dec - def __init__(self, exp_name, audio_controller=None, response_device=None, - stim_rms=0.01, stim_fs=24414, stim_db=65, noise_db=45, - output_dir='data', window_size=None, screen_num=None, - full_screen=True, force_quit=None, participant=None, - monitor=None, trigger_controller=None, session=None, - check_rms='windowed', suppress_resamp=False, version=None, - safe_flipping=None, n_channels=2, - trigger_duration=0.01, joystick=False, verbose=None): + def __init__( + self, + exp_name, + audio_controller=None, + response_device=None, + stim_rms=0.01, + stim_fs=24414, + stim_db=65, + noise_db=45, + output_dir="data", + window_size=None, + screen_num=None, + full_screen=True, + force_quit=None, + participant=None, + monitor=None, + trigger_controller=None, + session=None, + check_rms="windowed", + suppress_resamp=False, + version=None, + safe_flipping=None, + n_channels=2, + trigger_duration=0.01, + joystick=False, + verbose=None, + ): # initialize some values self._stim_fs = stim_fs self._stim_rms = stim_rms @@ -158,7 +189,7 @@ def __init__(self, exp_name, audio_controller=None, response_device=None, self._stim_scaler = None self._suppress_resamp = suppress_resamp self.video = None - self._bgcolor = _convert_color('k') + self._bgcolor = _convert_color("k") # placeholder for extra actions to do on flip-and-play self._on_every_flip = [] self._on_next_flip = [] @@ -179,19 +210,23 @@ def __init__(self, exp_name, audio_controller=None, response_device=None, _check_pyglet_version(raise_error=True) # assure proper formatting for force-quit keys if force_quit is None: - force_quit = ['lctrl', 'rctrl'] - elif isinstance(force_quit, (int, string_types)): + force_quit = ["lctrl", "rctrl"] + elif isinstance(force_quit, (int, str)): force_quit = [str(force_quit)] - if 'escape' in force_quit: - logger.warning('Expyfun: using "escape" as a force-quit key ' - 'is not recommended because it has special ' - 'status in pyglet.') + if "escape" in force_quit: + logger.warning( + 'Expyfun: using "escape" as a force-quit key ' + "is not recommended because it has special " + "status in pyglet." + ) # check expyfun version if version is None: - raise RuntimeError('You must specify an expyfun version string' - ' to use ExperimentController, or specify ' - 'version=\'dev\' to override.') - elif version.lower() != 'dev': + raise RuntimeError( + "You must specify an expyfun version string" + " to use ExperimentController, or specify " + "version='dev' to override." + ) + elif version.lower() != "dev": assert_version(version) # set up timing # Use ZeroClock, which uses the "clock" fn but starts at zero @@ -203,28 +238,28 @@ def __init__(self, exp_name, audio_controller=None, response_device=None, self._exp_info = OrderedDict() for name in _get_args(self.__init__): - if name != 'self': + if name != "self": self._exp_info[name] = locals()[name] - self._exp_info['date'] = date_str() + self._exp_info["date"] = date_str() # skip verbose decorator frames - self._exp_info['file'] = \ - op.abspath(inspect.getfile(sys._getframe(3))) - self._exp_info['version_used'] = __version__ + self._exp_info["file"] = op.abspath(inspect.getfile(sys._getframe(3))) + self._exp_info["version_used"] = __version__ # session start dialog, if necessary - show_list = ['exp_name', 'date', 'file', 'participant', 'session'] - edit_list = ['participant', 'session'] # things editable in GUI + show_list = ["exp_name", "date", "file", "participant", "session"] + edit_list = ["participant", "session"] # things editable in GUI for key in show_list: value = self._exp_info[key] - if key in edit_list and value is not None and \ - not isinstance(value, string_types): - raise TypeError('{} must be string or None' - ''.format(value)) + if ( + key in edit_list + and value is not None + and not isinstance(value, str) + ): + raise TypeError(f"{value} must be string or None" "") if key in edit_list and value is None: - self._exp_info[key] = get_keyboard_input( - '{0}: '.format(key)) + self._exp_info[key] = get_keyboard_input(f"{key}: ") else: - print('{0}: {1}'.format(key, value)) + print(f"{key}: {value}") # # initialize log file @@ -235,92 +270,103 @@ def __init__(self, exp_name, audio_controller=None, response_device=None, output_dir = op.abspath(output_dir) if not op.isdir(output_dir): os.mkdir(output_dir) - basename = op.join(output_dir, '{}_{}' - ''.format(self._exp_info['participant'], - self._exp_info['date'])) + basename = op.join( + output_dir, + "{}_{}" "".format( + self._exp_info["participant"], self._exp_info["date"] + ), + ) self._output_dir = basename - self._log_file = self._output_dir + '.log' + self._log_file = self._output_dir + ".log" set_log_file(self._log_file) closer = partial(set_log_file, None) # initialize data file - self._data_file = open(self._output_dir + '.tab', 'a') + self._data_file = open(self._output_dir + ".tab", "a") self._extra_cleanup_fun.append(self.flush) # flush self._extra_cleanup_fun.append(self._data_file.close) # close self._extra_cleanup_fun.append(closer) # un-set log file - self._data_file.write('# ' + json.dumps(self._exp_info) + '\n') - self.write_data_line('event', 'value', 'timestamp') - logger.info('Expyfun: Using version %s (requested %s)' - % (__version__, version)) + self._data_file.write("# " + json.dumps(self._exp_info) + "\n") + self.write_data_line("event", "value", "timestamp") + logger.info( + "Expyfun: Using version %s (requested %s)" % (__version__, version) + ) # # set up monitor # if safe_flipping is None: - safe_flipping = not (get_config('SAFE_FLIPPING', '').lower() == - 'false') + safe_flipping = not (get_config("SAFE_FLIPPING", "").lower() == "false") if not safe_flipping: - logger.warning('Expyfun: Unsafe flipping mode enabled, flip ' - 'timing not guaranteed') + logger.warning( + "Expyfun: Unsafe flipping mode enabled, flip " + "timing not guaranteed" + ) self.safe_flipping = safe_flipping if screen_num is None: - screen_num = int(get_config('SCREEN_NUM', '0')) + screen_num = int(get_config("SCREEN_NUM", "0")) display = _get_display() screen = display.get_screens()[screen_num] if monitor is None: mon_size = [screen.width, screen.height] - mon_size = ','.join([str(d) for d in mon_size]) + mon_size = ",".join([str(d) for d in mon_size]) monitor = dict() - width = float(get_config('SCREEN_WIDTH', '51.0')) - dist = float(get_config('SCREEN_DISTANCE', '48.0')) - monitor['SCREEN_WIDTH'] = width - monitor['SCREEN_DISTANCE'] = dist - mon_size = get_config('SCREEN_SIZE_PIX', mon_size).split(',') + width = float(get_config("SCREEN_WIDTH", "51.0")) + dist = float(get_config("SCREEN_DISTANCE", "48.0")) + monitor["SCREEN_WIDTH"] = width + monitor["SCREEN_DISTANCE"] = dist + mon_size = get_config("SCREEN_SIZE_PIX", mon_size).split(",") mon_size = [int(p) for p in mon_size] - monitor['SCREEN_SIZE_PIX'] = mon_size + monitor["SCREEN_SIZE_PIX"] = mon_size if not isinstance(monitor, dict): - raise TypeError('monitor must be a dict, got %r' % (monitor,)) - req_mon_keys = ['SCREEN_WIDTH', 'SCREEN_DISTANCE', - 'SCREEN_SIZE_PIX'] + raise TypeError("monitor must be a dict, got %r" % (monitor,)) + req_mon_keys = ["SCREEN_WIDTH", "SCREEN_DISTANCE", "SCREEN_SIZE_PIX"] missing_keys = [key for key in req_mon_keys if key not in monitor] if missing_keys: - raise KeyError('monitor is missing required keys {0}' - ''.format(missing_keys)) - mon_size = monitor['SCREEN_SIZE_PIX'] - monitor['SCREEN_DPI'] = (monitor['SCREEN_SIZE_PIX'][0] / - (monitor['SCREEN_WIDTH'] * 0.393701)) - monitor['SCREEN_HEIGHT'] = (monitor['SCREEN_WIDTH'] / - float(monitor['SCREEN_SIZE_PIX'][0]) * - float(monitor['SCREEN_SIZE_PIX'][1])) + raise KeyError(f"monitor is missing required keys {missing_keys}" "") + mon_size = monitor["SCREEN_SIZE_PIX"] + monitor["SCREEN_DPI"] = monitor["SCREEN_SIZE_PIX"][0] / ( + monitor["SCREEN_WIDTH"] * 0.393701 + ) + monitor["SCREEN_HEIGHT"] = ( + monitor["SCREEN_WIDTH"] + / float(monitor["SCREEN_SIZE_PIX"][0]) + * float(monitor["SCREEN_SIZE_PIX"][1]) + ) self._monitor = monitor # # parse audio controller # if audio_controller is None: - audio_controller = {'TYPE': get_config('AUDIO_CONTROLLER', - 'sound_card')} - elif isinstance(audio_controller, string_types): + audio_controller = { + "TYPE": get_config("AUDIO_CONTROLLER", "sound_card") + } + elif isinstance(audio_controller, str): # old option, backward compat / shortcut if audio_controller in _AUTO_BACKENDS: audio_controller = { - 'TYPE': 'sound_card', - 'SOUND_CARD_BACKEND': audio_controller} + "TYPE": "sound_card", + "SOUND_CARD_BACKEND": audio_controller, + } else: - audio_controller = {'TYPE': audio_controller.lower()} + audio_controller = {"TYPE": audio_controller.lower()} elif not isinstance(audio_controller, dict): - raise TypeError('audio_controller must be a str or dict, got ' - 'type %s' % (type(audio_controller),)) - audio_type = audio_controller['TYPE'].lower() + raise TypeError( + "audio_controller must be a str or dict, got " + "type %s" % (type(audio_controller),) + ) + audio_type = audio_controller["TYPE"].lower() # # parse response device # if response_device is None: - response_device = get_config('RESPONSE_DEVICE', 'keyboard') - if response_device not in ['keyboard', 'tdt', 'cedrus']: - raise ValueError('response_device must be "keyboard", "tdt", ' - '"cedrus", or None') + response_device = get_config("RESPONSE_DEVICE", "keyboard") + if response_device not in ["keyboard", "tdt", "cedrus"]: + raise ValueError( + 'response_device must be "keyboard", "tdt", ' '"cedrus", or None' + ) self._response_device = response_device # @@ -329,28 +375,40 @@ def __init__(self, exp_name, audio_controller=None, response_device=None, trigger_duration = float(trigger_duration) if not 0.001 < trigger_duration <= 0.02: # probably an error - raise ValueError('high_duration must be between 0.001 and ' - '0.02 sec, got %s' % (trigger_duration,)) + raise ValueError( + "high_duration must be between 0.001 and " + "0.02 sec, got %s" % (trigger_duration,) + ) # Audio (and for TDT, potentially keyboard) - if audio_type == 'tdt': - logger.info('Expyfun: Setting up TDT') + if audio_type == "tdt": + logger.info("Expyfun: Setting up TDT") if n_channels != 2: - raise ValueError('n_channels must be equal to 2 for the ' - 'TDT backend, got %s' % (n_channels,)) + raise ValueError( + "n_channels must be equal to 2 for the " + "TDT backend, got %s" % (n_channels,) + ) if trigger_duration != 0.01: - raise ValueError('trigger_duration must be 0.01 for TDT, ' - 'got %s' % (trigger_duration,)) + raise ValueError( + "trigger_duration must be 0.01 for TDT, " + "got %s" % (trigger_duration,) + ) self._ac = TDTController(audio_controller, ec=self) self.audio_type = self._ac.model - elif audio_type == 'sound_card': + elif audio_type == "sound_card": self._ac = SoundCardController( - audio_controller, self.stim_fs, n_channels, - trigger_duration=trigger_duration, ec=self) + audio_controller, + self.stim_fs, + n_channels, + trigger_duration=trigger_duration, + ec=self, + ) self.audio_type = self._ac.backend_name else: - raise ValueError('audio_controller[\'TYPE\'] must be "tdt" ' - 'or "sound_card", got %r.' % (audio_type,)) + raise ValueError( + "audio_controller['TYPE'] must be \"tdt\" " + 'or "sound_card", got %r.' % (audio_type,) + ) del audio_type self._extra_cleanup_fun.insert(0, self._ac.halt) # audio scaling factor; ensure uniform intensity across devices @@ -358,46 +416,50 @@ def __init__(self, exp_name, audio_controller=None, response_device=None, self.set_noise_db(self._noise_db) if self._fs_mismatch: - msg = ('Expyfun: Mismatch between reported stim sample ' - 'rate ({0}) and device sample rate ({1}). ' - .format(self.stim_fs, self.fs)) + msg = ( + "Expyfun: Mismatch between reported stim sample " + f"rate ({self.stim_fs}) and device sample rate ({self.fs}). " + ) if self._suppress_resamp: - msg += ('Nothing will be done about this because ' - 'suppress_resamp is "True"') + msg += ( + "Nothing will be done about this because " + 'suppress_resamp is "True"' + ) else: - msg += ('Experiment Controller will resample for you, but ' - 'this takes a non-trivial amount of processing ' - 'time and may compromise your experimental ' - 'timing and/or cause artifacts.') + msg += ( + "Experiment Controller will resample for you, but " + "this takes a non-trivial amount of processing " + "time and may compromise your experimental " + "timing and/or cause artifacts." + ) logger.warning(msg) # # set up visual window (must be done before keyboard and mouse) # - logger.info('Expyfun: Setting up screen') + logger.info("Expyfun: Setting up screen") if full_screen: if window_size is None: - window_size = monitor['SCREEN_SIZE_PIX'] + window_size = monitor["SCREEN_SIZE_PIX"] else: if window_size is None: - window_size = get_config('WINDOW_SIZE', - '800,600').split(',') + window_size = get_config("WINDOW_SIZE", "800,600").split(",") window_size = [int(w) for w in window_size] window_size = np.array(window_size) if window_size.ndim != 1 or window_size.size != 2: - raise ValueError('window_size must be 2-element array-like or ' - 'None') + raise ValueError("window_size must be 2-element array-like or " "None") # open window and setup GL config self._setup_window(window_size, exp_name, full_screen, screen) # Keyboard - if response_device == 'keyboard': + if response_device == "keyboard": self._response_handler = Keyboard(self, force_quit) - elif response_device == 'tdt': + elif response_device == "tdt": if not isinstance(self._ac, TDTController): - raise ValueError('response_device can only be "tdt" if ' - 'tdt is used for audio') + raise ValueError( + 'response_device can only be "tdt" if ' "tdt is used for audio" + ) self._response_handler = self._ac self._ac._add_keyboard_init(self, force_quit) else: # response_device == 'cedrus' @@ -415,67 +477,79 @@ def __init__(self, exp_name, audio_controller=None, response_device=None, # self._ofp_critical_funs = list() if trigger_controller is None: - trigger_controller = get_config('TRIGGER_CONTROLLER', 'dummy') - if isinstance(trigger_controller, string_types): + trigger_controller = get_config("TRIGGER_CONTROLLER", "dummy") + if isinstance(trigger_controller, str): trigger_controller = dict(TYPE=trigger_controller) assert isinstance(trigger_controller, dict) trigger_controller = trigger_controller.copy() - known_keys = ('TYPE',) + 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': + "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') + 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 ' - 'used for audio') + raise ValueError( + "trigger_controller can only be " + '"sound_card" if the sound card is ' + "used for audio" + ) if self._ac._n_channels_stim == 0: - raise ValueError('cannot use sound card for triggering ' - 'when SOUND_CARD_TRIGGER_CHANNELS is ' - 'zero') + raise ValueError( + "cannot use sound card for triggering " + "when SOUND_CARD_TRIGGER_CHANNELS is " + "zero" + ) self._tc = self._ac - elif trigger_controller['TYPE'] in ['parallel', 'dummy']: + elif trigger_controller["TYPE"] in ["parallel", "dummy"]: addr = trigger_controller.get( - 'TRIGGER_ADDRESS', get_config('TRIGGER_ADDRESS', None)) + "TRIGGER_ADDRESS", get_config("TRIGGER_ADDRESS", None) + ) self._tc = ParallelTrigger( - trigger_controller['TYPE'], addr, - trigger_duration, ec=self) + 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, 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'])) - self._id_call_dict['ttl_id'] = self._stamp_binary_id + raise ValueError( + "trigger_controller type must be " + '"parallel", "dummy", "sound_card", or "tdt",' + "got {0}".format(trigger_controller["TYPE"]) + ) + self._id_call_dict["ttl_id"] = self._stamp_binary_id # other basic components self._mouse_handler = Mouse(self) - t = np.arange(44100 // 3) / 44100. + t = np.arange(44100 // 3) / 44100.0 car = sum([np.sin(2 * np.pi * f * t) for f in [800, 1000, 1200]]) self._beep = None self._beep_data = np.tile(car * np.exp(-t * 10) / 4, (2, 3)) # finish initialization - logger.info('Expyfun: Initialization complete') - logger.exp('Expyfun: Participant: {0}' - ''.format(self._exp_info['participant'])) - logger.exp('Expyfun: Session: {0}' - ''.format(self._exp_info['session'])) - ok_log = partial(self.write_data_line, 'trial_ok', None) + logger.info("Expyfun: Initialization complete") + logger.exp( + "Expyfun: Participant: {0}" "".format(self._exp_info["participant"]) + ) + logger.exp("Expyfun: Session: {0}" "".format(self._exp_info["session"])) + ok_log = partial(self.write_data_line, "trial_ok", None) self._on_trial_ok.append(ok_log) self._on_trial_ok.append(self.flush) - self._trial_progress = 'stopped' + self._trial_progress = "stopped" except Exception: self.close() raise @@ -483,19 +557,28 @@ def __init__(self, exp_name, audio_controller=None, response_device=None, self.flip() def __repr__(self): - """Return a useful string representation of the experiment - """ - string = ('' - ''.format(self._exp_info['exp_name'], - self._exp_info['participant'], - self._exp_info['session'], - self.audio_type)) + """Return a useful string representation of the experiment""" + string = '' "".format( + self._exp_info["exp_name"], + self._exp_info["participant"], + self._exp_info["session"], + self.audio_type, + ) return string -# ############################### SCREEN METHODS ############################## - def screen_text(self, text, pos=[0, 0], color='white', font_name='Arial', - font_size=24, wrap=True, units='norm', attr=True, - log_data=True): + # ############################### SCREEN METHODS ############################## + def screen_text( + self, + text, + pos=(0, 0), + color="white", + font_name="Arial", + font_size=24, + wrap=True, + units="norm", + attr=True, + log_data=True, + ): """Show some text on the screen. Parameters @@ -534,18 +617,39 @@ def screen_text(self, text, pos=[0, 0], color='white', font_name='Arial', ExperimentController.screen_prompt """ check_units(units) - scr_txt = Text(self, text, pos, color, font_name, font_size, - wrap=wrap, units=units, attr=attr) + scr_txt = Text( + self, + text, + pos, + color, + font_name, + font_size, + wrap=wrap, + units=units, + attr=attr, + ) scr_txt.draw() if log_data: - self.call_on_next_flip(partial(self.write_data_line, 'screen_text', - text)) + self.call_on_next_flip(partial(self.write_data_line, "screen_text", text)) return scr_txt - def screen_prompt(self, text, max_wait=np.inf, min_wait=0, live_keys=None, - timestamp=False, clear_after=True, pos=[0, 0], - color='white', font_name='Arial', font_size=24, - wrap=True, units='norm', attr=True, click=False): + def screen_prompt( + self, + text, + max_wait=np.inf, + min_wait=0, + live_keys=None, + timestamp=False, + clear_after=True, + pos=(0, 0), + color="white", + font_name="Arial", + font_size=24, + wrap=True, + units="norm", + attr=True, + click=False, + ): """Display text and (optionally) wait for user continuation Parameters @@ -605,12 +709,19 @@ def screen_prompt(self, text, max_wait=np.inf, min_wait=0, live_keys=None, """ if not isinstance(text, list): text = [text] - if not all([isinstance(t, string_types) for t in text]): - raise TypeError('text must be a string or list of strings') + if not all(isinstance(t, str) for t in text): + raise TypeError("text must be a string or list of strings") for t in text: - self.screen_text(t, pos=pos, color=color, font_name=font_name, - font_size=font_size, wrap=wrap, units=units, - attr=attr) + self.screen_text( + t, + pos=pos, + color=color, + font_name=font_name, + font_size=font_size, + wrap=wrap, + units=units, + attr=attr, + ) self.flip() fun = self.wait_one_click if click else self.wait_one_press out = fun(max_wait, min_wait, live_keys, timestamp) @@ -618,7 +729,7 @@ def screen_prompt(self, text, max_wait=np.inf, min_wait=0, live_keys=None, self.flip() return out - def set_background_color(self, color='black'): + def set_background_color(self, color="black"): """Set and draw a solid background color Parameters @@ -635,10 +746,11 @@ def set_background_color(self, color='black'): appropriate background color. """ from pyglet import gl + # we go a little over here to be safe from round-off errors Rectangle(self, pos=[0, 0, 2.1, 2.1], fill_color=color).draw() self._bgcolor = _convert_color(color) - gl.glClearColor(*[c / 255. for c in self._bgcolor]) + gl.glClearColor(*[c / 255.0 for c in self._bgcolor]) def start_stimulus(self, start_of_trial=True, flip=True, when=None): """Play audio, (optionally) flip screen, run any "on_flip" functions. @@ -679,17 +791,19 @@ def start_stimulus(self, start_of_trial=True, flip=True, when=None): `call_on_next_flip` and `call_on_every_flip`. """ if start_of_trial: - if self._trial_progress != 'identified': - raise RuntimeError('Trial ID must be stamped before starting ' - 'the trial') - self._trial_progress = 'started' - extra = 'flipping screen and ' if flip else '' - logger.exp('Expyfun: Starting stimuli: {0}playing audio'.format(extra)) + if self._trial_progress != "identified": + raise RuntimeError( + "Trial ID must be stamped before starting " "the trial" + ) + self._trial_progress = "started" + extra = "flipping screen and " if flip else "" + logger.exp(f"Expyfun: Starting stimuli: {extra}playing audio") # ensure self._play comes first in list, followed by other critical # private functions (e.g., EL stamping), then user functions: if flip: - self._on_next_flip = ([self._play] + self._ofp_critical_funs + - self._on_next_flip) + self._on_next_flip = ( + [self._play] + self._ofp_critical_funs + self._on_next_flip + ) stimulus_time = self.flip(when) else: if when is not None: @@ -757,51 +871,56 @@ def _convert_units(self, verts, fro, to): check_units(fro) verts = np.array(np.atleast_2d(verts), dtype=float) if verts.shape[0] != 2: - raise RuntimeError('verts must have 2 rows') + raise RuntimeError("verts must have 2 rows") if fro == to: return verts # simplify by using two if neither is in normalized (native) units - if 'norm' not in [to, fro]: + if "norm" not in [to, fro]: # convert to normal - verts = self._convert_units(verts, fro, 'norm') + verts = self._convert_units(verts, fro, "norm") # convert from normal to dest - verts = self._convert_units(verts, 'norm', to) + verts = self._convert_units(verts, "norm", to) return verts # figure out our actual transition, knowing one is 'norm' win_w_pix, win_h_pix = self.window_size_pix mon_w_pix, mon_h_pix = self.monitor_size_pix - wh_cm = np.array([self._monitor['SCREEN_WIDTH'], - self._monitor['SCREEN_HEIGHT']], float) - d_cm = self._monitor['SCREEN_DISTANCE'] - cm_factors = (self.window_size_pix / self.monitor_size_pix * - wh_cm / 2.)[:, np.newaxis] - if 'pix' in [to, fro]: - if 'pix' == to: + wh_cm = np.array( + [self._monitor["SCREEN_WIDTH"], self._monitor["SCREEN_HEIGHT"]], float + ) + d_cm = self._monitor["SCREEN_DISTANCE"] + cm_factors = (self.window_size_pix / self.monitor_size_pix * wh_cm / 2.0)[ + :, np.newaxis + ] + if "pix" in [to, fro]: + if "pix" == to: # norm to pixels - x = np.array([[win_w_pix / 2., 0, win_w_pix / 2.], - [0, win_h_pix / 2., win_h_pix / 2.]]) + x = np.array( + [ + [win_w_pix / 2.0, 0, win_w_pix / 2.0], + [0, win_h_pix / 2.0, win_h_pix / 2.0], + ] + ) else: # pixels to norm - x = np.array([[2. / win_w_pix, 0, -1.], - [0, 2. / win_h_pix, -1.]]) + x = np.array([[2.0 / win_w_pix, 0, -1.0], [0, 2.0 / win_h_pix, -1.0]]) verts = np.dot(x, np.r_[verts, np.ones((1, verts.shape[1]))]) - elif 'deg' in [to, fro]: - if 'deg' == to: + elif "deg" in [to, fro]: + if "deg" == to: # norm (window) to norm (whole screen), then to deg verts = np.rad2deg(np.arctan2(verts * cm_factors, d_cm)) else: # deg to norm (whole screen), to norm (window) verts = (d_cm * np.tan(np.deg2rad(verts))) / cm_factors - elif 'cm' in [to, fro]: - if 'cm' == to: + elif "cm" in [to, fro]: + if "cm" == to: verts = verts * cm_factors else: verts = verts / cm_factors else: - raise KeyError('unknown conversion "{}" to "{}"'.format(fro, to)) + raise KeyError(f'unknown conversion "{fro}" to "{to}"') return verts def screenshot(self): @@ -817,9 +936,10 @@ def screenshot(self): """ import pyglet from PIL import Image + tempdir = _TempDir() - fname = op.join(tempdir, 'tmp.png') - with open(fname, 'wb') as fid: + fname = op.join(tempdir, "tmp.png") + with open(fname, "wb") as fid: pyglet.image.get_buffer_manager().get_color_buffer().save(file=fid) with Image.open(fname) as img: data = np.array(img) @@ -844,7 +964,7 @@ def window(self): @property def dpi(self): - return self._monitor['SCREEN_DPI'] + return self._monitor["SCREEN_DPI"] @property def window_size_pix(self): @@ -852,10 +972,10 @@ def window_size_pix(self): @property def monitor_size_pix(self): - return np.array(self._monitor['SCREEN_SIZE_PIX']) + return np.array(self._monitor["SCREEN_SIZE_PIX"]) -# ############################### VIDEO METHODS ############################### - def load_video(self, file_name, pos=(0, 0), units='norm', center=True): + # ############################### VIDEO METHODS ############################### + def load_video(self, file_name, pos=(0, 0), units="norm", center=True): """Load a video. Parameters @@ -877,26 +997,27 @@ def load_video(self, file_name, pos=(0, 0), units='norm', center=True): self.video = Video(self, file_name, pos, units) except MediaFormatException as exp: raise RuntimeError( - 'Something is wrong; probably you tried to load a ' - 'compressed video file but you do not have FFmpeg/Avbin ' - 'installed. Download and install it; if you are on Windows, ' - 'you may also need to manually copy the .dll file(s) ' - 'from C:\\Windows\\system32 to C:\\Windows\\SysWOW64.:\n%s' - % (exp,)) + "Something is wrong; probably you tried to load a " + "compressed video file but you do not have FFmpeg/Avbin " + "installed. Download and install it; if you are on Windows, " + "you may also need to manually copy the .dll file(s) " + "from C:\\Windows\\system32 to C:\\Windows\\SysWOW64.:\n%s" % (exp,) + ) def delete_video(self): """Delete the video.""" self.video._delete() self.video = None -# ############################### PYGLET EVENTS ############################### -# https://pyglet.readthedocs.io/en/latest/programming_guide/eventloop.html#dispatching-events-manually # noqa + # ############################### PYGLET EVENTS ############################### + # https://pyglet.readthedocs.io/en/latest/programming_guide/eventloop.html#dispatching-events-manually # noqa def _setup_event_loop(self): - from pyglet.app import platform_event_loop, event_loop + from pyglet.app import event_loop, platform_event_loop + event_loop.has_exit = False platform_event_loop.start() - event_loop.dispatch_event('on_enter') + event_loop.dispatch_event("on_enter") event_loop.is_running = True self._extra_cleanup_fun.append(self._end_event_loop) # This is when Pyglet calls: @@ -905,6 +1026,7 @@ def _setup_event_loop(self): def _dispatch_events(self): import pyglet + pyglet.clock.tick() self._win.dispatch_events() # timeout = self._event_loop.idle() @@ -912,27 +1034,40 @@ def _dispatch_events(self): pyglet.app.platform_event_loop.step(timeout) def _end_event_loop(self): - from pyglet.app import platform_event_loop, event_loop + from pyglet.app import event_loop, platform_event_loop + event_loop.is_running = False - event_loop.dispatch_event('on_exit') + event_loop.dispatch_event("on_exit") platform_event_loop.stop() -# ############################### OPENGL METHODS ############################## + # ############################### OPENGL METHODS ############################## def _setup_window(self, window_size, exp_name, full_screen, screen): import pyglet from pyglet import gl + # Use 16x sampling here - config_kwargs = dict(depth_size=8, double_buffer=True, stereo=False, - stencil_size=0, samples=0, sample_buffers=0) + config_kwargs = dict( + depth_size=8, + double_buffer=True, + stereo=False, + stencil_size=0, + samples=0, + sample_buffers=0, + ) # Travis can't handle multi-sampling, but our production machines must - if os.getenv('TRAVIS') == 'true': - del config_kwargs['samples'], config_kwargs['sample_buffers'] + if os.getenv("TRAVIS") == "true": + del config_kwargs["samples"], config_kwargs["sample_buffers"] self._full_screen = full_screen - win_kwargs = dict(width=int(window_size[0]), - height=int(window_size[1]), - caption=exp_name, fullscreen=False, - screen=screen, style='borderless', visible=False, - config=pyglet.gl.Config(**config_kwargs)) + win_kwargs = dict( + width=int(window_size[0]), + height=int(window_size[1]), + caption=exp_name, + fullscreen=False, + screen=screen, + style="borderless", + visible=False, + config=pyglet.gl.Config(**config_kwargs), + ) max_try = 5 # sometimes it fails for unknown reasons for ii in range(max_try): @@ -944,16 +1079,15 @@ def _setup_window(self, window_size, exp_name, full_screen, screen): else: break if not full_screen: - x = int(win.screen.width / 2. - win.width / 2.) - y = int(win.screen.height / 2. - win.height / 2.) + x = int(win.screen.width / 2.0 - win.width / 2.0) + y = int(win.screen.height / 2.0 - win.height / 2.0) win.set_location(x, y) self._win = win # with the context set up, do basic GL initialization gl.glClearColor(0.0, 0.0, 0.0, 1.0) # set the color to clear to gl.glClearDepth(1.0) # clear value for the depth buffer # set the viewport size - gl.glViewport(0, 0, int(self.window_size_pix[0]), - int(self.window_size_pix[1])) + gl.glViewport(0, 0, int(self.window_size_pix[0]), int(self.window_size_pix[1])) # set the projection matrix gl.glMatrixMode(gl.GL_PROJECTION) gl.glLoadIdentity() @@ -968,18 +1102,21 @@ def _setup_window(self, window_size, exp_name, full_screen, screen): gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) gl.glShadeModel(gl.GL_SMOOTH) gl.glClear(gl.GL_COLOR_BUFFER_BIT) - v_ = False if os.getenv('_EXPYFUN_WIN_INVISIBLE') == 'true' else True + v_ = False if os.getenv("_EXPYFUN_WIN_INVISIBLE") == "true" else True self.set_visible(v_) # this is when we set fullscreen # ensure we got the correct window size got_size = win.get_size() if not np.array_equal(got_size, window_size): - raise RuntimeError('Window size requested by config (%s) does not ' - 'match obtained window size (%s), is the ' - 'screen resolution set incorrectly?' - % (window_size, got_size)) + raise RuntimeError( + "Window size requested by config (%s) does not " + "match obtained window size (%s), is the " + "screen resolution set incorrectly?" % (window_size, got_size) + ) self._dispatch_events() - logger.info('Initialized %s window on screen %s with DPI %0.2f' - % (window_size, screen, self.dpi)) + logger.info( + "Initialized %s window on screen %s with DPI %0.2f" + % (window_size, screen, self.dpi) + ) def flip(self, when=None): """Flip screen, then run any "on-flip" functions. @@ -1013,6 +1150,7 @@ def flip(self, when=None): `call_on_every_flip`. """ from pyglet import gl + if when is not None: self.wait_until(when) call_list = self._on_next_flip + self._on_every_flip @@ -1033,7 +1171,7 @@ def flip(self, when=None): flip_time = self.get_time() for function in call_list: function() - self.write_data_line('flip', flip_time) + self.write_data_line("flip", flip_time) self._on_next_flip = [] return flip_time @@ -1056,7 +1194,7 @@ def estimate_screen_fs(self, n_rep=10): """ n_rep = int(n_rep) times = [self.flip() for _ in range(n_rep)] - return 1. / np.median(np.diff(times[1:])) + return 1.0 / np.median(np.diff(times[1:])) def set_visible(self, visible=True, flip=True): """Set the window visibility @@ -1075,13 +1213,13 @@ def set_visible(self, visible=True, flip=True): """ self._win.set_fullscreen(self._full_screen) self._win.set_visible(visible) - logger.exp('Expyfun: Set screen visibility {0}'.format(visible)) + logger.exp(f"Expyfun: Set screen visibility {visible}") if visible and flip: self.flip() # it seems like newer Pyglet sometimes messes up without two flips self.flip() -# ############################## KEYPRESS METHODS ############################# + # ############################## KEYPRESS METHODS ############################# def listen_presses(self): """Start listening for keypresses. @@ -1093,8 +1231,14 @@ def listen_presses(self): """ self._response_handler.listen_presses() - def get_presses(self, live_keys=None, timestamp=True, relative_to=None, - kind='presses', return_kinds=False): + def get_presses( + self, + live_keys=None, + timestamp=True, + relative_to=None, + kind="presses", + return_kinds=False, + ): """Get the entire keyboard / button box buffer. This will also clear events that are not requested per ``type``. @@ -1140,10 +1284,17 @@ def get_presses(self, live_keys=None, timestamp=True, relative_to=None, ExperimentController.wait_for_presses """ return self._response_handler.get_presses( - live_keys, timestamp, relative_to, kind, return_kinds) - - def wait_one_press(self, max_wait=np.inf, min_wait=0.0, live_keys=None, - timestamp=True, relative_to=None): + live_keys, timestamp, relative_to, kind, return_kinds + ) + + def wait_one_press( + self, + max_wait=np.inf, + min_wait=0.0, + live_keys=None, + timestamp=True, + relative_to=None, + ): """Returns only the first button pressed after min_wait. Parameters @@ -1182,10 +1333,12 @@ def wait_one_press(self, max_wait=np.inf, min_wait=0.0, live_keys=None, ExperimentController.wait_for_presses """ return self._response_handler.wait_one_press( - max_wait, min_wait, live_keys, timestamp, relative_to) + max_wait, min_wait, live_keys, timestamp, relative_to + ) - def wait_for_presses(self, max_wait, min_wait=0.0, live_keys=None, - timestamp=True, relative_to=None): + def wait_for_presses( + self, max_wait, min_wait=0.0, live_keys=None, timestamp=True, relative_to=None + ): """Returns all button presses between min_wait and max_wait. Parameters @@ -1222,9 +1375,10 @@ def wait_for_presses(self, max_wait, min_wait=0.0, live_keys=None, ExperimentController.wait_one_press """ return self._response_handler.wait_for_presses( - max_wait, min_wait, live_keys, timestamp, relative_to) + max_wait, min_wait, live_keys, timestamp, relative_to + ) - def _log_presses(self, pressed, kind='key'): + def _log_presses(self, pressed, kind="key"): """Write key presses to data file.""" # This function will typically be called by self._response_handler # after it retrieves some button presses @@ -1235,10 +1389,18 @@ def check_force_quit(self): """Check to see if any force quit keys were pressed.""" self._response_handler.check_force_quit() - def text_input(self, stop_key='return', instruction_string='Type' - ' response below', pos=[0, 0], color='white', - font_name='Arial', font_size=24, wrap=True, units='norm', - all_caps=True): + def text_input( + self, + stop_key="return", + instruction_string="Type" " response below", + pos=(0, 0), + color="white", + font_name="Arial", + font_size=24, + wrap=True, + units="norm", + all_caps=True, + ): """Allows participant to input text and view on the screen. Parameters @@ -1271,28 +1433,34 @@ def text_input(self, stop_key='return', instruction_string='Type' text : str The final input string. """ - letters = string.ascii_letters + ' ' - text = str() + letters = string.ascii_letters + " " + text = "" while True: - self.screen_text(instruction_string + '\n\n' + text + '|', - pos=pos, color=color, - font_name=font_name, font_size=font_size, - wrap=wrap, units=units, log_data=False) + self.screen_text( + instruction_string + "\n\n" + text + "|", + pos=pos, + color=color, + font_name=font_name, + font_size=font_size, + wrap=wrap, + units=units, + log_data=False, + ) self.flip() letter = self.wait_one_press(timestamp=False) if letter == stop_key: self.flip() break - if letter == 'backspace': + if letter == "backspace": text = text[:-1] else: - letter = ' ' if letter == 'space' else letter + letter = " " if letter == "space" else letter letter = letter.upper() if all_caps else letter - text += letter if letter in letters else '' - self.write_data_line('text_input', text) + text += letter if letter in letters else "" + self.write_data_line("text_input", text) return text -# ############################## KEYPRESS METHODS ############################# + # ############################## KEYPRESS METHODS ############################# def listen_joystick_button_presses(self): """Start listening for joystick buttons. @@ -1302,8 +1470,9 @@ def listen_joystick_button_presses(self): """ self._joystick_handler.listen_presses() - def get_joystick_button_presses(self, timestamp=True, relative_to=None, - kind='presses', return_kinds=False): + def get_joystick_button_presses( + self, timestamp=True, relative_to=None, kind="presses", return_kinds=False + ): """Get the entire joystick buffer. This will also clear events that are not requested per ``type``. @@ -1338,7 +1507,8 @@ def get_joystick_button_presses(self, timestamp=True, relative_to=None, """ self._dispatch_events() return self._joystick_handler.get_presses( - None, timestamp, relative_to, kind, return_kinds) + None, timestamp, relative_to, kind, return_kinds + ) def get_joystick_value(self, kind): """Get the current joystick x direction. @@ -1355,7 +1525,7 @@ def get_joystick_value(self, kind): """ return getattr(self._joystick_handler, kind) -# ############################## MOUSE METHODS ################################ + # ############################## MOUSE METHODS ################################ def listen_clicks(self): """Start listening for mouse clicks. @@ -1401,10 +1571,9 @@ def get_clicks(self, live_buttons=None, timestamp=True, relative_to=None): ExperimentController.wait_one_click ExperimentController.wait_for_clicks """ - return self._mouse_handler.get_clicks(live_buttons, timestamp, - relative_to) + return self._mouse_handler.get_clicks(live_buttons, timestamp, relative_to) - def get_mouse_position(self, units='pix'): + def get_mouse_position(self, units="pix"): """Mouse position in screen coordinates Parameters @@ -1427,7 +1596,7 @@ def get_mouse_position(self, units='pix'): """ check_units(units) pos = np.array(self._mouse_handler.pos) - pos = self._convert_units(pos[:, np.newaxis], 'norm', units)[:, 0] + pos = self._convert_units(pos[:, np.newaxis], "norm", units)[:, 0] return pos def toggle_cursor(self, visibility, flip=False): @@ -1456,8 +1625,15 @@ def toggle_cursor(self, visibility, flip=False): if flip: self.flip() - def wait_one_click(self, max_wait=np.inf, min_wait=0.0, live_buttons=None, - timestamp=True, relative_to=None, visible=None): + def wait_one_click( + self, + max_wait=np.inf, + min_wait=0.0, + live_buttons=None, + timestamp=True, + relative_to=None, + visible=None, + ): """Returns only the first mouse button clicked after min_wait. Parameters @@ -1501,11 +1677,11 @@ def wait_one_click(self, max_wait=np.inf, min_wait=0.0, live_buttons=None, ExperimentController.toggle_cursor ExperimentController.wait_for_clicks """ - return self._mouse_handler.wait_one_click(max_wait, min_wait, - live_buttons, timestamp, - relative_to, visible) + return self._mouse_handler.wait_one_click( + max_wait, min_wait, live_buttons, timestamp, relative_to, visible + ) - def move_mouse_to(self, pos, units='norm'): + def move_mouse_to(self, pos, units="norm"): """Move the mouse position to the specified position. Parameters @@ -1517,8 +1693,15 @@ def move_mouse_to(self, pos, units='norm'): """ self._mouse_handler._move_to(pos, units) - def wait_for_clicks(self, max_wait=np.inf, min_wait=0.0, live_buttons=None, - timestamp=True, relative_to=None, visible=None): + def wait_for_clicks( + self, + max_wait=np.inf, + min_wait=0.0, + live_buttons=None, + timestamp=True, + relative_to=None, + visible=None, + ): """Returns all clicks between min_wait and max_wait. Parameters @@ -1561,12 +1744,19 @@ def wait_for_clicks(self, max_wait=np.inf, min_wait=0.0, live_buttons=None, ExperimentController.toggle_cursor ExperimentController.wait_one_click """ - return self._mouse_handler.wait_for_clicks(max_wait, min_wait, - live_buttons, timestamp, - relative_to, visible) - - def wait_for_click_on(self, objects, max_wait=np.inf, min_wait=0.0, - live_buttons=None, timestamp=True, relative_to=None): + return self._mouse_handler.wait_for_clicks( + max_wait, min_wait, live_buttons, timestamp, relative_to, visible + ) + + def wait_for_click_on( + self, + objects, + max_wait=np.inf, + min_wait=0.0, + live_buttons=None, + timestamp=True, + relative_to=None, + ): """Returns the first click after min_wait over a visual object. Parameters @@ -1608,21 +1798,19 @@ def wait_for_click_on(self, objects, max_wait=np.inf, min_wait=0.0, if isinstance(objects, legal_types): objects = [objects] elif not isinstance(objects, list): - raise TypeError('objects must be a list or one of: %s' % - (legal_types,)) + raise TypeError("objects must be a list or one of: %s" % (legal_types,)) return self._mouse_handler.wait_for_click_on( - objects, max_wait, min_wait, live_buttons, timestamp, relative_to) + objects, max_wait, min_wait, live_buttons, timestamp, relative_to + ) def _log_clicks(self, clicked): - """Write mouse clicks to data file. - """ + """Write mouse clicks to data file.""" # This function will typically be called by self._response_handler # after it retrieves some mouse clicks for button, x, y, stamp in clicked: - self.write_data_line('mouseclick', '%s,%i,%i' % (button, x, y), - stamp) + self.write_data_line("mouseclick", "%s,%i,%i" % (button, x, y), stamp) -# ############################## AUDIO METHODS ################################ + # ############################## AUDIO METHODS ################################ def system_beep(self): """Play a system beep @@ -1674,13 +1862,13 @@ def load_buffer(self, samples): ExperimentController.stop """ if self._playing: - raise RuntimeError('Previous audio must be stopped before loading ' - 'the buffer') + raise RuntimeError( + "Previous audio must be stopped before loading " "the buffer" + ) samples = self._validate_audio(samples) - if not np.isclose(self._stim_scaler, 1.): + if not np.isclose(self._stim_scaler, 1.0): samples = samples * self._stim_scaler - logger.exp('Expyfun: Loading {} samples to buffer' - ''.format(samples.size)) + logger.exp(f"Expyfun: Loading {samples.size} samples to buffer" "") self._ac.load_buffer(samples) def play(self): @@ -1698,19 +1886,18 @@ def play(self): ExperimentController.start_stimulus ExperimentController.stop """ - logger.exp('Expyfun: Playing audio') + logger.exp("Expyfun: Playing audio") # ensure self._play comes first in list: self._play() return self.get_time() def _play(self): - """Play the audio buffer. - """ + """Play the audio buffer.""" if self._playing: - raise RuntimeError('Previous audio must be stopped before playing') + raise RuntimeError("Previous audio must be stopped before playing") self._ac.play() - logger.debug('Expyfun: started audio') - self.write_data_line('play') + logger.debug("Expyfun: started audio") + self.write_data_line("play") @property def _playing(self): @@ -1735,8 +1922,8 @@ def stop(self, wait=False): """ if self._ac is not None: # need to check b/c used in __exit__ self._ac.stop(wait=wait) - self.write_data_line('stop') - logger.exp('Expyfun: Audio stopped and reset.') + self.write_data_line("stop") + logger.exp("Expyfun: Audio stopped and reset.") def set_noise_db(self, new_db): """Set the level of the background noise. @@ -1775,10 +1962,9 @@ def set_stim_db(self, new_db): # not immediate: new value is applied on the next load_buffer call def _update_sound_scaler(self, desired_db, orig_rms): - """Calcs coefficient ensuring stim ampl equivalence across devices. - """ - exponent = (-(_get_dev_db(self.audio_type) - desired_db) / 20.0) - return (10 ** exponent) / float(orig_rms) + """Calcs coefficient ensuring stim ampl equivalence across devices.""" + exponent = -(_get_dev_db(self.audio_type) - desired_db) / 20.0 + return (10**exponent) / float(orig_rms) def _validate_audio(self, samples): """Converts audio sample data to the required format. @@ -1799,7 +1985,7 @@ def _validate_audio(self, samples): # check values if samples.size and np.max(np.abs(samples)) > 1: - raise ValueError('Sound data exceeds +/- 1.') + raise ValueError("Sound data exceeds +/- 1.") # samples /= np.max(np.abs(samples),axis=0) # check shape and dimensions, make stereo @@ -1810,52 +1996,60 @@ def _validate_audio(self, samples): if np.isclose(self.stim_fs, 24414, atol=1): max_samples = 4000000 - 1 if samples.shape[0] > max_samples: - raise RuntimeError('Sample too long {0} > {1}' - ''.format(samples.shape[0], max_samples)) + raise RuntimeError( + f"Sample too long {samples.shape[0]} > {max_samples}" "" + ) # resample if needed if self._fs_mismatch and not self._suppress_resamp: - logger.warning('Expyfun: Resampling {} seconds of audio' - ''.format(round(len(samples) / self.stim_fs, 2))) + logger.warning( + f"Expyfun: Resampling {round(len(samples) / self.stim_fs, 2)} " + "seconds of audio" + ) with warnings.catch_warnings(record=True): - warnings.simplefilter('ignore') + warnings.simplefilter("ignore") from mne.filter import resample if samples.size: samples = resample( - samples.astype(np.float64), self.fs, self.stim_fs, - axis=0).astype(np.float32) + samples.astype(np.float64), self.fs, self.stim_fs, axis=0 + ).astype(np.float32) # check RMS if self._check_rms is not None and samples.size: chans = [samples[:, x] for x in range(samples.shape[1])] - if self._check_rms == 'wholefile': - chan_rms = [np.sqrt(np.mean(x ** 2)) for x in chans] + if self._check_rms == "wholefile": + chan_rms = [np.sqrt(np.mean(x**2)) for x in chans] max_rms = max(chan_rms) else: # 'windowed' # ~226 sec at 44100 Hz if samples.size >= _SLOW_LIMIT and not self._slow_rms_warned: warnings.warn( - 'Checking RMS with a 10 ms window and many samples is ' - 'slow, consider using None or "wholefile" modes.') + "Checking RMS with a 10 ms window and many samples is " + 'slow, consider using None or "wholefile" modes.' + ) self._slow_rms_warned = True win_length = int(self.fs * 0.01) # 10ms running window max_rms = [running_rms(x, win_length).max() for x in chans] max_rms = max(max_rms) if max_rms > 2 * self._stim_rms: - warn_string = ('Expyfun: Stimulus max RMS ({}) exceeds stated ' - 'RMS ({}) by more than 6 dB.' - ''.format(max_rms, self._stim_rms)) + warn_string = ( + f"Expyfun: Stimulus max RMS ({max_rms}) exceeds stated " + f"RMS ({self._stim_rms}) by more than 6 dB." + "" + ) logger.warning(warn_string) warnings.warn(warn_string) elif max_rms < 0.5 * self._stim_rms: - warn_string = ('Expyfun: Stimulus max RMS ({}) is less than ' - 'stated RMS ({}) by more than 6 dB.' - ''.format(max_rms, self._stim_rms)) + warn_string = ( + f"Expyfun: Stimulus max RMS ({max_rms}) is less than " + f"stated RMS ({self._stim_rms}) by more than 6 dB." + "" + ) logger.warning(warn_string) # let's make sure we don't change our version of this array later samples = samples.view() - samples.flags['WRITEABLE'] = False + samples.flags["WRITEABLE"] = False return samples def set_rms_checking(self, check_rms): @@ -1870,24 +2064,25 @@ def set_rms_checking(self, check_rms): ``stim_rms``. ``'wholefile'`` checks the RMS of the stimulus as a whole, while ``None`` disables RMS checking. """ - if check_rms not in [None, 'wholefile', 'windowed']: - raise ValueError('check_rms must be one of "wholefile", "windowed"' - ', or None.') + if check_rms not in [None, "wholefile", "windowed"]: + raise ValueError( + 'check_rms must be one of "wholefile", "windowed"' ", or None." + ) self._slow_rms_warned = False self._check_rms = check_rms -# ############################## OTHER METHODS ################################ + # ############################## OTHER METHODS ################################ @property def participant(self): - return self._exp_info['participant'] + return self._exp_info["participant"] @property def session(self): - return self._exp_info['session'] + return self._exp_info["session"] @property def exp_name(self): - return self._exp_info['exp_name'] + return self._exp_info["exp_name"] @property def data_fname(self): @@ -1923,35 +2118,38 @@ def write_data_line(self, event_type, value=None, timestamp=None): """ if timestamp is None: timestamp = self._master_clock() - ll = '\t'.join(_sanitize(x) for x in [timestamp, event_type, - value]) + '\n' + ll = "\t".join(_sanitize(x) for x in [timestamp, event_type, value]) + "\n" if self._data_file is not None: if self._data_file.closed: - logger.warning('Data line not written due to closed file %s:\n' - '%s' % (self.data_fname, ll[:-1])) + logger.warning( + "Data line not written due to closed file %s:\n" + "%s" % (self.data_fname, ll[:-1]) + ) else: self._data_file.write(ll) self.flush() def _get_time_correction(self, clock_type): - """Clock correction (sec) for different devices (screen, bbox, etc.) - """ - time_correction = (self._master_clock() - - self._time_correction_fxns[clock_type]()) + """Clock correction (sec) for different devices (screen, bbox, etc.)""" + time_correction = ( + self._master_clock() - self._time_correction_fxns[clock_type]() + ) if clock_type not in self._time_corrections: self._time_corrections[clock_type] = time_correction diff = time_correction - self._time_corrections[clock_type] max_dt = self._time_correction_maxs.get(clock_type, 50e-6) if np.abs(diff) > max_dt: - logger.warning('Expyfun: drift of > {} microseconds ({}) ' - 'between {} clock and EC master clock.' - ''.format(max_dt * 1e6, int(round(diff * 1e6)), - clock_type)) - logger.debug('Expyfun: time correction between {} clock and EC ' - 'master clock is {}. This is a change of {}.' - ''.format(clock_type, time_correction, time_correction - - self._time_corrections[clock_type])) + logger.warning( + f"Expyfun: drift of > {max_dt * 1e6} microseconds " + f"({int(round(diff * 1e6))}) " + f"between {clock_type} clock and EC master clock." + ) + logger.debug( + f"Expyfun: time correction between {clock_type} clock and EC " + f"master clock is {time_correction}. This is a change of " + f"{time_correction - self._time_corrections[clock_type]}." + ) return time_correction def wait_secs(self, secs): @@ -1997,9 +2195,11 @@ def wait_until(self, timestamp): """ time_left = timestamp - self._master_clock() if time_left < 0: - logger.warning('Expyfun: wait_until was called with a timestamp ' - '({}) that had already passed {} seconds prior.' - ''.format(timestamp, -time_left)) + logger.warning( + "Expyfun: wait_until was called with a timestamp " + f"({timestamp}) that had already passed {-time_left} seconds prior." + "" + ) else: self.wait_secs(time_left) return time_left @@ -2024,22 +2224,23 @@ def identify_trial(self, **ids): ExperimentController.stop ExperimentController.trial_ok """ - if self._trial_progress != 'stopped': - raise RuntimeError('Cannot identify a trial twice') + if self._trial_progress != "stopped": + raise RuntimeError("Cannot identify a trial twice") call_set = set(self._id_call_dict.keys()) passed_set = set(ids.keys()) if not call_set == passed_set: - raise KeyError('All keys passed in {0} must match the set of ' - 'keys required {1}'.format(passed_set, call_set)) + raise KeyError( + f"All keys passed in {passed_set} must match the set of " + f"keys required {call_set}" + ) ll = max([len(key) for key in ids.keys()]) for key, id_ in ids.items(): - logger.exp('Expyfun: Stamp trial ID to {0} : {1}' - ''.format(key.ljust(ll), str(id_))) + logger.exp(f"Expyfun: Stamp trial ID to {key.ljust(ll)} : {str(id_)}" "") if isinstance(id_, dict): self._id_call_dict[key](**id_) else: self._id_call_dict[key](id_) - self._trial_progress = 'identified' + self._trial_progress = "identified" def trial_ok(self): """Report that the trial was okay and do post-trial tasks. @@ -2053,19 +2254,21 @@ def trial_ok(self): ExperimentController.start_stimulus ExperimentController.stop """ - if self._trial_progress != 'started': - raise RuntimeError('trial cannot be okay unless it was started, ' - 'did you call ec.start_stimulus?') + if self._trial_progress != "started": + raise RuntimeError( + "trial cannot be okay unless it was started, " + "did you call ec.start_stimulus?" + ) if self._playing: - logger.warning('ec.trial_ok called before stimulus had stopped') + logger.warning("ec.trial_ok called before stimulus had stopped") for func in self._on_trial_ok: func() - logger.exp('Expyfun: Trial OK') - self._trial_progress = 'stopped' + logger.exp("Expyfun: Trial OK") + self._trial_progress = "stopped" def _stamp_ec_id(self, id_): """Stamp id -- currently anything allowed""" - self.write_data_line('trial_id', id_) + self.write_data_line("trial_id", id_) def _stamp_binary_id(self, id_, wait_for_last=True): """Helper for ec to stamp a set of IDs using binary controller @@ -2075,14 +2278,14 @@ def _stamp_binary_id(self, id_, wait_for_last=True): but for now it's unified. ``delay`` is the inter-trigger delay. """ if not isinstance(id_, (list, tuple, np.ndarray)): - raise TypeError('id must be array-like') + raise TypeError("id must be array-like") id_ = np.array(id_) - if not np.all(np.in1d(id_, [0, 1])): - raise ValueError('All values of id must be 0 or 1') + if not np.all(np.isin(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, True) - def stamp_triggers(self, ids, check='binary', wait_for_last=True): + def stamp_triggers(self, ids, check="binary", wait_for_last=True): """Stamp binary values Parameters @@ -2106,22 +2309,24 @@ def stamp_triggers(self, ids, check='binary', wait_for_last=True): -------- ExperimentController.identify_trial """ - if check not in ('int4', 'binary'): + if check not in ("int4", "binary"): raise ValueError('Check must be either "int4" or "binary"') ids = [ids] if not isinstance(ids, list) else ids if not all(isinstance(id_, int) and 1 <= id_ <= 15 for id_ in ids): - raise ValueError('ids must all be integers between 1 and 15') - if check == 'binary': + raise ValueError("ids must all be integers between 1 and 15") + if check == "binary": _vals = [1, 2, 4, 8] 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)) + raise ValueError( + 'with check="binary", ids must all be ' f"1, 2, 4, or 8: {ids}" + ) self._stamp_ttl_triggers(ids, wait_for_last, False) def _stamp_ttl_triggers(self, ids, wait_for_last, is_trial_id): - logger.exp('Stamping TTL triggers: %s', ids) + logger.exp("Stamping TTL triggers: %s", ids) self._tc.stamp_triggers( - ids, wait_for_last=wait_for_last, is_trial_id=is_trial_id) + ids, wait_for_last=wait_for_last, is_trial_id=is_trial_id + ) self.flush() def flush(self): @@ -2131,24 +2336,22 @@ def flush(self): self._data_file.flush() def close(self): - """Close all connections in experiment controller. - """ + """Close all connections in experiment controller.""" self.__exit__(None, None, None) def __enter__(self): - logger.debug('Expyfun: Entering') + logger.debug("Expyfun: Entering") return self def __exit__(self, err_type, value, traceback): - """ - Notes - ----- + """Exit cleanly. + err_type, value and traceback will be None when called by self.close() """ - logger.info('Expyfun: Exiting') + logger.info("Expyfun: Exiting") # do external cleanups cleanup_actions = [] - if hasattr(self, '_win'): + if hasattr(self, "_win"): cleanup_actions.append(self._win.close) cleanup_actions.extend([self.stop_noise, self.stop]) cleanup_actions.extend(self._extra_cleanup_fun) @@ -2177,8 +2380,9 @@ def refocus(self): This function currently does nothing on Linux and OSX. """ # noqa: E501 - if sys.platform == 'win32': + if sys.platform == "win32": from pyglet.libs.win32 import _user32 + m_hWnd = self._win._hwnd hCurWnd = _user32.GetForegroundWindow() if hCurWnd != m_hWnd: @@ -2192,52 +2396,45 @@ def refocus(self): _user32.SetFocus(m_hWnd) _user32.SetActiveWindow(m_hWnd) -# ############################## READ-ONLY PROPERTIES ######################### + # ############################## READ-ONLY PROPERTIES ######################### @property def id_types(self): - """Trial ID types needed for each trial. - """ + """Trial ID types needed for each trial.""" return sorted(self._id_call_dict.keys()) @property def fs(self): - """Playback frequency of the audio controller (samples / second). - """ + """Playback frequency of the audio controller (samples / second).""" return self._ac.fs # not user-settable @property def stim_fs(self): - """Sampling rate at which the stimuli were generated. - """ + """Sampling rate at which the stimuli were generated.""" return self._stim_fs # not user-settable @property def stim_db(self): - """Sound power in dB of the stimuli. - """ + """Sound power in dB of the stimuli.""" return self._stim_db # not user-settable @property def noise_db(self): - """Sound power in dB of the background noise. - """ + """Sound power in dB of the background noise.""" return self._noise_db # not user-settable @property def current_time(self): - """Timestamp from the experiment master clock. - """ + """Timestamp from the experiment master clock.""" return self._master_clock() @property def _fs_mismatch(self): - """Quantify if sample rates substantively differ. - """ + """Quantify if sample rates substantively differ.""" return not np.allclose(self.stim_fs, self.fs, rtol=0, atol=0.5) # Testing cruft to work around "queue full" errors on Windows def _ac_flush(self): - if isinstance(getattr(self, '_ac', None), SoundCardController): + if isinstance(getattr(self, "_ac", None), SoundCardController): self._ac.halt() @@ -2267,11 +2464,11 @@ def get_keyboard_input(prompt, default=None, out_type=str, valid=None): # pass a lambda, e.g., that made sure a float was in a given range # TODO: add tests if not isinstance(out_type, type): - raise TypeError('out_type must be a type') + raise TypeError("out_type must be a type") good = False while not good: response = input(prompt) - if response == '' and default is not None: + if response == "" and default is not None: response = default try: response = out_type(response) @@ -2285,27 +2482,28 @@ def get_keyboard_input(prompt, default=None, out_type=str, valid=None): def _get_dev_db(audio_controller): - """Selects device-specific amplitude to ensure equivalence across devices. - """ + """Selects device-specific amplitude to ensure equivalence across devices.""" # First try to get the level from the expyfun.json file. - level = get_config('DB_OF_SINE_AT_1KHZ_1RMS') + level = get_config("DB_OF_SINE_AT_1KHZ_1RMS") if level is None: level = dict( - RM1=108., # approx w/ knob @ 12 o'clock (knob not detented) - RP2=108., - RP2legacy=108., - RZ6=114., + RM1=108.0, # approx w/ knob @ 12 o'clock (knob not detented) + RP2=108.0, + RP2legacy=108.0, + RZ6=114.0, # TODO: these values not calibrated, system-dependent - pyglet=100., - rtmixer=100., - dummy=100., # only used for testing + pyglet=100.0, + rtmixer=100.0, + dummy=100.0, # only used for testing ).get(audio_controller, None) else: level = float(level) if level is None: - logger.warning('Expyfun: Unknown audio controller %s: stim scaler may ' - 'not work correctly. You may want to remove your ' - 'headphones if this is the first run of your ' - 'experiment.' % (audio_controller,)) + logger.warning( + "Expyfun: Unknown audio controller %s: stim scaler may " + "not work correctly. You may want to remove your " + "headphones if this is the first run of your " + "experiment." % (audio_controller,) + ) level = 100 # for untested TDT models return level diff --git a/expyfun/_externals/__init__.py b/expyfun/_externals/__init__.py deleted file mode 100644 index f6748740..00000000 --- a/expyfun/_externals/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -*- coding: utf-8 -*- - -from .decorator import decorator # noqa -from ._h5io import read_hdf5, write_hdf5 # noqa, analysis:ignore diff --git a/expyfun/_externals/_h5io.py b/expyfun/_externals/_h5io.py deleted file mode 100644 index 0130dff3..00000000 --- a/expyfun/_externals/_h5io.py +++ /dev/null @@ -1,425 +0,0 @@ -# -*- coding: utf-8 -*- -# Authors: Eric Larson -# -# License: BSD (3-clause) - -import sys -import tempfile -from shutil import rmtree -from os import path as op - -import numpy as np -try: - from scipy import sparse -except ImportError: - sparse = None - -# Adapted from six -PY3 = sys.version_info[0] == 3 -text_type = str if PY3 else unicode # noqa -string_types = str if PY3 else basestring # noqa - -special_chars = {'{FWDSLASH}': '/'} - - -############################################################################## -# WRITING - -def _check_h5py(): - """Helper to check if h5py is installed""" - try: - import h5py - except ImportError: - raise ImportError('the h5py module is required to use HDF5 I/O') - return h5py - - -def _create_titled_group(root, key, title): - """Helper to create a titled group in h5py""" - out = root.create_group(key) - out.attrs['TITLE'] = title - return out - - -def _create_titled_dataset(root, key, title, data, comp_kw=None): - """Helper to create a titled dataset in h5py""" - comp_kw = {} if comp_kw is None else comp_kw - out = root.create_dataset(key, data=data, **comp_kw) - out.attrs['TITLE'] = title - return out - - -def _create_pandas_dataset(fname, root, key, title, data): - h5py = _check_h5py() - rootpath = '/'.join([root, key]) - data.to_hdf(fname, rootpath) - with h5py.File(fname, mode='a') as fid: - fid[rootpath].attrs['TITLE'] = 'pd_dataframe' - - -def write_hdf5(fname, data, overwrite=False, compression=4, - title='h5io', slash='error'): - """Write python object to HDF5 format using h5py - - Parameters - ---------- - fname : str - Filename to use. - data : object - Object to write. Can be of any of these types: - {ndarray, dict, list, tuple, int, float, str} - Note that dict objects must only have ``str`` keys. It is recommended - to use ndarrays where possible, as it is handled most efficiently. - overwrite : True | False | 'update' - If True, overwrite file (if it exists). If 'update', appends the title - to the file (or replace value if title exists). - compression : int - Compression level to use (0-9) to compress data using gzip. - title : str - The top-level directory name to use. Typically it is useful to make - this your package name, e.g. ``'mnepython'``. - slash : 'error' | 'replace' - Whether to replace forward-slashes ('/') in any key found nested within - keys in data. This does not apply to the top level name (title). - If 'error', '/' is not allowed in any lower-level keys. - """ - h5py = _check_h5py() - mode = 'w' - if op.isfile(fname): - if isinstance(overwrite, string_types): - if overwrite != 'update': - raise ValueError('overwrite must be "update" or a bool') - mode = 'a' - elif not overwrite: - raise IOError('file "%s" exists, use overwrite=True to overwrite' - % fname) - if not isinstance(title, string_types): - raise ValueError('title must be a string') - comp_kw = dict() - if compression > 0: - comp_kw = dict(compression='gzip', compression_opts=compression) - with h5py.File(fname, mode=mode) as fid: - if title in fid: - del fid[title] - cleanup_data = [] - _triage_write(title, data, fid, comp_kw, str(type(data)), - cleanup_data=cleanup_data, slash=slash, title=title) - - # Will not be empty if any extra data to be written - for data in cleanup_data: - # In case different extra I/O needs different inputs - title = list(data.keys())[0] - if title in ['pd_dataframe', 'pd_series']: - rootname, key, value = data[title] - _create_pandas_dataset(fname, rootname, key, title, value) - - -def _triage_write(key, value, root, comp_kw, where, - cleanup_data=[], slash='error', title=None): - if key != title and '/' in key: - if slash == 'error': - raise ValueError('Found a key with "/", ' - 'this is not allowed if slash == error') - elif slash == 'replace': - # Auto-replace keys with proper values - for key_spec, val_spec in special_chars.items(): - key = key.replace(val_spec, key_spec) - else: - raise ValueError("slash must be one of ['error', 'replace'") - - if isinstance(value, dict): - sub_root = _create_titled_group(root, key, 'dict') - for key, sub_value in value.items(): - if not isinstance(key, string_types): - raise TypeError('All dict keys must be strings') - _triage_write( - 'key_{0}'.format(key), sub_value, sub_root, comp_kw, - where + '["%s"]' % key, cleanup_data=cleanup_data, slash=slash) - elif isinstance(value, (list, tuple)): - title = 'list' if isinstance(value, list) else 'tuple' - sub_root = _create_titled_group(root, key, title) - for vi, sub_value in enumerate(value): - _triage_write( - 'idx_{0}'.format(vi), sub_value, sub_root, comp_kw, - where + '[%s]' % vi, cleanup_data=cleanup_data, slash=slash) - elif isinstance(value, type(None)): - _create_titled_dataset(root, key, 'None', [False]) - elif isinstance(value, (int, float)): - if isinstance(value, int): - title = 'int' - else: # isinstance(value, float): - title = 'float' - _create_titled_dataset(root, key, title, np.atleast_1d(value)) - elif isinstance(value, np.bool_): - _create_titled_dataset(root, key, 'np_bool_', np.atleast_1d(value)) - elif isinstance(value, string_types): - if isinstance(value, text_type): # unicode - value = np.fromstring(value.encode('utf-8'), np.uint8) - title = 'unicode' - else: - value = np.fromstring(value.encode('ASCII'), np.uint8) - title = 'ascii' - _create_titled_dataset(root, key, title, value, comp_kw) - elif isinstance(value, np.ndarray): - _create_titled_dataset(root, key, 'ndarray', value) - elif sparse is not None and isinstance(value, sparse.csc_matrix): - sub_root = _create_titled_group(root, key, 'csc_matrix') - _triage_write('data', value.data, sub_root, comp_kw, - where + '.csc_matrix_data', cleanup_data=cleanup_data, - slash=slash) - _triage_write('indices', value.indices, sub_root, comp_kw, - where + '.csc_matrix_indices', cleanup_data=cleanup_data, - slash=slash) - _triage_write('indptr', value.indptr, sub_root, comp_kw, - where + '.csc_matrix_indptr', cleanup_data=cleanup_data, - slash=slash) - elif sparse is not None and isinstance(value, sparse.csr_matrix): - sub_root = _create_titled_group(root, key, 'csr_matrix') - _triage_write('data', value.data, sub_root, comp_kw, - where + '.csr_matrix_data', cleanup_data=cleanup_data, - slash=slash) - _triage_write('indices', value.indices, sub_root, comp_kw, - where + '.csr_matrix_indices', cleanup_data=cleanup_data, - slash=slash) - _triage_write('indptr', value.indptr, sub_root, comp_kw, - where + '.csr_matrix_indptr', cleanup_data=cleanup_data, - slash=slash) - _triage_write('shape', value.shape, sub_root, comp_kw, - where + '.csr_matrix_shape', cleanup_data=cleanup_data, - slash=slash) - else: - try: - from pandas import DataFrame, Series - except ImportError: - pass - else: - if isinstance(value, (DataFrame, Series)): - if isinstance(value, DataFrame): - title = 'pd_dataframe' - else: - title = 'pd_series' - rootname = root.name - cleanup_data.append({title: (rootname, key, value)}) - return - - err_str = 'unsupported type %s (in %s)' % (type(value), where) - raise TypeError(err_str) - -############################################################################## -# READING - - -def read_hdf5(fname, title='h5io', slash='ignore'): - """Read python object from HDF5 format using h5py - - Parameters - ---------- - fname : str - File to load. - title : str - The top-level directory name to use. Typically it is useful to make - this your package name, e.g. ``'mnepython'``. - slash : 'ignore' | 'replace' - Whether to replace the string {FWDSLASH} with the value /. This does - not apply to the top level name (title). If 'ignore', nothing will be - replaced. - - Returns - ------- - data : object - The loaded data. Can be of any type supported by ``write_hdf5``. - """ - h5py = _check_h5py() - if not op.isfile(fname): - raise IOError('file "%s" not found' % fname) - if not isinstance(title, string_types): - raise ValueError('title must be a string') - with h5py.File(fname, mode='r') as fid: - if title not in fid: - raise ValueError('no "%s" data found' % title) - if isinstance(fid[title], h5py.Group): - if 'TITLE' not in fid[title].attrs: - raise ValueError('no "%s" data found' % title) - data = _triage_read(fid[title], slash=slash) - return data - - -def _triage_read(node, slash='ignore'): - if slash not in ['ignore', 'replace']: - raise ValueError("slash must be one of 'replace', 'ignore'") - h5py = _check_h5py() - type_str = node.attrs['TITLE'] - if isinstance(type_str, bytes): - type_str = type_str.decode() - if isinstance(node, h5py.Group): - if type_str == 'dict': - data = dict() - for key, subnode in node.items(): - if slash == 'replace': - for key_spec, val_spec in special_chars.items(): - key = key.replace(key_spec, val_spec) - data[key[4:]] = _triage_read(subnode, slash=slash) - elif type_str in ['list', 'tuple']: - data = list() - ii = 0 - while True: - subnode = node.get('idx_{0}'.format(ii), None) - if subnode is None: - break - data.append(_triage_read(subnode, slash=slash)) - ii += 1 - assert len(data) == ii - data = tuple(data) if type_str == 'tuple' else data - return data - elif type_str == 'csc_matrix': - if sparse is None: - raise RuntimeError('scipy must be installed to read this data') - data = sparse.csc_matrix((_triage_read(node['data'], slash=slash), - _triage_read(node['indices'], - slash=slash), - _triage_read(node['indptr'], - slash=slash))) - elif type_str == 'csr_matrix': - if sparse is None: - raise RuntimeError('scipy must be installed to read this data') - data = sparse.csr_matrix((_triage_read(node['data'], slash=slash), - _triage_read(node['indices'], - slash=slash), - _triage_read(node['indptr'], - slash=slash)), - shape=_triage_read(node['shape'])) - elif type_str in ['pd_dataframe', 'pd_series']: - from pandas import read_hdf - rootname = node.name - filename = node.file.filename - data = read_hdf(filename, rootname, mode='r') - else: - raise NotImplementedError('Unknown group type: {0}' - ''.format(type_str)) - elif type_str == 'ndarray': - data = np.array(node) - elif type_str in ('int', 'float'): - cast = int if type_str == 'int' else float - data = cast(np.array(node)[0]) - elif type_str == 'np_bool_': - data = np.bool_(np.array(node)[0]) - elif type_str in ('unicode', 'ascii', 'str'): # 'str' for backward compat - decoder = 'utf-8' if type_str == 'unicode' else 'ASCII' - cast = text_type if type_str == 'unicode' else str - data = cast(np.array(node).tostring().decode(decoder)) - elif type_str == 'None': - data = None - else: - raise TypeError('Unknown node type: {0}'.format(type_str)) - return data - - -# ############################################################################ -# UTILITIES - -def _sort_keys(x): - """Sort and return keys of dict""" - keys = list(x.keys()) # note: not thread-safe - idx = np.argsort([str(k) for k in keys]) - keys = [keys[ii] for ii in idx] - return keys - - -def object_diff(a, b, pre=''): - """Compute all differences between two python variables - - Parameters - ---------- - a : object - Currently supported: dict, list, tuple, ndarray, int, str, bytes, - float. - b : object - Must be same type as x1. - pre : str - String to prepend to each line. - - Returns - ------- - diffs : str - A string representation of the differences. - """ - - try: - from pandas import DataFrame, Series - except ImportError: - DataFrame = Series = type(None) - - out = '' - if type(a) != type(b): - out += pre + ' type mismatch (%s, %s)\n' % (type(a), type(b)) - elif isinstance(a, dict): - k1s = _sort_keys(a) - k2s = _sort_keys(b) - m1 = set(k2s) - set(k1s) - if len(m1): - out += pre + ' x1 missing keys %s\n' % (m1) - for key in k1s: - if key not in k2s: - out += pre + ' x2 missing key %s\n' % key - else: - out += object_diff(a[key], b[key], pre + 'd1[%s]' % repr(key)) - elif isinstance(a, (list, tuple)): - if len(a) != len(b): - out += pre + ' length mismatch (%s, %s)\n' % (len(a), len(b)) - else: - for xx1, xx2 in zip(a, b): - out += object_diff(xx1, xx2, pre='') - elif isinstance(a, (string_types, int, float, bytes)): - if a != b: - out += pre + ' value mismatch (%s, %s)\n' % (a, b) - elif a is None: - pass # b must be None due to our type checking - elif isinstance(a, np.ndarray): - if not np.array_equal(a, b): - out += pre + ' array mismatch\n' - elif sparse is not None and sparse.isspmatrix(a): - # sparsity and sparse type of b vs a already checked above by type() - if b.shape != a.shape: - out += pre + (' sparse matrix a and b shape mismatch' - '(%s vs %s)' % (a.shape, b.shape)) - else: - c = a - b - c.eliminate_zeros() - if c.nnz > 0: - out += pre + (' sparse matrix a and b differ on %s ' - 'elements' % c.nnz) - elif isinstance(a, (DataFrame, Series)): - if b.shape != a.shape: - out += pre + (' pandas values a and b shape mismatch' - '(%s vs %s)' % (a.shape, b.shape)) - else: - c = a.values - b.values - nzeros = np.sum(c != 0) - if nzeros > 0: - out += pre + (' pandas values a and b differ on %s ' - 'elements' % nzeros) - else: - raise RuntimeError(pre + ': unsupported type %s (%s)' % (type(a), a)) - return out - - -class _TempDir(str): - """Class for creating and auto-destroying temp dir - - This is designed to be used with testing modules. Instances should be - defined inside test functions. Instances defined at module level can not - guarantee proper destruction of the temporary directory. - - When used at module level, the current use of the __del__() method for - cleanup can fail because the rmtree function may be cleaned up before this - object (an alternative could be using the atexit module instead). - """ - def __new__(self): - new = str.__new__(self, tempfile.mkdtemp()) - return new - - def __init__(self): - self._path = self.__str__() - - def __del__(self): - rmtree(self._path, ignore_errors=True) diff --git a/expyfun/_externals/decorator.py b/expyfun/_externals/decorator.py deleted file mode 100644 index 0836d2b3..00000000 --- a/expyfun/_externals/decorator.py +++ /dev/null @@ -1,254 +0,0 @@ -# -*- coding: utf-8 -*- -########################## LICENCE ############################### - -# Copyright (c) 2005-2012, Michele Simionato -# All rights reserved. - -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are -# met: - -# Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# Redistributions in bytecode form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in -# the documentation and/or other materials provided with the -# distribution. - -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -# HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, -# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, -# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS -# OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR -# TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE -# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH -# DAMAGE. - -""" -Decorator module, see http://pypi.python.org/pypi/decorator -for the documentation. -""" -from __future__ import print_function - -__version__ = '3.4.0' - -__all__ = ["decorator", "FunctionMaker", "contextmanager"] - - -import sys, re, inspect -if sys.version >= '3': - from inspect import getfullargspec - def get_init(cls): - return cls.__init__ -else: - class getfullargspec(object): - "A quick and dirty replacement for getfullargspec for Python 2.X" - def __init__(self, f): - self.args, self.varargs, self.varkw, self.defaults = \ - inspect.getargspec(f) - self.kwonlyargs = [] - self.kwonlydefaults = None - def __iter__(self): - yield self.args - yield self.varargs - yield self.varkw - yield self.defaults - def get_init(cls): - return cls.__init__.__func__ - -DEF = re.compile(r'\s*def\s*([_\w][_\w\d]*)\s*\(') - -# basic functionality -class FunctionMaker(object): - """ - An object with the ability to create functions with a given signature. - It has attributes name, doc, module, signature, defaults, dict and - methods update and make. - """ - def __init__(self, func=None, name=None, signature=None, - defaults=None, doc=None, module=None, funcdict=None): - self.shortsignature = signature - if func: - # func can be a class or a callable, but not an instance method - self.name = func.__name__ - if self.name == '': # small hack for lambda functions - self.name = '_lambda_' - self.doc = func.__doc__ - self.module = func.__module__ - if inspect.isfunction(func): - argspec = getfullargspec(func) - self.annotations = getattr(func, '__annotations__', {}) - for a in ('args', 'varargs', 'varkw', 'defaults', 'kwonlyargs', - 'kwonlydefaults'): - setattr(self, a, getattr(argspec, a)) - for i, arg in enumerate(self.args): - setattr(self, 'arg%d' % i, arg) - if sys.version < '3': # easy way - self.shortsignature = self.signature = \ - inspect.formatargspec( - formatvalue=lambda val: "", *argspec)[1:-1] - else: # Python 3 way - allargs = list(self.args) - allshortargs = list(self.args) - if self.varargs: - allargs.append('*' + self.varargs) - allshortargs.append('*' + self.varargs) - elif self.kwonlyargs: - allargs.append('*') # single star syntax - for a in self.kwonlyargs: - allargs.append('%s=None' % a) - allshortargs.append('%s=%s' % (a, a)) - if self.varkw: - allargs.append('**' + self.varkw) - allshortargs.append('**' + self.varkw) - self.signature = ', '.join(allargs) - self.shortsignature = ', '.join(allshortargs) - self.dict = func.__dict__.copy() - # func=None happens when decorating a caller - if name: - self.name = name - if signature is not None: - self.signature = signature - if defaults: - self.defaults = defaults - if doc: - self.doc = doc - if module: - self.module = module - if funcdict: - self.dict = funcdict - # check existence required attributes - assert hasattr(self, 'name') - if not hasattr(self, 'signature'): - raise TypeError('You are decorating a non function: %s' % func) - - def update(self, func, **kw): - "Update the signature of func with the data in self" - func.__name__ = self.name - func.__doc__ = getattr(self, 'doc', None) - func.__dict__ = getattr(self, 'dict', {}) - func.__defaults__ = getattr(self, 'defaults', ()) - func.__kwdefaults__ = getattr(self, 'kwonlydefaults', None) - func.__annotations__ = getattr(self, 'annotations', None) - callermodule = sys._getframe(3).f_globals.get('__name__', '?') - func.__module__ = getattr(self, 'module', callermodule) - func.__dict__.update(kw) - - def make(self, src_templ, evaldict=None, addsource=False, **attrs): - "Make a new function from a given template and update the signature" - src = src_templ % vars(self) # expand name and signature - evaldict = evaldict or {} - mo = DEF.match(src) - if mo is None: - raise SyntaxError('not a valid function template\n%s' % src) - name = mo.group(1) # extract the function name - names = set([name] + [arg.strip(' *') for arg in - self.shortsignature.split(',')]) - for n in names: - if n in ('_func_', '_call_'): - raise NameError('%s is overridden in\n%s' % (n, src)) - if not src.endswith('\n'): # add a newline just for safety - src += '\n' # this is needed in old versions of Python - try: - code = compile(src, '', 'single') - # print >> sys.stderr, 'Compiling %s' % src - exec(code, evaldict) - except: - print('Error in generated code:', file=sys.stderr) - print(src, file=sys.stderr) - raise - func = evaldict[name] - if addsource: - attrs['__source__'] = src - self.update(func, **attrs) - return func - - @classmethod - def create(cls, obj, body, evaldict, defaults=None, - doc=None, module=None, addsource=True, **attrs): - """ - Create a function from the strings name, signature and body. - evaldict is the evaluation dictionary. If addsource is true an attribute - __source__ is added to the result. The attributes attrs are added, - if any. - """ - if isinstance(obj, str): # "name(signature)" - name, rest = obj.strip().split('(', 1) - signature = rest[:-1] #strip a right parens - func = None - else: # a function - name = None - signature = None - func = obj - self = cls(func, name, signature, defaults, doc, module) - ibody = '\n'.join(' ' + line for line in body.splitlines()) - return self.make('def %(name)s(%(signature)s):\n' + ibody, - evaldict, addsource, **attrs) - -def decorator(caller, func=None): - """ - decorator(caller) converts a caller function into a decorator; - decorator(caller, func) decorates a function using a caller. - """ - if func is not None: # returns a decorated function - evaldict = func.__globals__.copy() - evaldict['_call_'] = caller - evaldict['_func_'] = func - return FunctionMaker.create( - func, "return _call_(_func_, %(shortsignature)s)", - evaldict, undecorated=func, __wrapped__=func) - else: # returns a decorator - if inspect.isclass(caller): - name = caller.__name__.lower() - callerfunc = get_init(caller) - doc = 'decorator(%s) converts functions/generators into ' \ - 'factories of %s objects' % (caller.__name__, caller.__name__) - fun = getfullargspec(callerfunc).args[1] # second arg - elif inspect.isfunction(caller): - name = '_lambda_' if caller.__name__ == '' \ - else caller.__name__ - callerfunc = caller - doc = caller.__doc__ - fun = getfullargspec(callerfunc).args[0] # first arg - else: # assume caller is an object with a __call__ method - name = caller.__class__.__name__.lower() - callerfunc = caller.__call__.__func__ - doc = caller.__call__.__doc__ - fun = getfullargspec(callerfunc).args[1] # second arg - evaldict = callerfunc.__globals__.copy() - evaldict['_call_'] = caller - evaldict['decorator'] = decorator - return FunctionMaker.create( - '%s(%s)' % (name, fun), - 'return decorator(_call_, %s)' % fun, - evaldict, undecorated=caller, __wrapped__=caller, - doc=doc, module=caller.__module__) - -######################### contextmanager ######################## - -def __call__(self, func): - 'Context manager decorator' - return FunctionMaker.create( - func, "with _self_: return _func_(%(shortsignature)s)", - dict(_self_=self, _func_=func), __wrapped__=func) - -try: # Python >= 3.2 - - from contextlib import _GeneratorContextManager - ContextManager = type( - 'ContextManager', (_GeneratorContextManager,), dict(__call__=__call__)) - -except ImportError: # Python >= 2.5 - - from contextlib import GeneratorContextManager - def __init__(self, f, *a, **k): - return GeneratorContextManager.__init__(self, f(*a, **k)) - ContextManager = type( - 'ContextManager', (GeneratorContextManager,), - dict(__call__=__call__, __init__=__init__)) - -contextmanager = decorator(ContextManager) diff --git a/expyfun/_eyelink_controller.py b/expyfun/_eyelink_controller.py index eb257888..cfd88dcc 100644 --- a/expyfun/_eyelink_controller.py +++ b/expyfun/_eyelink_controller.py @@ -5,16 +5,17 @@ # # License: BSD (3-clause) -import numpy as np import datetime import os -from os import path as op -import sys import subprocess +import sys import time +from os import path as op -from .visual import FixationDot, Circle, RawImage, Line, Text -from ._utils import get_config, verbose_dec, logger, string_types +import numpy as np + +from ._utils import get_config, logger, verbose_dec +from .visual import Circle, FixationDot, Line, RawImage, Text # Constants TRIAL_OK = 0 @@ -35,6 +36,7 @@ def dummy_fun(*args, **kwargs): # don't prevent basic functionality for folks who don't use EL try: import pylink + cal_super_class = pylink.EyeLinkCustomDisplay openGraphicsEx = pylink.openGraphicsEx except ImportError: @@ -43,87 +45,105 @@ def dummy_fun(*args, **kwargs): openGraphicsEx = dummy_fun -eye_list = ['LEFT_EYE', 'RIGHT_EYE', 'BINOCULAR'] # Used by eyeAvailable +eye_list = ["LEFT_EYE", "RIGHT_EYE", "BINOCULAR"] # Used by eyeAvailable def _get_key_trans_dict(): """Helper to translate pyglet keys to pylink codes""" from pyglet.window import key - key_trans_dict = {str(key.F1): pylink.F1_KEY, - str(key.F2): pylink.F2_KEY, - str(key.F3): pylink.F3_KEY, - str(key.F4): pylink.F4_KEY, - str(key.F5): pylink.F5_KEY, - str(key.F6): pylink.F6_KEY, - str(key.F7): pylink.F7_KEY, - str(key.F8): pylink.F8_KEY, - str(key.F9): pylink.F9_KEY, - str(key.F10): pylink.F10_KEY, - str(key.PAGEUP): pylink.PAGE_UP, - str(key.PAGEDOWN): pylink.PAGE_DOWN, - str(key.UP): pylink.CURS_UP, - str(key.DOWN): pylink.CURS_DOWN, - str(key.LEFT): pylink.CURS_LEFT, - str(key.RIGHT): pylink.CURS_RIGHT, - str(key.BACKSPACE): '\b', - str(key.RETURN): pylink.ENTER_KEY, - str(key.ESCAPE): pylink.ESC_KEY, - str(key.NUM_ADD): key.PLUS, - str(key.NUM_SUBTRACT): key.MINUS, - } + + key_trans_dict = { + str(key.F1): pylink.F1_KEY, + str(key.F2): pylink.F2_KEY, + str(key.F3): pylink.F3_KEY, + str(key.F4): pylink.F4_KEY, + str(key.F5): pylink.F5_KEY, + str(key.F6): pylink.F6_KEY, + str(key.F7): pylink.F7_KEY, + str(key.F8): pylink.F8_KEY, + str(key.F9): pylink.F9_KEY, + str(key.F10): pylink.F10_KEY, + str(key.PAGEUP): pylink.PAGE_UP, + str(key.PAGEDOWN): pylink.PAGE_DOWN, + str(key.UP): pylink.CURS_UP, + str(key.DOWN): pylink.CURS_DOWN, + str(key.LEFT): pylink.CURS_LEFT, + str(key.RIGHT): pylink.CURS_RIGHT, + str(key.BACKSPACE): "\b", + str(key.RETURN): pylink.ENTER_KEY, + str(key.ESCAPE): pylink.ESC_KEY, + str(key.NUM_ADD): key.PLUS, + str(key.NUM_SUBTRACT): key.MINUS, + } return key_trans_dict def _get_color_dict(): """Helper to translate pylink colors to pyglet""" - color_dict = {str(CR_HAIR_COLOR): (1.0, 1.0, 1.0), - str(PUPIL_HAIR_COLOR): (1.0, 1.0, 1.0), - str(PUPIL_BOX_COLOR): (0.0, 1.0, 0.0), - str(SEARCH_LIMIT_BOX_COLOR): (1.0, 0.0, 0.0), - str(MOUSE_CURSOR_COLOR): (1.0, 0.0, 0.0)} + color_dict = { + str(CR_HAIR_COLOR): (1.0, 1.0, 1.0), + str(PUPIL_HAIR_COLOR): (1.0, 1.0, 1.0), + str(PUPIL_BOX_COLOR): (0.0, 1.0, 0.0), + str(SEARCH_LIMIT_BOX_COLOR): (1.0, 0.0, 0.0), + str(MOUSE_CURSOR_COLOR): (1.0, 0.0, 0.0), + } return color_dict -def _check(val, msg, out='error'): +def _check(val, msg, out="error"): """Helper to check output""" if val != TRIAL_OK: msg = msg.format(val) - if out == 'warn': + if out == "warn": logger.warning(msg) else: raise RuntimeError(msg) _dummy_names = [ - 'setSaccadeVelocityThreshold', 'setAccelerationThreshold', - 'setUpdateInterval', 'setFixationUpdateAccumulate', 'setFileEventFilter', - 'setLinkEventFilter', 'setFileSampleFilter', 'setLinkSampleFilter', - 'setPupilSizeDiameter', 'setAcceptTargetFixationButton', - 'openDataFile', 'startRecording', 'waitForModeReady', - 'isRecording', 'stopRecording', 'closeDataFile', 'doTrackerSetup', - 'receiveDataFile', 'close', 'eyeAvailable', 'sendCommand', + "setSaccadeVelocityThreshold", + "setAccelerationThreshold", + "setUpdateInterval", + "setFixationUpdateAccumulate", + "setFileEventFilter", + "setLinkEventFilter", + "setFileSampleFilter", + "setLinkSampleFilter", + "setPupilSizeDiameter", + "setAcceptTargetFixationButton", + "openDataFile", + "startRecording", + "waitForModeReady", + "isRecording", + "stopRecording", + "closeDataFile", + "doTrackerSetup", + "receiveDataFile", + "close", + "eyeAvailable", + "sendCommand", ] -class DummyEl(object): +class DummyEl: """Dummy EyeLink controller.""" def __init__(self): for name in _dummy_names: setattr(self, name, dummy_fun) - self.getTrackerVersion = lambda: 'Dummy' + self.getTrackerVersion = lambda: "Dummy" self.getDummyMode = lambda: True self.getCurrentMode = lambda: IN_RECORD_MODE self.waitForBlockStart = lambda a, b, c: 1 def sendMessage(self, msg): """Send a message.""" - if not isinstance(msg, string_types): - raise TypeError('msg must be str') + if not isinstance(msg, str): + raise TypeError("msg must be str") return TRIAL_OK -class EyelinkController(object): +class EyelinkController: """Eyelink communication and control methods. Parameters @@ -147,50 +167,53 @@ class EyelinkController(object): """ @verbose_dec - def __init__(self, ec, link='default', fs=1000, verbose=None): - if link == 'default': - link = get_config('EXPYFUN_EYELINK', None) + def __init__(self, ec, link="default", fs=1000, verbose=None): + if link == "default": + link = get_config("EXPYFUN_EYELINK", None) if link is not None and pylink is None: - raise ImportError('Could not import pylink, please ensure it ' - 'is installed correctly to use the EyeLink') + raise ImportError( + "Could not import pylink, please ensure it " + "is installed correctly to use the EyeLink" + ) valid_fs = (250, 500, 1000, 2000) if fs not in valid_fs: - raise ValueError('fs must be one of {0}'.format(list(valid_fs))) + raise ValueError(f"fs must be one of {list(valid_fs)}") output_dir = ec._output_dir if output_dir is None: output_dir = os.getcwd() - if not isinstance(output_dir, string_types): - raise TypeError('output_dir must be a string') + if not isinstance(output_dir, str): + raise TypeError("output_dir must be a string") if not op.isdir(output_dir): os.mkdir(output_dir) self._output_dir = output_dir self._ec = ec - if 'el_id' in self._ec._id_call_dict: - raise RuntimeError('Cannot use initialize EL twice') - logger.info('EyeLink: Initializing on {}'.format(link)) + if "el_id" in self._ec._id_call_dict: + raise RuntimeError("Cannot use initialize EL twice") + logger.info(f"EyeLink: Initializing on {link}") ec.flush() if link is not None: - iswin = (sys.platform == 'win32') - cmd = 'ping -n 1 -w 100' if iswin else 'fping -c 1 -t100' - cmd = subprocess.Popen('%s %s' % (cmd, link), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) + iswin = sys.platform == "win32" + cmd = "ping -n 1 -w 100" if iswin else "fping -c 1 -t100" + cmd = subprocess.Popen( + "%s %s" % (cmd, link), stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) if cmd.returncode: - raise RuntimeError('could not connect to Eyelink @ %s, ' - 'is it turned on?' % link) + raise RuntimeError( + "could not connect to Eyelink @ %s, " "is it turned on?" % link + ) self._eyelink = DummyEl() if link is None else pylink.EyeLink(link) self._file_list = [] self._size = np.array(self._ec.window_size_pix) self._ec._extra_cleanup_fun += [self._close] self._ec.flush() self._setup(fs) - self._ec._id_call_dict['el_id'] = self._stamp_trial_id + self._ec._id_call_dict["el_id"] = self._stamp_trial_id self._ec._ofp_critical_funs.append(self._stamp_trial_start) self._ec._on_trial_ok.append(self._stamp_trial_ok) self._fake_calibration = False # Only used for testing self._closed = False # to prevent double-closing self._current_open_file = None - logger.debug('EyeLink: Setup complete') + logger.debug("EyeLink: Setup complete") self._ec.flush() def _setup(self, fs=1000): @@ -206,10 +229,10 @@ def _setup(self, fs=1000): """ # map the gaze positions from the tracker to screen pixel positions res = self._size - res_str = '0 0 {0} {1}'.format(res[0] - 1, res[1] - 1) - logger.debug('EyeLink: Setting display coordinates and saccade levels') - self._command('screen_pixel_coords = ' + res_str) - self._message('DISPLAY_COORDS ' + res_str) + res_str = f"0 0 {res[0] - 1} {res[1] - 1}" + logger.debug("EyeLink: Setting display coordinates and saccade levels") + self._command("screen_pixel_coords = " + res_str) + self._message("DISPLAY_COORDS " + res_str) # set calibration parameters self.custom_calibration() @@ -219,33 +242,31 @@ def _setup(self, fs=1000): self._eyelink.setAccelerationThreshold(9500) self._eyelink.setUpdateInterval(50) self._eyelink.setFixationUpdateAccumulate(50) - self._command('sample_rate = {0}'.format(fs)) + self._command(f"sample_rate = {fs}") # retrieve tracker version and tracker software version v = str(self._eyelink.getTrackerVersion()).strip() - logger.info('Eyelink: Running experiment on a version ''{0}'' ' - 'tracker.'.format(v)) - v = v.split('.') + logger.info("Eyelink: Running experiment on a version " f"{v}" " " "tracker.") + v = v.split(".") # set EDF file contents - logger.debug('EyeLink: Setting file and event filters') - fef = 'LEFT,RIGHT,FIXATION,SACCADE,BLINK,MESSAGE,BUTTON,INPUT' + logger.debug("EyeLink: Setting file and event filters") + fef = "LEFT,RIGHT,FIXATION,SACCADE,BLINK,MESSAGE,BUTTON,INPUT" self._eyelink.setFileEventFilter(fef) - lef = ('LEFT,RIGHT,FIXATION,SACCADE,BLINK,MESSAGE,' - 'BUTTON,FIXUPDATE,INPUT') + lef = "LEFT,RIGHT,FIXATION,SACCADE,BLINK,MESSAGE," "BUTTON,FIXUPDATE,INPUT" self._eyelink.setLinkEventFilter(lef) - fsf = 'LEFT,RIGHT,GAZE,HREF,AREA,GAZERES,STATUS,INPUT' - lsf = 'LEFT,RIGHT,GAZE,GAZERES,AREA,STATUS,INPUT' - if len(v) > 1 and v[0] == '3' and v[1] == '4': + fsf = "LEFT,RIGHT,GAZE,HREF,AREA,GAZERES,STATUS,INPUT" + lsf = "LEFT,RIGHT,GAZE,GAZERES,AREA,STATUS,INPUT" + if len(v) > 1 and v[0] == "3" and v[1] == "4": # remote mode possible add HTARGET ( head target) - fsf += ',HTARGET' + fsf += ",HTARGET" # set link data (used for gaze cursor) - lsf += ',HTARGET' + lsf += ",HTARGET" self._eyelink.setFileSampleFilter(fsf) self._eyelink.setLinkSampleFilter(lsf) # Ensure that we get areas - self._eyelink.setPupilSizeDiameter('NO') + self._eyelink.setPupilSizeDiameter("NO") # calibration/drift cordisp.rection target self._eyelink.setAcceptTargetFixationButton(5) @@ -266,23 +287,24 @@ def fs(self): @property def _is_file_open(self): - return (self._current_open_file is not None) + return self._current_open_file is not None def _open_file(self): """Opens a new file on the Eyelink""" if self._is_file_open: - raise RuntimeError('Cannot start new file, old must be closed') - file_name = datetime.datetime.now().strftime('%H%M%S') + raise RuntimeError("Cannot start new file, old must be closed") + file_name = datetime.datetime.now().strftime("%H%M%S") while file_name in self._file_list: # This should succeed in under 1 second - file_name = datetime.datetime.now().strftime('%H%M%S') + file_name = datetime.datetime.now().strftime("%H%M%S") # make absolutely sure we don't break this, but it shouldn't ever # be wrong assert len(file_name) <= 8 - logger.info('Eyelink: Opening remote file with filename {}' - ''.format(file_name)) - _check(self._eyelink.openDataFile(file_name), - 'Remote file "' + file_name + '" could not be opened: {0}') + logger.info(f"Eyelink: Opening remote file with filename {file_name}" "") + _check( + self._eyelink.openDataFile(file_name), + 'Remote file "' + file_name + '" could not be opened: {0}', + ) self._current_open_file = file_name self._file_list.append(file_name) return self._current_open_file @@ -290,28 +312,33 @@ def _open_file(self): def _start_recording(self): """Start Eyelink recording""" if not self._is_file_open: - raise RuntimeError('cannot start recording without file open') + raise RuntimeError("cannot start recording without file open") for ii in range(5): self._ec.wait_secs(0.1) - out = 'check' if ii < 4 else 'error' - _check(self._eyelink.startRecording(1, 1, 1, 1), - 'Recording could not be started: {0}', out) + out = "check" if ii < 4 else "error" + _check( + self._eyelink.startRecording(1, 1, 1, 1), + "Recording could not be started: {0}", + out, + ) # self._eyelink.waitForModeReady(100) # doesn't work - _check(not self._eyelink.waitForBlockStart(100, 1, 0), - 'No link samples received: {0}') + _check( + not self._eyelink.waitForBlockStart(100, 1, 0), + "No link samples received: {0}", + ) if not self.recording: - raise RuntimeError('Eyelink is not recording') + raise RuntimeError("Eyelink is not recording") # double-check mode = self._eyelink.getCurrentMode() if mode != IN_RECORD_MODE: - raise RuntimeError('Eyelink is not recording: {0}'.format(mode)) + raise RuntimeError(f"Eyelink is not recording: {mode}") self._ec.flush() self._toggle_dummy_cursor(True) @property def recording(self): """Returns boolean for whether or not the Eyelink is recording""" - return (self._eyelink.isRecording() == TRIAL_OK) + return self._eyelink.isRecording() == TRIAL_OK def stop(self): """Stop Eyelink recording and close current file @@ -322,12 +349,11 @@ def stop(self): EyelinkController.transfer_remote_file """ if not self.recording: - raise RuntimeError('Cannot stop, not currently recording') - logger.info('Eyelink: Stopping recording') + raise RuntimeError("Cannot stop, not currently recording") + logger.info("Eyelink: Stopping recording") self._eyelink.stopRecording() - logger.info('Eyelink: Closing file') - _check(self._eyelink.closeDataFile(), - 'File could not be closed: {0}', 'warn') + logger.info("Eyelink: Closing file") + _check(self._eyelink.closeDataFile(), "File could not be closed: {0}", "warn") self._current_open_file = None self._toggle_dummy_cursor(False) @@ -363,9 +389,11 @@ def calibrate(self, beep=False, prompt=True): # open file to record *before* running calibration so it gets saved! fname = self._open_file() if prompt: - self._ec.screen_prompt('We will now perform a screen calibration.' - '\n\nPress a button to continue.') - logger.info('EyeLink: Entering calibration') + self._ec.screen_prompt( + "We will now perform a screen calibration." + "\n\nPress a button to continue." + ) + logger.info("EyeLink: Entering calibration") self._ec.flush() # enter Eyetracker camera setup mode, calibration and validation self._ec.flip() @@ -377,7 +405,7 @@ def calibrate(self, beep=False, prompt=True): self._eyelink.doTrackerSetup() cal.release_event_handlers() self._ec.flip() - logger.info('EyeLink: Completed calibration') + logger.info("EyeLink: Completed calibration") self._ec.flush() self._start_recording() return fname @@ -401,13 +429,13 @@ def _stamp_trial_id(self, ids): # such as one number for each trial independent variable. # Here we just force up to 12 integers for simplicity. if not isinstance(ids, (list, tuple)): - raise TypeError('ids must be a list (or tuple)') + raise TypeError("ids must be a list (or tuple)") if not all([np.isscalar(x) for x in ids]): - raise ValueError('All ids must be numeric') + raise ValueError("All ids must be numeric") if len(ids) > 12: - raise ValueError('ids must not have more than 12 entries') - ids = ' '.join([str(int(ii)) for ii in ids]) - msg = 'TRIALID {}'.format(ids) + raise ValueError("ids must not have more than 12 entries") + ids = " ".join([str(int(ii)) for ii in ids]) + msg = f"TRIALID {ids}" self._message(msg) def _stamp_trial_start(self): @@ -416,17 +444,16 @@ def _stamp_trial_start(self): This is a timing-critical operation used to synchronize the recording to stimulus presentation. """ - self._eyelink.sendMessage('SYNCTIME') + self._eyelink.sendMessage("SYNCTIME") def _stamp_trial_ok(self): - """Signal the end of a trial - """ - self._eyelink.sendMessage('TRIAL OK') + """Signal the end of a trial""" + self._eyelink.sendMessage("TRIAL OK") def _message(self, msg): """Send message to eyelink, must be a string""" self._eyelink.sendMessage(msg) - self._command('record_status_message "{0}"'.format(msg)) + self._command(f'record_status_message "{msg}"') def _command(self, cmd): """Send Eyelink a command, must be a string""" @@ -449,11 +476,10 @@ def transfer_remote_file(self, remote_name): -------- EyelinkController.stop """ - fname = op.join(self._output_dir, '{0}.edf'.format(remote_name)) - logger.info('Eyelink: saving Eyelink file: {0} ...' - ''.format(remote_name)) + fname = op.join(self._output_dir, f"{remote_name}.edf") + logger.info(f"Eyelink: saving Eyelink file: {remote_name} ..." "") status = self._eyelink.receiveDataFile(remote_name, fname) - logger.info('Eyelink: transferred {0} bytes'.format(status)) + logger.info(f"Eyelink: transferred {status} bytes") return fname def _close(self): @@ -463,21 +489,30 @@ def _close(self): if self.recording: self.stop() # make sure files get transferred - fnames = [self.transfer_remote_file(remote_name) - for remote_name in self._file_list] + fnames = [ + self.transfer_remote_file(remote_name) + for remote_name in self._file_list + ] self._file_list = list() self._eyelink.close() self._closed = True - assert 'el_id' in self._ec._id_call_dict - del self._ec._id_call_dict['el_id'] + assert "el_id" in self._ec._id_call_dict + del self._ec._id_call_dict["el_id"] idx = self._ec._ofp_critical_funs.index(self._stamp_trial_start) self._ec._ofp_critical_funs.pop(idx) idx = self._ec._on_trial_ok.index(self._stamp_trial_ok) self._ec._on_trial_ok.pop(idx) return fnames - def wait_for_fix(self, fix_pos, fix_time=0., tol=100., max_wait=np.inf, - check_interval=0.001, units='norm'): + def wait_for_fix( + self, + fix_pos, + fix_time=0.0, + tol=100.0, + max_wait=np.inf, + check_interval=0.001, + units="norm", + ): """Wait for gaze to settle within a defined region Parameters @@ -512,11 +547,12 @@ def wait_for_fix(self, fix_pos, fix_time=0., tol=100., max_wait=np.inf, time_out = time_in + max_wait fix_pos = np.array(fix_pos) if not (fix_pos.ndim == 1 and fix_pos.size == 2): - raise ValueError('fix_pos must be a 2-element array-like vector') - fix_pos = self._ec._convert_units(fix_pos[:, np.newaxis], units, 'pix') + raise ValueError("fix_pos must be a 2-element array-like vector") + fix_pos = self._ec._convert_units(fix_pos[:, np.newaxis], units, "pix") fix_pos = fix_pos[:, 0] - while (time.time() < time_out and not - (fix_success and time.time() - time_in >= fix_time)): + while time.time() < time_out and not ( + fix_success and time.time() - time_in >= fix_time + ): # sample eye position eye_pos = self.get_eye_position() # in pixels if _within_distance(eye_pos, fix_pos, tol): @@ -529,8 +565,16 @@ def wait_for_fix(self, fix_pos, fix_time=0., tol=100., max_wait=np.inf, return fix_success - def maintain_fix(self, fix_pos, check_duration, tol=100., period=.250, - check_interval=0.001, units='norm', stop_early=False): + def maintain_fix( + self, + fix_pos, + check_duration, + tol=100.0, + period=0.250, + check_interval=0.001, + units="norm", + stop_early=False, + ): """Check to see if subject is fixating in a region. This checks to make sure that the subjects gaze falls within a region @@ -569,12 +613,15 @@ def maintain_fix(self, fix_pos, check_duration, tol=100., period=.250, fix_pos = np.array(fix_pos) if not (fix_pos.ndim == 1 and fix_pos.size == 2): - raise ValueError('fix_pos must be a 2-element array-like vector') - fix_pos = self._ec._convert_units(fix_pos[:, np.newaxis], units, 'pix') + raise ValueError("fix_pos must be a 2-element array-like vector") + fix_pos = self._ec._convert_units(fix_pos[:, np.newaxis], units, "pix") fix_pos = fix_pos[:, 0] check = [] - while ((fix_success and time.time() < time_end) if stop_early else - time.time() < time_end): + while ( + (fix_success and time.time() < time_end) + if stop_early + else time.time() < time_end + ): if fix_success: # sample eye position eye_pos = self.get_eye_position() # in pixels @@ -588,8 +635,14 @@ def maintain_fix(self, fix_pos, check_duration, tol=100., period=.250, self._ec.wait_secs(check_interval) return fix_success - def custom_calibration(self, ctype='HV5', horiz=2./3., vert=2./3., - coordinates=None, units='norm'): + def custom_calibration( + self, + ctype="HV5", + horiz=2.0 / 3.0, + vert=2.0 / 3.0, + coordinates=None, + units="norm", + ): """Set Eyetracker to use a custom calibration sequence Parameters @@ -611,54 +664,79 @@ def custom_calibration(self, ctype='HV5', horiz=2./3., vert=2./3., -------- EyelinkController.calibrate """ - allowed_types = ['H3', 'HV5', 'HV9', 'HV13', 'custom'] + allowed_types = ["H3", "HV5", "HV9", "HV13", "custom"] if ctype not in allowed_types: - raise ValueError('ctype cannot be "{0}", but must be one of {1}' - ''.format(ctype, allowed_types)) - if ctype != 'custom': + raise ValueError( + f'ctype cannot be "{ctype}", but must be one of {allowed_types}' "" + ) + if ctype != "custom": if coordinates is not None: - raise ValueError('If ctype is not \'custom\' coordinates canno' - 't be used to generate calibration pattern.') + raise ValueError( + "If ctype is not 'custom' coordinates canno" + "t be used to generate calibration pattern." + ) horiz, vert = float(horiz), float(vert) - xx = np.array(([0., horiz], [0., vert])) - h_pix, v_pix = np.diff(self._ec._convert_units(xx, units, 'pix'), - axis=1)[:, 0] - h_max, v_max = self._size[0] / 2., self._size[1] / 2. - for p, m, s in zip((h_pix, v_pix), (h_max, v_max), ('horiz', 'vert')): + xx = np.array(([0.0, horiz], [0.0, vert])) + h_pix, v_pix = np.diff(self._ec._convert_units(xx, units, "pix"), axis=1)[:, 0] + h_max, v_max = self._size[0] / 2.0, self._size[1] / 2.0 + for p, m, s in zip((h_pix, v_pix), (h_max, v_max), ("horiz", "vert")): if p > m: - raise ValueError('{0} too large ({1} > {2})' - ''.format(s, p, m)) + raise ValueError(f"{s} too large ({p} > {m})" "") # make the locations - if ctype == 'HV5': + if ctype == "HV5": mat = np.array([[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) - elif ctype == 'HV9': - mat = np.array([[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], - [-1, -1], [1, -1], [-1, 1]]) - elif ctype == 'H3': + elif ctype == "HV9": + mat = np.array( + [ + [0, 0], + [1, 0], + [-1, 0], + [0, 1], + [0, -1], + [1, 1], + [-1, -1], + [1, -1], + [-1, 1], + ] + ) + elif ctype == "H3": mat = np.array([[0, 0], [1, 0], [-1, 0]]) - elif ctype == 'HV13': - mat = np.array([[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], - [-1, -1], [1, -1], [-1, 1], [.5, .5], [-.5, -.5], - [.5, -.5], [-.5, .5]]) - elif ctype == 'custom': + elif ctype == "HV13": + mat = np.array( + [ + [0, 0], + [1, 0], + [-1, 0], + [0, 1], + [0, -1], + [1, 1], + [-1, -1], + [1, -1], + [-1, 1], + [0.5, 0.5], + [-0.5, -0.5], + [0.5, -0.5], + [-0.5, 0.5], + ] + ) + elif ctype == "custom": mat = np.array(coordinates, float) if mat.ndim != 2 or mat.shape[-1] != 2: - raise ValueError('Each coordinate must be a list with length 2' - '.') + raise ValueError("Each coordinate must be a list with length 2" ".") offsets = mat * np.array([h_pix, v_pix]) - coords = (self._size / 2. + offsets) + coords = self._size / 2.0 + offsets n_samples = coords.shape[0] - targs = ' '.join(['{0},{1}'.format(*c) for c in coords]) - seq = ','.join([str(x) for x in range(n_samples + 1)]) - self._command('calibration_type = {0}'.format(ctype)) - self._command('generate_default_targets = NO') - self._command('calibration_samples = {0}'.format(n_samples)) - self._command('calibration_sequence = ' + seq) - self._command('calibration_targets = ' + targs) - self._command('validation_samples = {0}'.format(n_samples)) - self._command('validation_sequence = ' + seq) - self._command('validation_targets = ' + targs) + targs = " ".join(["{0},{1}".format(*c) for c in coords]) + seq = ",".join([str(x) for x in range(n_samples + 1)]) + self._command(f"calibration_type = {ctype}") + self._command("generate_default_targets = NO") + self._command(f"calibration_samples = {n_samples}") + self._command("calibration_sequence = " + seq) + self._command("calibration_targets = " + targs) + self._command(f"validation_samples = {n_samples}") + self._command("validation_sequence = " + seq) + self._command("validation_targets = " + targs) def get_eye_position(self): """The current eye position in pixels @@ -676,11 +754,14 @@ def get_eye_position(self): if not self.dummy_mode: sample = self._eyelink.getNewestSample() if sample is None: - raise RuntimeError('No sample data, consider starting a ' - 'recording using el.start()') + raise RuntimeError( + "No sample data, consider starting a " "recording using el.start()" + ) if sample.isBinocular(): - eye_pos = (np.array(sample.getLeftEye().getGaze()) + - np.array(sample.getRightEye().getGaze())) / 2. + eye_pos = ( + np.array(sample.getLeftEye().getGaze()) + + np.array(sample.getRightEye().getGaze()) + ) / 2.0 elif sample.isLeftSample(): eye_pos = np.array(sample.getLeftEye().getGaze()) elif sample.isRightSample(): @@ -699,8 +780,7 @@ def _toggle_dummy_cursor(self, visibility): @property def file_list(self): - """The list of files started on the EyeLink - """ + """The list of files started on the EyeLink""" return self._file_list @property @@ -730,7 +810,7 @@ def __init__(self, ec, beep=False): self.ec = ec self.keys = [] ws = np.array(ec.window_size_pix) - self.img_span = 1.5 * np.array((float(ws[0]) / ws[1], 1.)) + self.img_span = 1.5 * np.array((float(ws[0]) / ws[1], 1.0)) # set up reusable objects self.targ_circ = FixationDot(self.ec) @@ -749,11 +829,15 @@ def __init__(self, ec, beep=False): self.img_size = (0, 0) def setup_event_handlers(self): - self.label = Text(self.ec, 'Eye Label', units='norm', - pos=(0, -self.img_span[1] / 2.), - anchor_y='top', color='white') - self.img = RawImage(self.ec, np.zeros((1, 2, 3)), - pos=(0, 0), units='norm') + self.label = Text( + self.ec, + "Eye Label", + units="norm", + pos=(0, -self.img_span[1] / 2.0), + anchor_y="top", + color="white", + ) + self.img = RawImage(self.ec, np.zeros((1, 2, 3)), pos=(0, 0), units="norm") def on_mouse_press(x, y, button, modifiers): self.state = 1 @@ -773,9 +857,13 @@ def on_key_press(symbol, modifiers): self.keys += [pylink.KeyInput(key, modifiers)] # create new handler at top of handling stack - self.ec.window.push_handlers(on_key_press, on_mouse_press, - on_mouse_motion, on_mouse_release, - on_mouse_drag) + self.ec.window.push_handlers( + on_key_press, + on_mouse_press, + on_mouse_motion, + on_mouse_release, + on_mouse_drag, + ) def release_event_handlers(self): try: @@ -794,7 +882,7 @@ def record_abort_hide(self): pass def draw_cal_target(self, x, y): - self.targ_circ.set_pos((x, y), units='pix') + self.targ_circ.set_pos((x, y), units="pix") self.targ_circ.draw() self.ec.flip() @@ -829,19 +917,25 @@ def _img2win(self, x, y): return x, y def alert_printf(self, msg): - logger.warning('EyeLink: alert_printf {}'.format(msg)) + logger.warning(f"EyeLink: alert_printf {msg}") def setup_image_display(self, w, h): # convert w, h from pixels to relative units x = np.array([[0, 0], [0, self.img_span[1]]], float) - x = np.diff(self.ec._convert_units(x, 'norm', 'pix')[1]) / h + x = np.diff(self.ec._convert_units(x, "norm", "pix")[1]) / h self.img.set_scale(x) self.clear_display() def image_title(self, text): - text = "
{0}
".format(text) - self.label = Text(self.ec, text, units='norm', anchor_y='top', - color='white', pos=(0, -self.img_span[1] / 2.)) + text = f"
{text}
" + self.label = Text( + self.ec, + text, + units="norm", + anchor_y="top", + color="white", + pos=(0, -self.img_span[1] / 2.0), + ) def set_image_palette(self, r, g, b): self.palette = np.array([r, g, b], np.uint8).T @@ -850,7 +944,7 @@ def draw_image_line(self, width, line, totlines, buff): if self.image_buffer is None: self.img_size = (width, totlines) self.image_buffer = np.empty((totlines, width, 3), float) - self.image_buffer[line - 1, :, :] = self.palette[buff, :] / 255. + self.image_buffer[line - 1, :, :] = self.palette[buff, :] / 255.0 if line == totlines: self.img.set_image(self.image_buffer) self.img.draw() @@ -862,18 +956,18 @@ def draw_line(self, x1, y1, x2, y2, colorindex): color = _get_color_dict()[str(colorindex)] x1, y1 = self._img2win(x1, y1) x2, y2 = self._img2win(x2, y2) - Line(self.ec, ((x1, x2), (y1, y2)), 'pix', color).draw() + Line(self.ec, ((x1, x2), (y1, y2)), "pix", color).draw() def draw_lozenge(self, x, y, width, height, colorindex): - coords = self._img2win(x + width / 2., y + width / 2.) - width = width * self.img.scale / 2. - height = height * self.img.scale / 2. + coords = self._img2win(x + width / 2.0, y + width / 2.0) + width = width * self.img.scale / 2.0 + height = height * self.img.scale / 2.0 self.loz_circ.set_line_color(_get_color_dict()[str(colorindex)]) - self.loz_circ.set_pos(coords, units='pix') - self.loz_circ.set_radius((width, height), units='pix') + self.loz_circ.set_pos(coords, units="pix") + self.loz_circ.set_radius((width, height), units="pix") self.loz_circ.draw() def _within_distance(pos_1, pos_2, radius): """Helper for checking eye position""" - return np.sum((pos_1 - pos_2) ** 2) <= radius ** 2 + return np.sum((pos_1 - pos_2) ** 2) <= radius**2 diff --git a/expyfun/_git.py b/expyfun/_git.py index 06e78d68..fb8d1908 100644 --- a/expyfun/_git.py +++ b/expyfun/_git.py @@ -1,16 +1,17 @@ -# -*- coding: utf-8 -*- import os -from os import path as op import sys import warnings +from importlib import reload +from io import StringIO +from os import path as op -from ._utils import _TempDir, string_types, run_subprocess, StringIO, reload +from ._utils import _TempDir, run_subprocess from ._version import __version__ this_version = __version__[-7:] try: - run_subprocess(['git', '--help']) + run_subprocess(["git", "--help"]) except Exception as exp: _has_git, why_not = False, str(exp) else: @@ -20,22 +21,21 @@ def _check_git(): """Helper to check the expyfun version""" if not _has_git: - raise RuntimeError('git not found: {0}'.format(why_not)) + raise RuntimeError(f"git not found: {why_not}") def _check_version_format(version): """Helper to ensure version is of proper format""" - if not isinstance(version, string_types) or len(version) != 7: - raise TypeError('version must be a string of length 7, got {0}' - ''.format(version)) + if not isinstance(version, str) or len(version) != 7: + raise TypeError(f"version must be a string of length 7, got {version}" "") def _active_version(wd): """Helper to get the currently active version""" - return run_subprocess(['git', 'rev-parse', 'HEAD'], cwd=wd)[0][:7] + return run_subprocess(["git", "rev-parse", "HEAD"], cwd=wd)[0][:7] -def download_version(version='current', dest_dir=None): +def download_version(version="current", dest_dir=None): """Download specific expyfun version Parameters @@ -56,30 +56,28 @@ def download_version(version='current', dest_dir=None): _check_version_format(version) if dest_dir is None: dest_dir = os.getcwd() - if not isinstance(dest_dir, string_types) or not op.isdir(dest_dir): - raise IOError('Destination directory {0} does not exist' - ''.format(dest_dir)) - if op.isdir(op.join(dest_dir, 'expyfun')): - raise IOError('Destination directory {0} already has "expyfun" ' - 'subdirectory'.format(dest_dir)) + if not isinstance(dest_dir, str) or not op.isdir(dest_dir): + raise OSError(f"Destination directory {dest_dir} does not exist" "") + if op.isdir(op.join(dest_dir, "expyfun")): + raise OSError( + f'Destination directory {dest_dir} already has "expyfun" ' "subdirectory" + ) # fetch locally and get the proper version tempdir = _TempDir() - expyfun_dir = op.join(tempdir, 'expyfun') # git will auto-create this dir - repo_url = 'https://github.com/LABSN/expyfun.git' + expyfun_dir = op.join(tempdir, "expyfun") # git will auto-create this dir + repo_url = "https://github.com/LABSN/expyfun.git" env = os.environ.copy() env["GIT_TERMINAL_PROMPT"] = "0" # do not prompt for credentials run_subprocess( - ['git', 'clone', repo_url, expyfun_dir, - "--single-branch", "--branch", "main"], + ["git", "clone", repo_url, expyfun_dir, "--single-branch", "--branch", "main"], env=env, ) - version = _active_version(expyfun_dir) if version == 'current' else version + version = _active_version(expyfun_dir) if version == "current" else version try: - run_subprocess(['git', 'checkout', version], cwd=expyfun_dir, env=env) + run_subprocess(["git", "checkout", version], cwd=expyfun_dir, env=env) except Exception as exp: - raise RuntimeError('Could not check out version {0}: {1}' - ''.format(version, str(exp))) + raise RuntimeError(f"Could not check out version {version}: {str(exp)}" "") assert _active_version(expyfun_dir) == version # install @@ -88,29 +86,53 @@ def download_version(version='current', dest_dir=None): # ensure our version-specific "setup" is imported sys.path.insert(0, expyfun_dir) orig_stdout = sys.stdout + # numpy.distutils is gone, but all we use is setup from it. Let's use the one + # from setuptools instead. + orig_numpy_distutils_core = None + if "numpy.distutils.core" in sys.modules: + orig_numpy_distutils_core = sys.modules["numpy.distutils.core"] + import setuptools + + sys.modules["numpy.distutils.core"] = setuptools try: # on pytest with Py3k this can be problematic - if 'setup' in sys.modules: - del sys.modules['setup'] + if "setup" in sys.modules: + del sys.modules["setup"] import setup + reload(setup) setup_version = setup.git_version() # This is necessary because for a while git_version returned # a tuple of (version, fork) - if not isinstance(setup_version, string_types): + if not isinstance(setup_version, str): setup_version = setup_version[0] assert version.lower() == setup_version[:7].lower() del setup_version + # Now we need to monkey-patch to change FULL_VERSION, which can be for example: + # 2.0.0.dev-090948e + # to + # 2.0.0.dev0+090948e + if "-" in setup.FULL_VERSION: + setup.FULL_VERSION = setup.FULL_VERSION.replace("-", "0+") # PEP440 sys.stdout = StringIO() with warnings.catch_warnings(record=True): # PEP440 - setup.setup_package( - script_args=['build', '--build-purelib', dest_dir]) + setup.setup_package(script_args=["build", "--build-purelib", dest_dir]) finally: sys.stdout = orig_stdout sys.path.pop(sys.path.index(expyfun_dir)) os.chdir(orig_dir) - print('\n'.join(['Successfully checked out expyfun version:', version, - 'into destination directory:', op.join(dest_dir)])) + if orig_numpy_distutils_core is not None: + sys.modules["numpy.distutils.core"] = orig_numpy_distutils_core + print( + "\n".join( + [ + "Successfully checked out expyfun version:", + version, + "into destination directory:", + op.join(dest_dir), + ] + ) + ) def assert_version(version): @@ -123,5 +145,7 @@ def assert_version(version): """ _check_version_format(version) if this_version.lower() != version.lower(): - raise AssertionError('Requested version {0} does not match current ' - 'version {1}'.format(version, this_version)) + raise AssertionError( + f"Requested version {version} does not match current " + f"version {this_version}" + ) diff --git a/expyfun/_input_controllers.py b/expyfun/_input_controllers.py index 046c44cb..57631a09 100644 --- a/expyfun/_input_controllers.py +++ b/expyfun/_input_controllers.py @@ -7,17 +7,16 @@ # # License: BSD (3-clause) -from functools import partial import sys +from functools import partial import numpy as np -from .visual import (Triangle, Rectangle, Circle, Diamond, ConcentricCircles, - FixationDot) -from ._utils import clock, string_types, logger +from ._utils import clock, logger +from .visual import Circle, ConcentricCircles, Diamond, FixationDot, Rectangle, Triangle -class Keyboard(object): +class Keyboard: """Retrieve presses from various devices. Public metohds: @@ -34,16 +33,19 @@ class Keyboard(object): _retrieve_events """ - key_event_types = {'presses': ['press'], 'releases': ['release'], - 'both': ['press', 'release']} + key_event_types = { + "presses": ["press"], + "releases": ["release"], + "both": ["press", "release"], + } def __init__(self, ec, force_quit_keys): self.master_clock = ec._master_clock self.log_presses = ec._log_presses self.force_quit_keys = force_quit_keys self.listen_start = None - ec._time_correction_fxns['keypress'] = self._get_timebase - self.get_time_corr = partial(ec._get_time_correction, 'keypress') + ec._time_correction_fxns["keypress"] = self._get_timebase + self.get_time_corr = partial(ec._get_time_correction, "keypress") self.time_correction = self.get_time_corr() self.ec = ec # always init pyglet response handler for error (and non-error) keys @@ -57,19 +59,18 @@ def __init__(self, ec, force_quit_keys): def _clear_events(self): self._clear_keyboard_events() - def _retrieve_events(self, live_keys, kind='presses'): + def _retrieve_events(self, live_keys, kind="presses"): return self._retrieve_keyboard_events(live_keys, kind) def _get_timebase(self): - """Get keyboard time reference (in seconds) - """ + """Get keyboard time reference (in seconds)""" return clock() def _clear_keyboard_events(self): self.ec._dispatch_events() self._keyboard_buffer = [] - def _retrieve_keyboard_events(self, live_keys, kind='presses'): + def _retrieve_keyboard_events(self, live_keys, kind="presses"): # add escape keys if live_keys is not None: live_keys = [str(x) for x in live_keys] # accept ints @@ -83,22 +84,21 @@ def _retrieve_keyboard_events(self, live_keys, kind='presses'): targets.append(key) return targets - def _on_pyglet_keypress(self, symbol, modifiers, emulated=False, - isPress=True): + def _on_pyglet_keypress(self, symbol, modifiers, emulated=False, isPress=True): """Handler for on_key_press pyglet events""" key_time = clock() if emulated: this_key = str(symbol) else: from pyglet.window import key + this_key = key.symbol_string(symbol).lower() - this_key = this_key.lstrip('_').lstrip('NUM_') - press_or_release = {True: 'press', False: 'release'}[isPress] + this_key = this_key.lstrip("_").lstrip("NUM_") + press_or_release = {True: "press", False: "release"}[isPress] self._keyboard_buffer.append((this_key, key_time, press_or_release)) def _on_pyglet_keyrelease(self, symbol, modifiers, emulated=False): - self._on_pyglet_keypress(symbol, modifiers, emulated=emulated, - isPress=False) + self._on_pyglet_keypress(symbol, modifiers, emulated=emulated, isPress=False) def listen_presses(self): """Start listening for keypresses.""" @@ -106,8 +106,9 @@ def listen_presses(self): self.listen_start = self.master_clock() self._clear_events() - def get_presses(self, live_keys, timestamp, relative_to, kind='presses', - return_kinds=False): + def get_presses( + self, live_keys, timestamp, relative_to, kind="presses", return_kinds=False + ): """Get the current entire keyboard / button box buffer. Parameters @@ -129,21 +130,24 @@ def get_presses(self, live_keys, timestamp, relative_to, kind='presses', The presses (and possibly timestamps and/or types). """ if kind not in self.key_event_types.keys(): - raise ValueError('Kind argument must be one of: '+', '.join( - self.key_event_types.keys())) + raise ValueError( + "Kind argument must be one of: " + + ", ".join(self.key_event_types.keys()) + ) events = [] if timestamp and relative_to is None: if self.listen_start is None: - raise ValueError('I cannot timestamp: relative_to is None and ' - 'you have not yet called listen_presses.') + raise ValueError( + "I cannot timestamp: relative_to is None and " + "you have not yet called listen_presses." + ) relative_to = self.listen_start events = self._retrieve_events(live_keys, kind) events = self._correct_presses(events, timestamp, relative_to, kind) events = [e[:-1] for e in events] if not return_kinds else events return events - def wait_one_press(self, max_wait, min_wait, live_keys, timestamp, - relative_to): + def wait_one_press(self, max_wait, min_wait, live_keys, timestamp, relative_to): """Return the first button pressed after min_wait. Parameters @@ -165,10 +169,10 @@ def wait_one_press(self, max_wait, min_wait, live_keys, timestamp, The press. Will be tuple if timestamp is True. """ relative_to, start_time = self._init_wait_press( - max_wait, min_wait, live_keys, relative_to) + max_wait, min_wait, live_keys, relative_to + ) pressed = [] - while (not len(pressed) and - self.master_clock() - start_time < max_wait): + while not len(pressed) and self.master_clock() - start_time < max_wait: pressed = self._retrieve_events(live_keys) # handle non-presses @@ -179,8 +183,7 @@ def wait_one_press(self, max_wait, min_wait, live_keys, timestamp, pressed = (None, None) if timestamp else None return pressed - def wait_for_presses(self, max_wait, min_wait, live_keys, - timestamp, relative_to): + def wait_for_presses(self, max_wait, min_wait, live_keys, timestamp, relative_to): """Return all button presses between min_wait and max_wait. Parameters @@ -202,9 +205,10 @@ def wait_for_presses(self, max_wait, min_wait, live_keys, The list of presses (and possibly timestamps). """ relative_to, start_time = self._init_wait_press( - max_wait, min_wait, live_keys, relative_to) + max_wait, min_wait, live_keys, relative_to + ) pressed = [] - while (self.master_clock() - start_time < max_wait): + while self.master_clock() - start_time < max_wait: pressed = self._retrieve_events(live_keys) pressed = self._correct_presses(pressed, timestamp, relative_to) pressed = [p[:2] if timestamp else p[0] for p in pressed] @@ -224,18 +228,20 @@ def check_force_quit(self, keys=None): # only grab the force-quit keys keys = self._retrieve_keyboard_events([]) else: - if isinstance(keys, string_types): + if isinstance(keys, str): keys = [keys] if isinstance(keys, list): keys = [k for k in keys if k in self.force_quit_keys] else: - raise TypeError('Force quit checking requires a string or ' - ' list of strings, not a {}.' - ''.format(type(keys))) + raise TypeError( + "Force quit checking requires a string or " + f" list of strings, not a {type(keys)}." + "" + ) if len(keys): - raise RuntimeError('Quit key pressed') + raise RuntimeError("Quit key pressed") - def _correct_presses(self, events, timestamp, relative_to, kind='presses'): + def _correct_presses(self, events, timestamp, relative_to, kind="presses"): """Correct timing of presses and check for quit press.""" events = [(k, s + self.time_correction, r) for k, s, r in events] self.log_presses(events) @@ -251,10 +257,11 @@ def _correct_presses(self, events, timestamp, relative_to, kind='presses'): def _init_wait_press(self, max_wait, min_wait, live_keys, relative_to): """Prepare for ``wait_one_press`` and ``wait_for_presses``.""" if np.isinf(max_wait) and live_keys == []: - raise ValueError('max_wait cannot be infinite if there are no live' - ' keys.') + raise ValueError( + "max_wait cannot be infinite if there are no live" " keys." + ) if not min_wait <= max_wait: - raise ValueError('min_wait must be less than max_wait') + raise ValueError("min_wait must be less than max_wait") start_time = self.master_clock() relative_to = start_time if relative_to is None else relative_to self.ec.wait_secs(min_wait) @@ -263,7 +270,7 @@ def _init_wait_press(self, max_wait, min_wait, live_keys, relative_to): return relative_to, start_time -class Mouse(object): +class Mouse: """Class to track mouse properties and events Parameters @@ -289,21 +296,28 @@ class Mouse(object): def __init__(self, ec, visible=False): from pyglet.window import mouse + self.ec = ec self.set_visible(visible) self.master_clock = ec._master_clock self.log_clicks = ec._log_clicks self.listen_start = None - ec._time_correction_fxns['mouseclick'] = self._get_timebase - self.get_time_corr = partial(ec._get_time_correction, 'mouseclick') + ec._time_correction_fxns["mouseclick"] = self._get_timebase + self.get_time_corr = partial(ec._get_time_correction, "mouseclick") self.time_correction = self.get_time_corr() self._check_force_quit = ec.check_force_quit self.ec._win.on_mouse_press = self._on_pyglet_mouse_click self._mouse_buffer = [] - self._button_names = {mouse.LEFT: 'left', mouse.MIDDLE: 'middle', - mouse.RIGHT: 'right'} - self._button_ids = {'left': mouse.LEFT, 'middle': mouse.MIDDLE, - 'right': mouse.RIGHT} + self._button_names = { + mouse.LEFT: "left", + mouse.MIDDLE: "middle", + mouse.RIGHT: "right", + } + self._button_ids = { + "left": mouse.LEFT, + "middle": mouse.MIDDLE, + "right": mouse.RIGHT, + } self._legal_types = (Rectangle, Circle) def set_visible(self, visible): @@ -326,10 +340,12 @@ def visible(self): @property def pos(self): """The current position of the mouse in normalized units""" - x = (self.ec._win._mouse_x - - self.ec._win.width / 2.) / (self.ec._win.width / 2.) - y = (self.ec._win._mouse_y - - self.ec._win.height / 2.) / (self.ec._win.height / 2.) + x = (self.ec._win._mouse_x - self.ec._win.width / 2.0) / ( + self.ec._win.width / 2.0 + ) + y = (self.ec._win._mouse_y - self.ec._win.height / 2.0) / ( + self.ec._win.height / 2.0 + ) return np.array([x, y]) ########################################################################### @@ -342,8 +358,7 @@ def _retrieve_events(self, live_buttons): return self._retrieve_mouse_events(live_buttons) def _get_timebase(self): - """Get mouse time reference (in seconds) - """ + """Get mouse time reference (in seconds)""" return clock() def _clear_mouse_events(self): @@ -365,35 +380,35 @@ def _on_pyglet_mouse_click(self, x, y, button, modifiers): self._mouse_buffer.append((this_button, x, y, button_time)) def listen_clicks(self): - """Start listening for mouse clicks. - """ + """Start listening for mouse clicks.""" self.time_correction = self.get_time_corr() self.listen_start = self.master_clock() self._clear_events() def get_clicks(self, live_buttons, timestamp, relative_to): - """Get the current entire mouse buffer. - """ + """Get the current entire mouse buffer.""" clicked = [] if timestamp and relative_to is None: if self.listen_start is None: - raise ValueError('I cannot timestamp: relative_to is None and ' - 'you have not yet called listen_clicks.') + raise ValueError( + "I cannot timestamp: relative_to is None and " + "you have not yet called listen_clicks." + ) else: relative_to = self.listen_start clicked = self._retrieve_events(live_buttons) return self._correct_clicks(clicked, timestamp, relative_to) - def wait_one_click(self, max_wait, min_wait, live_buttons, - timestamp, relative_to, visible): - """Returns only the first button clicked after min_wait. - """ + def wait_one_click( + self, max_wait, min_wait, live_buttons, timestamp, relative_to, visible + ): + """Returns only the first button clicked after min_wait.""" relative_to, start_time, was_visible = self._init_wait_click( - max_wait, min_wait, live_buttons, timestamp, relative_to, visible) + max_wait, min_wait, live_buttons, timestamp, relative_to, visible + ) clicked = [] - while (not len(clicked) and - self.master_clock() - start_time < max_wait): + while not len(clicked) and self.master_clock() - start_time < max_wait: clicked = self._retrieve_events(live_buttons) # handle non-clicks @@ -405,29 +420,30 @@ def wait_one_click(self, max_wait, min_wait, live_buttons, clicked = None return clicked - def wait_for_clicks(self, max_wait, min_wait, live_buttons, - timestamp, relative_to, visible=None): - """Returns all clicks between min_wait and max_wait. - """ + def wait_for_clicks( + self, max_wait, min_wait, live_buttons, timestamp, relative_to, visible=None + ): + """Returns all clicks between min_wait and max_wait.""" relative_to, start_time, was_visible = self._init_wait_click( - max_wait, min_wait, live_buttons, timestamp, relative_to, visible) + max_wait, min_wait, live_buttons, timestamp, relative_to, visible + ) clicked = [] - while (self.master_clock() - start_time < max_wait): + while self.master_clock() - start_time < max_wait: clicked = self._retrieve_events(live_buttons) return self._correct_clicks(clicked, timestamp, relative_to) - def wait_for_click_on(self, objects, max_wait, min_wait, - live_buttons, timestamp, relative_to): - """Waits for a click on one of the supplied window objects - """ + def wait_for_click_on( + self, objects, max_wait, min_wait, live_buttons, timestamp, relative_to + ): + """Waits for a click on one of the supplied window objects""" relative_to, start_time, was_visible = self._init_wait_click( - max_wait, min_wait, live_buttons, timestamp, relative_to, True) + max_wait, min_wait, live_buttons, timestamp, relative_to, True + ) index = None ci = 0 - while (self.master_clock() - start_time < max_wait and - index is None): + while self.master_clock() - start_time < max_wait and index is None: clicked = self._retrieve_events(live_buttons) self._check_force_quit() while ci < len(clicked) and index is None: # clicks first @@ -453,29 +469,28 @@ def wait_for_click_on(self, objects, max_wait, min_wait, def _correct_clicks(self, clicked, timestamp, relative_to): """Correct timing of clicks""" if len(clicked): - clicked = [(b, x, y, s + self.time_correction) for - b, x, y, s in clicked] + clicked = [(b, x, y, s + self.time_correction) for b, x, y, s in clicked] self.log_clicks(clicked) buttons = [(b, x, y) for b, x, y, _ in clicked] self._check_force_quit() if timestamp: - clicked = [(b, x, y, s - relative_to) for - b, x, y, s in clicked] + clicked = [(b, x, y, s - relative_to) for b, x, y, s in clicked] else: clicked = buttons return clicked - def _init_wait_click(self, max_wait, min_wait, live_buttons, timestamp, - relative_to, visible): - """Actions common to ``wait_one_click`` and ``wait_for_clicks`` - """ + def _init_wait_click( + self, max_wait, min_wait, live_buttons, timestamp, relative_to, visible + ): + """Actions common to ``wait_one_click`` and ``wait_for_clicks``""" if np.isinf(max_wait) and live_buttons == []: - raise ValueError('max_wait cannot be infinite if there are no live' - ' mouse buttons.') + raise ValueError( + "max_wait cannot be infinite if there are no live" " mouse buttons." + ) if not min_wait <= max_wait: - raise ValueError('min_wait must be less than max_wait') + raise ValueError("min_wait must be less than max_wait") if visible not in [True, False, None]: - raise ValueError('set_visible must be one of (True, False, None)') + raise ValueError("set_visible must be one of (True, False, None)") start_time = self.master_clock() if timestamp and relative_to is None: relative_to = start_time @@ -489,27 +504,25 @@ def _init_wait_click(self, max_wait, min_wait, live_buttons, timestamp, # Define some functions for determining if a click point is in an object def _point_in_object(self, pos, obj): - """Determine if a point is within a visual object - """ + """Determine if a point is within a visual object""" if isinstance(obj, (Rectangle, Circle, Diamond, Triangle)): return self._point_in_tris(pos, obj) elif isinstance(obj, (ConcentricCircles, FixationDot)): return np.any([self._point_in_tris(pos, c) for c in obj._circles]) def _point_in_tris(self, pos, obj): - """Check to see if a point is in any of the triangles - """ - these_tris = obj._tris['fill'].reshape(-1, 3) + """Check to see if a point is in any of the triangles""" + these_tris = obj._tris["fill"].reshape(-1, 3) for tri in these_tris: - if self._point_in_tri(pos, obj._points['fill'][tri]): + if self._point_in_tri(pos, obj._points["fill"][tri]): return True return False def _point_in_tri(self, pos, tri): - """Check to see if a point is in a single triangle - """ - signs = np.sign([np.cross(tri[np.mod(i + 1, 3)] - tri[i], - pos - tri[i]) for i in range(3)]) + """Check to see if a point is in a single triangle""" + signs = np.sign( + [_cross_2d(tri[np.mod(i + 1, 3)] - tri[i], pos - tri[i]) for i in range(3)] + ) if np.all(signs[1:] == signs[0]): return True else: @@ -517,12 +530,16 @@ def _point_in_tri(self, pos, tri): def _move_to(self, pos, units): # adapted from pyautogui (BSD) - x, y = self.ec._convert_units(np.array( - [pos]).T, units, 'pix')[:, 0].round().astype(int) + x, y = ( + self.ec._convert_units(np.array([pos]).T, units, "pix")[:, 0] + .round() + .astype(int) + ) # The "y" we use is inverted relative to the OSes y = self.ec.window.height - y - if sys.platform == 'darwin': - from pyglet.libs.darwin.cocoapy import quartz, CGPoint, CGRect + if sys.platform == "darwin": + from pyglet.libs.darwin.cocoapy import CGPoint, CGRect, quartz + # Convert from window to global view, window = self.ec.window._nsview, self.ec.window._nswindow point = CGPoint() @@ -548,9 +565,10 @@ def _move_to(self, pos, units): # func(kCGHIDEventTap, event) # time.sleep(0.001) # quartz.CFRelease(event) - elif sys.platform.startswith('win'): + elif sys.platform.startswith("win"): # Convert from window to global from pyglet.window.win32 import POINT, _user32, byref + point = POINT() point.x = x point.y = y @@ -559,8 +577,13 @@ def _move_to(self, pos, units): _user32.SetCursorPos(point.x, point.y) else: # https://stackoverflow.com/questions/2433447 - from pyglet.libs.x11.xlib import (XWarpPointer, XFlush, - XSelectInput, KeyReleaseMask) + from pyglet.libs.x11.xlib import ( + KeyReleaseMask, + XFlush, + XSelectInput, + XWarpPointer, + ) + display, window = self.ec.window._x_display, self.ec.window._window XSelectInput(display, window, KeyReleaseMask) XWarpPointer(display, 0, window, 0, 0, 0, 0, x, y) @@ -576,13 +599,14 @@ class CedrusBox(Keyboard): def __init__(self, ec, force_quit_keys): import pyxid + pyxid.use_response_pad_timer = True dev = pyxid.get_xid_devices()[0] dev.reset_base_timer() assert dev.is_response_device() self._dev = dev - super(CedrusBox, self).__init__(ec, force_quit_keys) - ec._time_correction_maxs['keypress'] = 1e-3 # higher tolerance + super().__init__(ec, force_quit_keys) + ec._time_correction_maxs["keypress"] = 1e-3 # higher tolerance def _get_timebase(self): """WARNING: For now this will clear the event queue!""" @@ -599,7 +623,7 @@ def _clear_events(self): self._retrieve_events(None) self._keyboard_buffer = [] - def _retrieve_events(self, live_keys, kind='presses'): + def _retrieve_events(self, live_keys, kind="presses"): # add escape keys if live_keys is not None: live_keys = [str(x) for x in live_keys] # accept ints @@ -608,9 +632,8 @@ def _retrieve_events(self, live_keys, kind='presses'): self._dev.poll_for_response() while self._dev.response_queue_size() > 0: key = self._dev.get_next_response() - press_or_release = {True: 'press', - False: 'release'}[key['pressed']] - key = [str(key['key'] + 1), key['time'] / 1000., press_or_release] + press_or_release = {True: "press", False: "release"}[key["pressed"]] + key = [str(key["key"] + 1), key["time"] / 1000.0, press_or_release] self._keyboard_buffer.append(key) self._dev.poll_for_response() # check to see if we have matches @@ -632,39 +655,47 @@ class Joystick(Keyboard): def __init__(self, ec): import pyglet.input + self.ec = ec self.master_clock = ec._master_clock - self.log_presses = partial(ec._log_presses, kind='joy') + self.log_presses = partial(ec._log_presses, kind="joy") self.force_quit_keys = [] self.listen_start = None - ec._time_correction_fxns['joystick'] = self._get_timebase - self.get_time_corr = partial(ec._get_time_correction, 'joystick') + ec._time_correction_fxns["joystick"] = self._get_timebase + self.get_time_corr = partial(ec._get_time_correction, "joystick") self.time_correction = self.get_time_corr() self._keyboard_buffer = [] self._dev = pyglet.input.get_joysticks()[0] - logger.info('Expyfun: Initializing joystick %s' % (self._dev.device,)) + logger.info("Expyfun: Initializing joystick %s" % (self._dev.device,)) self._dev.open(window=ec._win, exclusive=True) - assert hasattr(self._dev, 'on_joybutton_press') - self._dev.on_joybutton_press = partial( - self._on_pyglet_joybutton, kind='press') + assert hasattr(self._dev, "on_joybutton_press") + self._dev.on_joybutton_press = partial(self._on_pyglet_joybutton, kind="press") self._dev.on_joybutton_release = partial( - self._on_pyglet_joybutton, kind='release') + self._on_pyglet_joybutton, kind="release" + ) - def _on_pyglet_joybutton(self, joystick, button='foo', kind='press'): + def _on_pyglet_joybutton(self, joystick, button="foo", kind="press"): """Handler for on_joybutton_press events.""" key_time = clock() self._keyboard_buffer.append((str(button), key_time, kind)) def _close(self): - dev = getattr(self, '_dev', None) + dev = getattr(self, "_dev", None) if dev is not None: dev.close() -for key in ('x', 'y', 'hat_x', 'hat_y', 'z', 'rz', 'rx', 'ry'): +for key in ("x", "y", "hat_x", "hat_y", "z", "rz", "rx", "ry"): + def _wrap(key=key): - sign = -1 if key in ('rz',) else 1 + sign = -1 if key in ("rz",) else 1 return property(lambda self: sign * getattr(self._dev, key)) + setattr(Joystick, key, _wrap()) del _wrap del key + + +# https://github.com/numpy/numpy/pull/26694/files +def _cross_2d(x, y): + return x[..., 0] * y[..., 1] - x[..., 1] * y[..., 0] diff --git a/expyfun/_parallel.py b/expyfun/_parallel.py index 127ab079..6150947b 100644 --- a/expyfun/_parallel.py +++ b/expyfun/_parallel.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- -"""Parallel util functions -""" +"""Parallel util functions""" # Adapted from mne-python with permission @@ -62,9 +60,10 @@ def _check_n_jobs(n_jobs): The checked number of jobs. Always positive. """ if not isinstance(n_jobs, int): - raise TypeError('n_jobs must be an integer') + raise TypeError("n_jobs must be an integer") if n_jobs <= 0: import multiprocessing + n_cores = multiprocessing.cpu_count() n_jobs = max(min(n_cores + n_jobs + 1, n_cores), 1) return n_jobs diff --git a/expyfun/_sound_controllers/__init__.py b/expyfun/_sound_controllers/__init__.py index 3d779b52..809bd6c7 100644 --- a/expyfun/_sound_controllers/__init__.py +++ b/expyfun/_sound_controllers/__init__.py @@ -1,3 +1,8 @@ -from ._sound_controller import (SoundCardController, SoundPlayer, _BACKENDS, - _import_backend) -_AUTO_BACKENDS = ('auto',) + _BACKENDS +from ._sound_controller import ( + SoundCardController, + SoundPlayer, + _BACKENDS, + _import_backend, +) + +_AUTO_BACKENDS = ("auto",) + _BACKENDS diff --git a/expyfun/_sound_controllers/_pyglet.py b/expyfun/_sound_controllers/_pyglet.py index 2c594aa3..0fc00864 100644 --- a/expyfun/_sound_controllers/_pyglet.py +++ b/expyfun/_sound_controllers/_pyglet.py @@ -10,21 +10,18 @@ import warnings import numpy as np - import pyglet from .._utils import _new_pyglet _PRIORITY = 200 -_use_silent = (os.getenv('_EXPYFUN_SILENT', '') == 'true') -_opts_dict = dict(linux2=('pulse',), - win32=('directsound',), - darwin=('openal',)) -_opts_dict['linux'] = _opts_dict['linux2'] # new name on Py3k -_driver = _opts_dict[sys.platform] if not _use_silent else ('silent',) +_use_silent = os.getenv("_EXPYFUN_SILENT", "") == "true" +_opts_dict = dict(linux2=("pulse",), win32=("directsound",), darwin=("openal",)) +_opts_dict["linux"] = _opts_dict["linux2"] # new name on Py3k +_driver = _opts_dict[sys.platform] if not _use_silent else ("silent",) -pyglet.options['audio'] = _driver +pyglet.options["audio"] = _driver # We might also want this at some point if we hit OSX problems: # pyglet.options['shadow_window'] = False @@ -35,6 +32,7 @@ except ImportError: from pyglet.media import AudioFormat from pyglet.media import Player, SourceGroup # noqa + try: from pyglet.media.codecs import StaticMemorySource except ImportError: @@ -43,30 +41,42 @@ except ImportError: from pyglet.media.sources.base import StaticMemorySource # noqa except Exception as exp: - warnings.warn('Pyglet could not be imported:\n%s' % exp) + warnings.warn("Pyglet could not be imported:\n%s" % exp) Player = AudioFormat = SourceGroup = StaticMemorySource = object def _check_pyglet_audio(): - if pyglet.media.get_audio_driver() is None and \ - not (_new_pyglet() and _driver == ('silent',)): - raise SystemError('pyglet audio ("%s") could not be initialized' - % pyglet.options['audio'][0]) + if pyglet.media.get_audio_driver() is None and not ( + _new_pyglet() and _driver == ("silent",) + ): + raise SystemError( + 'pyglet audio ("%s") could not be initialized' % pyglet.options["audio"][0] + ) class SoundPlayer(Player): """SoundPlayer based on Pyglet.""" - def __init__(self, data, fs=None, loop=False, api=None, name=None, - fixed_delay=None, api_options=None): + def __init__( + self, + data, + fs=None, + loop=False, + api=None, + name=None, + fixed_delay=None, + api_options=None, + ): assert AudioFormat is not None if any(x is not None for x in (api, name, fixed_delay, api_options)): - raise ValueError('The Pyglet backend does not support specifying ' - 'api, name, fixed_delay, or api_options') + raise ValueError( + "The Pyglet backend does not support specifying " + "api, name, fixed_delay, or api_options" + ) # We could maybe let Pyglet make this decision, but hopefully # people won't need to tweak the Pyglet backend anyway self.fs = 44100 if fs is None else fs - super(SoundPlayer, self).__init__() + super().__init__() _check_pyglet_audio() sms = _as_static(data, self.fs) if _new_pyglet(): @@ -79,34 +89,32 @@ def __init__(self, data, fs=None, loop=False, api=None, name=None, self.queue(group) self._ec_duration = sms._duration - def stop(self, wait=True, extra_delay=0.): + def stop(self, wait=True, extra_delay=0.0): """Stop.""" try: self.pause() # assert timestamp >= 0, 'Timestamp beyond dequeued source memory' except AssertionError: pass - self.seek(0.) + self.seek(0.0) @property def playing(self): # Pyglet has this, but it doesn't notice when it's finished on its own - return (super(SoundPlayer, self).playing and not - np.isclose(self.time, self._ec_duration)) + return super().playing and not np.isclose(self.time, self._ec_duration) def _as_static(data, fs): """Get data into the Pyglet audio format.""" fs = int(fs) if data.ndim not in (1, 2): - raise ValueError('Data must have one or two dimensions') + raise ValueError("Data must have one or two dimensions") n_ch = data.shape[0] if data.ndim == 2 else 1 - audio_format = AudioFormat(channels=n_ch, sample_size=16, - sample_rate=fs) - data = data.T.ravel('C') + audio_format = AudioFormat(channels=n_ch, sample_size=16, sample_rate=fs) + data = data.T.ravel("C") data[data < -1] = -1 data[data > 1] = 1 - data = (data * (2 ** 15)).astype('int16').tobytes() + data = (data * (2**15)).astype("int16").tobytes() return StaticMemorySourceFixed(data, audio_format) diff --git a/expyfun/_sound_controllers/_rtmixer.py b/expyfun/_sound_controllers/_rtmixer.py index dc38ab5b..71673853 100644 --- a/expyfun/_sound_controllers/_rtmixer.py +++ b/expyfun/_sound_controllers/_rtmixer.py @@ -7,10 +7,10 @@ import sys import numpy as np - -from rtmixer import Mixer, RingBuffer import sounddevice -from .._utils import logger, get_config +from rtmixer import Mixer, RingBuffer + +from .._utils import get_config, logger _PRIORITY = 100 _DEFAULT_NAME = None @@ -19,11 +19,11 @@ # only initialize each mixer once and reuse it until this gets garbage # collected -class _MixerRegistry(dict): +class _MixerRegistry(dict): def __del__(self): for mixer in self.values(): - print(f'Closing {mixer}') + print(f"Closing {mixer}") mixer.abort() mixer.close() self.clear() @@ -32,15 +32,15 @@ def _get_mixer(self, fs, n_channels, api, name, api_options): """Select the API and device.""" # API if api is None: - api = get_config('SOUND_CARD_API', None) + api = get_config("SOUND_CARD_API", None) if api is None: # Eventually we should maybe allow 'Windows WDM-KS', # 'Windows DirectSound', or 'MME' api = dict( - darwin='Core Audio', - win32='Windows WASAPI', - linux='ALSA', - linux2='ALSA', + darwin="Core Audio", + win32="Windows WASAPI", + linux="ALSA", + linux2="ALSA", )[sys.platform] key = (fs, n_channels, api, name) if key not in self: @@ -54,84 +54,97 @@ def _get_mixer(self, fs, n_channels, api, name, api_options): def _init_mixer(fs, n_channels, api, name, api_options=None): devices = sounddevice.query_devices() if len(devices) == 0: - raise OSError('No sound devices found!') + raise OSError("No sound devices found!") apis = sounddevice.query_hostapis() valid_apis = [] for ai, this_api in enumerate(apis): - if this_api['name'] == api: + if this_api["name"] == api: api = this_api break else: - valid_apis.append(this_api['name']) + valid_apis.append(this_api["name"]) else: m = 'Could not find host API %s. Valid choices are "%s"' - raise RuntimeError(m % (api, ', '.join(valid_apis))) + raise RuntimeError(m % (api, ", ".join(valid_apis))) del this_api # Name if name is None: - name = get_config('SOUND_CARD_NAME', None) + name = get_config("SOUND_CARD_NAME", None) if name is None: global _DEFAULT_NAME if _DEFAULT_NAME is None: - di = api['default_output_device'] - _DEFAULT_NAME = devices[di]['name'] - logger.exp('Selected default sound device: %r' % (_DEFAULT_NAME,)) + di = api["default_output_device"] + _DEFAULT_NAME = devices[di]["name"] + logger.exp("Selected default sound device: %r" % (_DEFAULT_NAME,)) name = _DEFAULT_NAME possible = list() for di, device in enumerate(devices): - if device['hostapi'] == ai: - possible.append(device['name']) - if name in device['name']: + if device["hostapi"] == ai: + possible.append(device["name"]) + if name in device["name"]: break else: - raise RuntimeError('Could not find device on API %r with name ' - 'containing %r, found:\n%s' - % (api['name'], name, '\n'.join(possible))) - param_str = ('sound card %r (devices[%d]) via %r' - % (device['name'], di, api['name'])) + raise RuntimeError( + "Could not find device on API %r with name " + "containing %r, found:\n%s" % (api["name"], name, "\n".join(possible)) + ) + param_str = "sound card %r (devices[%d]) via %r" % (device["name"], di, api["name"]) extra_settings = None if api_options is not None: - if api['name'] == 'Windows WASAPI': + if api["name"] == "Windows WASAPI": # exclusive mode is needed for zero jitter on Windows in testing extra_settings = sounddevice.WasapiSettings(**api_options) else: raise ValueError( 'api_options only supported for "Windows WASAPI" backend, ' - 'using %s backend got api_options=%s' - % (api['name'], api_options)) - param_str += ' with options %s' % (api_options,) - param_str += ', %d channels' % (n_channels,) + "using %s backend got api_options=%s" % (api["name"], api_options) + ) + param_str += " with options %s" % (api_options,) + param_str += ", %d channels" % (n_channels,) if fs is not None: - param_str += ' @ %d Hz' % (fs,) + param_str += " @ %d Hz" % (fs,) try: mixer = Mixer( - samplerate=fs, latency='low', channels=n_channels, - dither_off=True, device=di, - extra_settings=extra_settings) + samplerate=fs, + latency="low", + channels=n_channels, + dither_off=True, + device=di, + extra_settings=extra_settings, + ) except Exception as exp: - raise RuntimeError( - f'Could not set up {param_str}:\n{exp}') from None + raise RuntimeError(f"Could not set up {param_str}:\n{exp}") from None assert mixer.channels == n_channels if fs is None: - param_str += ' @ %d Hz' % (mixer.samplerate,) + param_str += " @ %d Hz" % (mixer.samplerate,) else: assert mixer.samplerate == fs mixer.start() assert mixer.active - logger.info('Expyfun: using %s, %0.1f ms nominal latency' - % (param_str, 1000 * device['default_low_output_latency'])) + logger.info( + "Expyfun: using %s, %0.1f ms nominal latency" + % (param_str, 1000 * device["default_low_output_latency"]) + ) return mixer -class SoundPlayer(object): +class SoundPlayer: """SoundPlayer based on rtmixer.""" - def __init__(self, data, fs=None, loop=False, api=None, name=None, - fixed_delay=None, api_options=None): + def __init__( + self, + data, + fs=None, + loop=False, + api=None, + name=None, + fixed_delay=None, + api_options=None, + ): data = np.atleast_2d(data).T - data = np.asarray(data, np.float32, 'C') + data = np.asarray(data, np.float32, "C") self._data = data self.loop = bool(loop) self._n_samples, n_channels = self._data.shape @@ -139,20 +152,23 @@ def __init__(self, data, fs=None, loop=False, api=None, name=None, self._n_channels = n_channels self._mixer = None # in case the next line crashes, __del__ works self._mixer = _mixer_registry._get_mixer( - fs, self._n_channels, api, name, api_options) + fs, self._n_channels, api, name, api_options + ) if loop: - self._ring = RingBuffer(self._data.itemsize * self._n_channels, - self._data.size) + self._ring = RingBuffer( + self._data.itemsize * self._n_channels, self._data.size + ) self._ring.write(self._data) self._fs = float(self._mixer.samplerate) self._ec_duration = self._n_samples / self._fs self._action = None self._fixed_delay = fixed_delay if fixed_delay is not None: - logger.info('Expyfun: Using fixed audio delay %0.1f ms' - % (1000 * fixed_delay,)) + logger.info( + "Expyfun: Using fixed audio delay %0.1f ms" % (1000 * fixed_delay,) + ) else: - logger.info('Expyfun: Variable audio delay') + logger.info("Expyfun: Variable audio delay") @property def fs(self): @@ -167,25 +183,28 @@ def _start_time(self): if self._fixed_delay is not None: return self._mixer.time + self._fixed_delay else: - return 0. + return 0.0 def play(self): """Play.""" if not self.playing and self._mixer is not None: if self.loop: self._action = self._mixer.play_ringbuffer( - self._ring, start=self._start_time) + self._ring, start=self._start_time + ) else: self._action = self._mixer.play_buffer( - self._data, self._data.shape[1], start=self._start_time) + self._data, self._data.shape[1], start=self._start_time + ) - def stop(self, wait=True, extra_delay=0.): + def stop(self, wait=True, extra_delay=0.0): """Stop.""" if self.playing: action, self._action = self._action, None # Impose the same delay here that we imposed on the stim start cancel_action = self._mixer.cancel( - action, time=self._start_time + extra_delay) + action, time=self._start_time + extra_delay + ) if wait: self._mixer.wait(cancel_action) else: @@ -193,17 +212,17 @@ def stop(self, wait=True, extra_delay=0.): def delete(self): """Delete.""" - if getattr(self, '_mixer', None) is not None: + if getattr(self, "_mixer", None) is not None: self.stop(wait=False) mixer, self._mixer = self._mixer, None try: stats = mixer.fetch_and_reset_stats().stats except RuntimeError as exc: # action queue is full - logger.exp(f'Could not fetch mixer stats ({exc})') + logger.exp(f"Could not fetch mixer stats ({exc})") else: logger.exp( - f'{stats.output_underflows} underflows ' - f'{stats.blocks} blocks') + f"{stats.output_underflows} underflows " f"{stats.blocks} blocks" + ) def __del__(self): # noqa self.delete() diff --git a/expyfun/_sound_controllers/_sound_controller.py b/expyfun/_sound_controllers/_sound_controller.py index fd7ec85b..bef54133 100644 --- a/expyfun/_sound_controllers/_sound_controller.py +++ b/expyfun/_sound_controllers/_sound_controller.py @@ -9,31 +9,42 @@ import operator import os import os.path as op - -import numpy as np - -from .._fixes import rfft, irfft, rfftfreq -from .._utils import logger, flush_logger, _check_params import warnings +import numpy as np -_BACKENDS = tuple(sorted( - op.splitext(x.lstrip('._'))[0] for x in os.listdir(op.dirname(__file__)) - if x.startswith('_') and x.endswith(('.py', '.pyc')) and - not x.startswith(('_sound_controller.py', '__init__.py')))) +from .._fixes import irfft, rfft, rfftfreq +from .._utils import _check_params, flush_logger, logger + +_BACKENDS = tuple( + sorted( + op.splitext(x.lstrip("._"))[0] + for x in os.listdir(op.dirname(__file__)) + if x.startswith("_") + and x.endswith((".py", ".pyc")) + and not x.startswith(("_sound_controller.py", "__init__.py")) + ) +) # libsoundio stub (kind of iffy) # https://gist.github.com/larsoner/fd9228f321d369c8a00c66a246fcc83f _SOUND_CARD_KEYS = ( - 'TYPE', 'SOUND_CARD_BACKEND', 'SOUND_CARD_API', - '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', 'SOUND_CARD_DRIFT_TRIGGER', + "TYPE", + "SOUND_CARD_BACKEND", + "SOUND_CARD_API", + "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", + "SOUND_CARD_DRIFT_TRIGGER", ) -class SoundCardController(object): +class SoundCardController: """Use a sound card. Parameters @@ -90,33 +101,32 @@ class SoundCardController(object): the configuration file. """ - def __init__(self, params, stim_fs, n_channels=2, trigger_duration=0.01, - ec=None): + def __init__(self, params, stim_fs, n_channels=2, trigger_duration=0.01, ec=None): self.ec = ec defaults = dict( - SOUND_CARD_BACKEND='auto', + SOUND_CARD_BACKEND="auto", SOUND_CARD_TRIGGER_CHANNELS=0, - SOUND_CARD_TRIGGER_SCALE=1. / float(2 ** 31 - 1), - SOUND_CARD_TRIGGER_INSERTION='prepend', + SOUND_CARD_TRIGGER_SCALE=1.0 / float(2**31 - 1), + SOUND_CARD_TRIGGER_INSERTION="prepend", SOUND_CARD_TRIGGER_ID_AFTER_ONSET=False, - SOUND_CARD_DRIFT_TRIGGER='end', + SOUND_CARD_DRIFT_TRIGGER="end", ) # any omitted become None - params = _check_params(params, _SOUND_CARD_KEYS, defaults, 'params') - if params['SOUND_CARD_FS'] is not None: - params['SOUND_CARD_FS'] = float(params['SOUND_CARD_FS']) - - self.backend, self.backend_name = _import_backend( - params['SOUND_CARD_BACKEND']) - self._n_channels_stim = int(params['SOUND_CARD_TRIGGER_CHANNELS']) - trig_scale = float(params['SOUND_CARD_TRIGGER_SCALE']) + params = _check_params(params, _SOUND_CARD_KEYS, defaults, "params") + if params["SOUND_CARD_FS"] is not None: + params["SOUND_CARD_FS"] = float(params["SOUND_CARD_FS"]) + + self.backend, self.backend_name = _import_backend(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') + str(params["SOUND_CARD_TRIGGER_ID_AFTER_ONSET"]).lower() == "true" + ) self._extra_onset_triggers = list() - drift_trigger = params['SOUND_CARD_DRIFT_TRIGGER'] + drift_trigger = params["SOUND_CARD_DRIFT_TRIGGER"] if np.isscalar(drift_trigger): drift_trigger = [drift_trigger] # convert possible command-line option - if isinstance(drift_trigger, str) and drift_trigger != 'end': + if isinstance(drift_trigger, str) and drift_trigger != "end": drift_trigger = eval(drift_trigger) if isinstance(drift_trigger, str): drift_trigger = [drift_trigger] @@ -124,55 +134,59 @@ def __init__(self, params, stim_fs, n_channels=2, trigger_duration=0.01, drift_trigger = list(drift_trigger) # make mutable for trig in drift_trigger: if isinstance(trig, str): - assert trig == 'end', trig + assert trig == "end", trig else: assert isinstance(trig, (int, float)), type(trig) self._drift_trigger_time = drift_trigger assert self._n_channels_stim >= 0 self._n_channels = int(operator.index(n_channels)) del n_channels - insertion = str(params['SOUND_CARD_TRIGGER_INSERTION']) - if insertion not in ('prepend', 'append'): - raise ValueError('SOUND_CARD_TRIGGER_INSERTION must be "prepend" ' - 'or "append", got %r' % (insertion,)) - self._stim_sl = slice(None, None, 1 if insertion == 'prepend' else -1) - extra = '' + insertion = str(params["SOUND_CARD_TRIGGER_INSERTION"]) + if insertion not in ("prepend", "append"): + raise ValueError( + 'SOUND_CARD_TRIGGER_INSERTION must be "prepend" ' + 'or "append", got %r' % (insertion,) + ) + self._stim_sl = slice(None, None, 1 if insertion == "prepend" else -1) + extra = "" if self._n_channels_stim: - extra = ('%d %sed stim and ' - % (self._n_channels_stim, insertion)) + extra = "%d %sed stim and " % (self._n_channels_stim, insertion) else: - extra = '' + extra = "" del insertion - logger.info('Expyfun: Setting up sound card using %s backend with %s' - '%d playback channels' - % (self.backend_name, extra, self._n_channels)) - self._kwargs = {key: params['SOUND_CARD_' + key.upper()] for key in ( - 'fs', 'api', 'name', 'fixed_delay', 'api_options')} + logger.info( + "Expyfun: Setting up sound card using %s backend with %s" + "%d playback channels" % (self.backend_name, extra, self._n_channels) + ) + self._kwargs = { + key: params["SOUND_CARD_" + key.upper()] + for key in ("fs", "api", "name", "fixed_delay", "api_options") + } temp_sound = np.zeros((self._n_channels_tot, 1000)) temp_sound = self.backend.SoundPlayer(temp_sound, **self._kwargs) self.fs = float(temp_sound.fs) - self._mixer = getattr(temp_sound, '_mixer', None) + self._mixer = getattr(temp_sound, "_mixer", None) del temp_sound # Need to generate at RMS=1 to match TDT circuit, and use a power of # 2 length for the RingBuffer (here make it >= 15 sec) - n_samples = 2 ** int(np.ceil(np.log2(self.fs * 15.))) + n_samples = 2 ** int(np.ceil(np.log2(self.fs * 15.0))) noise = np.random.normal(0, 1.0, (self._n_channels, n_samples)) # Low-pass if necessary if stim_fs < self.fs: # note we can use cheap DFT method here b/c # circular convolution won't matter for AWGN (yay!) - freqs = rfftfreq(noise.shape[-1], 1. / self.fs) + freqs = rfftfreq(noise.shape[-1], 1.0 / self.fs) noise = rfft(noise, axis=-1) - noise[:, np.abs(freqs) > stim_fs / 2.] = 0.0 + noise[:, np.abs(freqs) > stim_fs / 2.0] = 0.0 noise = irfft(noise, axis=-1) # ensure true RMS of 1.0 (DFT method also lowers RMS, compensate here) noise /= np.sqrt(np.mean(noise * noise)) noise = np.concatenate( - (np.zeros((self._n_channels_stim, noise.shape[1]), noise.dtype), - noise)) + (np.zeros((self._n_channels_stim, noise.shape[1]), noise.dtype), noise) + ) self.noise_array = noise self.noise_level = 0.01 self.noise = None @@ -183,8 +197,10 @@ def __init__(self, params, stim_fs, n_channels=2, trigger_duration=0.01, flush_logger() def __repr__(self): - return ('' - % (self._n_channels, self._n_channels_stim)) + return "" % ( + self._n_channels, + self._n_channels_stim, + ) @property def _n_channels_tot(self): @@ -194,7 +210,8 @@ def start_noise(self): """Start noise.""" if not self._noise_playing: self.noise = self.backend.SoundPlayer( - self.noise_array * self.noise_level, loop=True, **self._kwargs) + self.noise_array * self.noise_level, loop=True, **self._kwargs + ) self.noise.play() def stop_noise(self, wait=False): @@ -235,46 +252,49 @@ def load_buffer(self, samples): sample_len = len(samples) extra = sample_len - stim_len if extra > 0: # stim shorter than samples (typical) - stim = np.pad(stim, ((0, extra), (0, 0)), 'constant') + stim = np.pad(stim, ((0, extra), (0, 0)), "constant") elif extra < 0: # samples shorter than stim (very brief stim) - samples = np.pad(samples, ((0, -extra), (0, 0)), 'constant') + samples = np.pad(samples, ((0, -extra), (0, 0)), "constant") # place the drift triggers trig2 = self._make_digital_trigger([2]) trig2_len = trig2.shape[0] trig2_starts = [] for trig2_time in self._drift_trigger_time: - if trig2_time == 'end': + if trig2_time == "end": stim[-trig2_len:] = np.bitwise_or(stim[-trig2_len:], trig2) - trig2_starts += [sample_len-trig2_len] + trig2_starts += [sample_len - trig2_len] else: trig2_start = int(np.round(trig2_time * self.fs)) - if ((trig2_start >= 0 and trig2_start <= stim_len) or - (trig2_start < 0 and abs(trig2_start) >= extra)): - warnings.warn('Drift triggers overlap' - ' with onset triggers.') - if ((trig2_start > 0 and - trig2_start > sample_len-trig2_len) or - (trig2_start < 0 and - abs(trig2_start) >= sample_len)): - warnings.warn('Drift trigger at {} seconds occurs' - ' outside stimulus window, ' - 'not stamping ' - 'trigger.'.format(trig2_time)) + if (trig2_start >= 0 and trig2_start <= stim_len) or ( + trig2_start < 0 and abs(trig2_start) >= extra + ): + warnings.warn("Drift triggers overlap" " with onset triggers.") + if (trig2_start > 0 and trig2_start > sample_len - trig2_len) or ( + trig2_start < 0 and abs(trig2_start) >= sample_len + ): + warnings.warn( + f"Drift trigger at {trig2_time} seconds occurs" + " outside stimulus window, " + "not stamping " + "trigger." + ) continue - stim[trig2_start:trig2_start+trig2_len] = \ - np.bitwise_or(stim[trig2_start:trig2_start+trig2_len], - trig2) + stim[trig2_start : trig2_start + trig2_len] = np.bitwise_or( + stim[trig2_start : trig2_start + trig2_len], trig2 + ) if trig2_start > 0: trig2_starts += [trig2_start] else: trig2_starts += [sample_len + trig2_start] if np.any(np.diff(trig2_starts) < trig2_len): - warnings.warn('Some 2-triggers overlap, times should be at ' - 'least {} seconds apart.'.format(trig2_len / - self.fs)) - self.ec.write_data_line('Drift triggers were stamped at the ' - 'folowing times: ', - str([t2s/self.fs for t2s in trig2_starts])) + warnings.warn( + "Some 2-triggers overlap, times should be at " + f"least {trig2_len / self.fs} seconds apart." + ) + self.ec.write_data_line( + "Drift triggers were stamped at the following times: ", + str([t2s / self.fs for t2s in trig2_starts]), + ) stim = self._scale_digital_trigger(stim) samples = np.concatenate((stim, samples)[self._stim_sl], axis=1) self.audio = self.backend.SoundPlayer(samples.T, **self._kwargs) @@ -317,16 +337,16 @@ def _make_digital_trigger(self, trigs, delay=None): stim = np.zeros((n_samples, self._n_channels_stim), np.int32) offset = 0 for trig in trigs: - stim[offset:offset + n_on] = trig + stim[offset : offset + n_on] = trig offset += n_each return stim def _scale_digital_trigger(self, triggers): - return ((triggers << 8) * - self._trig_scale).astype(np.float32) + return ((triggers << 8) * self._trig_scale).astype(np.float32) - def stamp_triggers(self, triggers, delay=None, wait_for_last=True, - is_trial_id=False): + 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 @@ -350,8 +370,7 @@ def stamp_triggers(self, triggers, delay=None, wait_for_last=True, delay = 2 * self._trigger_duration stim = self._make_digital_trigger(triggers, delay) stim = self._scale_digital_trigger(stim) - stim = np.pad( - stim, ((0, 0), (0, self._n_channels)[self._stim_sl]), 'constant') + stim = np.pad(stim, ((0, 0), (0, self._n_channels)[self._stim_sl]), "constant") stim = self.backend.SoundPlayer(stim.T, **self._kwargs) stim.play() t_each = self._trigger_duration + delay @@ -404,13 +423,13 @@ def halt(self): """Halt.""" self.stop(wait=True) self.stop_noise(wait=True) - abort_all = getattr(self.backend, '_abort_all_queues', lambda: None) + abort_all = getattr(self.backend, "_abort_all_queues", lambda: None) abort_all() def _import_backend(backend): # Auto mode is special, will loop through all possible backends - if backend == 'auto': + if backend == "auto": backends = list() for backend in _BACKENDS: try: @@ -419,21 +438,21 @@ def _import_backend(backend): pass backends = sorted([backend._PRIORITY, backend] for backend in backends) if len(backends) == 0: - raise RuntimeError('Could not load any sound backend: %s' - % (_BACKENDS,)) + raise RuntimeError("Could not load any sound backend: %s" % (_BACKENDS,)) backend = op.splitext(op.basename(backends[0][1].__file__))[0][1:] if backend not in _BACKENDS: - raise ValueError('Unknown sound card backend %r, must be one of %s' - % (backend, ('auto',) + _BACKENDS)) - lib = importlib.import_module('._' + backend, - package='expyfun._sound_controllers') + raise ValueError( + "Unknown sound card backend %r, must be one of %s" + % (backend, ("auto",) + _BACKENDS) + ) + lib = importlib.import_module("._" + backend, package="expyfun._sound_controllers") return lib, backend -class SoundPlayer(object): +class SoundPlayer: """Play sounds via the sound card.""" def __new__(self, data, **kwargs): """Create a new instance.""" - backend = kwargs.pop('backend', 'auto') + backend = kwargs.pop("backend", "auto") return _import_backend(backend)[0].SoundPlayer(data, **kwargs) diff --git a/expyfun/_tdt_controller.py b/expyfun/_tdt_controller.py index 89ac7276..859e3479 100644 --- a/expyfun/_tdt_controller.py +++ b/expyfun/_tdt_controller.py @@ -7,31 +7,41 @@ # License: BSD (3-clause) import time -import numpy as np -from os import path as op -from functools import partial import warnings +from functools import partial +from os import path as op + +import numpy as np -from ._utils import _check_params, logger, ZeroClock from ._input_controllers import Keyboard +from ._utils import ZeroClock, _check_params, logger def _dummy_fun(self, name, ret, *args, **kwargs): - logger.info('dummy-tdt: {0} {1}'.format(name, str(args)[:20] + ' ... ' + - str(kwargs)[:20] + ' ...')) + logger.info( + "dummy-tdt: {0} {1}".format( + name, str(args)[:20] + " ... " + str(kwargs)[:20] + " ..." + ) + ) return ret -class DummyRPcoX(object): +class DummyRPcoX: """Dummy RPcoX.""" def __init__(self, model, interface): self.model = model self.interface = interface - names = ['LoadCOF', 'ClearCOF', 'Run', 'ZeroTag', 'SetTagVal', - 'GetSFreq', 'Halt'] - returns = [True, True, True, True, True, - 24414.0125, True] + names = [ + "LoadCOF", + "ClearCOF", + "Run", + "ZeroTag", + "SetTagVal", + "GetSFreq", + "Halt", + ] + returns = [True, True, True, True, True, 24414.0125, True] for name, ret in zip(names, returns): setattr(self, name, partial(_dummy_fun, self, name, ret)) self._clock = ZeroClock() @@ -40,7 +50,7 @@ def __init__(self, model, interface): def WriteTagVEX(self, name, offset, kind, data): """Write tag data.""" - if name == 'datainleft': + if name == "datainleft": self._stim_dur = len(data) / self.GetSFreq() return True @@ -54,14 +64,14 @@ def SoftTrg(self, trignum): def GetTagVal(self, name): """Get a tag value.""" - if name == 'masterclock': + if name == "masterclock": return self._clock.get_time() - elif name == 'npressabs': + elif name == "npressabs": return 0 - elif name == 'playing': - return (time.time() - self._play_start < self._stim_dur) + elif name == "playing": + return time.time() - self._play_start < self._stim_dur else: - raise ValueError('unknown tag "{0}"'.format(name)) + raise ValueError(f'unknown tag "{name}"') class TDTController(Keyboard): # lgtm [py/missing-call-to-init] @@ -100,36 +110,49 @@ class TDTController(Keyboard): # lgtm [py/missing-call-to-init] def __init__(self, tdt_params, ec): self.ec = ec - defaults = dict(TDT_MODEL='dummy', TDT_DELAY='0', TDT_TRIG_DELAY='0', - TYPE='tdt') # if not listed -> None - keys = ['TYPE', 'TDT_MODEL', 'TDT_CIRCUIT_PATH', 'TDT_INTERFACE', - 'TDT_DELAY', 'TDT_TRIG_DELAY'] - tdt_params = _check_params(tdt_params, keys, defaults, 'tdt_params') - if tdt_params['TYPE'] != 'tdt': - raise ValueError('tdt_params["TYPE"] must be "tdt", not ' - '{0}'.format(tdt_params['TYPE'])) - for key in ('TDT_DELAY', 'TDT_TRIG_DELAY'): + defaults = dict( + TDT_MODEL="dummy", TDT_DELAY="0", TDT_TRIG_DELAY="0", TYPE="tdt" + ) # if not listed -> None + keys = [ + "TYPE", + "TDT_MODEL", + "TDT_CIRCUIT_PATH", + "TDT_INTERFACE", + "TDT_DELAY", + "TDT_TRIG_DELAY", + ] + tdt_params = _check_params(tdt_params, keys, defaults, "tdt_params") + if tdt_params["TYPE"] != "tdt": + raise ValueError( + 'tdt_params["TYPE"] must be "tdt", not ' "{0}".format( + tdt_params["TYPE"] + ) + ) + for key in ("TDT_DELAY", "TDT_TRIG_DELAY"): tdt_params[key] = int(tdt_params[key]) - if tdt_params['TDT_DELAY'] < 0: - raise ValueError('tdt_delay must be non-negative.') - self._model = tdt_params['TDT_MODEL'] - legal_models = ['RM1', 'RP2', 'RZ6', 'RP2legacy', 'dummy'] + if tdt_params["TDT_DELAY"] < 0: + raise ValueError("tdt_delay must be non-negative.") + self._model = tdt_params["TDT_MODEL"] + legal_models = ["RM1", "RP2", "RZ6", "RP2legacy", "dummy"] if self.model not in legal_models: - raise ValueError('TDT_MODEL="{0}" must be one of ' - '{1}'.format(self.model, legal_models)) - - if tdt_params['TDT_CIRCUIT_PATH'] is None and self.model != 'dummy': - cl = dict(RM1='RM1', RP2='RM1', RP2legacy='RP2legacy', RZ6='RZ6') - self._circuit = op.join(op.dirname(__file__), 'data', - 'expCircuitF32_' + cl[self._model] + - '.rcx') + raise ValueError( + f'TDT_MODEL="{self.model}" must be one of ' f"{legal_models}" + ) + + if tdt_params["TDT_CIRCUIT_PATH"] is None and self.model != "dummy": + cl = dict(RM1="RM1", RP2="RM1", RP2legacy="RP2legacy", RZ6="RZ6") + self._circuit = op.join( + op.dirname(__file__), + "data", + "expCircuitF32_" + cl[self._model] + ".rcx", + ) else: - self._circuit = tdt_params['TDT_CIRCUIT_PATH'] - if self.model != 'dummy' and not op.isfile(self._circuit): - raise IOError('Could not find file {}'.format(self._circuit)) - if tdt_params['TDT_INTERFACE'] is None: - tdt_params['TDT_INTERFACE'] = 'USB' - self._interface = tdt_params['TDT_INTERFACE'] + self._circuit = tdt_params["TDT_CIRCUIT_PATH"] + if self.model != "dummy" and not op.isfile(self._circuit): + raise OSError(f"Could not find file {self._circuit}") + if tdt_params["TDT_INTERFACE"] is None: + tdt_params["TDT_INTERFACE"] = "USB" + self._interface = tdt_params["TDT_INTERFACE"] self._n_channels = 2 # initialize RPcoX connection @@ -143,28 +166,33 @@ def __init__(self, tdt_params, ec): self.connection = self.rpcox.ConnectRM1(IntName=interface, DevNum=1) """ # MID-LEVEL APPROACH - if tdt_params['TDT_MODEL'] != 'dummy': + if tdt_params["TDT_MODEL"] != "dummy": from tdt.util import connect_rpcox - use_model = self.model if self.model != 'RP2legacy' else 'RP2' + + use_model = self.model if self.model != "RP2legacy" else "RP2" try: - self.rpcox = connect_rpcox(name=use_model, - interface=self.interface, - device_id=1, address=None) + self.rpcox = connect_rpcox( + name=use_model, interface=self.interface, device_id=1, address=None + ) except Exception as exp: - raise OSError('Could not connect to {}, is it turned on? ' - '(TDT message: "{}")'.format(self._model, exp)) + raise OSError( + f"Could not connect to {self._model}, is it turned on? " + f'(TDT message: "{exp}")' + ) else: - msg = ('TDT is in dummy mode. No sound or triggers will ' - 'be produced. Check TDT configuration and TDTpy ' - 'installation.') + msg = ( + "TDT is in dummy mode. No sound or triggers will " + "be produced. Check TDT configuration and TDTpy " + "installation." + ) logger.warning(msg) # log it warnings.warn(msg) # make it red self.rpcox = DummyRPcoX(self._model, self._interface) if self.rpcox is not None: - logger.info('Expyfun: RPcoX connection established') + logger.info("Expyfun: RPcoX connection established") else: - raise IOError('Problem initializing RPcoX.') + raise OSError("Problem initializing RPcoX.") """ # start zBUS (may be needed for devices other than RM1) self.zbus = connect_zbus(interface=interface) @@ -175,30 +203,31 @@ def __init__(self, tdt_params, ec): """ # load circuit if not self.rpcox.LoadCOF(self.circuit): - logger.debug('Expyfun: Problem loading circuit. Clearing...') + logger.debug("Expyfun: Problem loading circuit. Clearing...") try: if self.rpcox.ClearCOF(): - logger.debug('Expyfun: TDT circuit cleared') + logger.debug("Expyfun: TDT circuit cleared") time.sleep(0.25) if not self.rpcox.LoadCOF(self.circuit): - raise RuntimeError('Second loading attempt failed') + raise RuntimeError("Second loading attempt failed") except Exception: - raise IOError('Expyfun: Problem loading circuit.') - logger.info('Expyfun: Circuit loaded to {1} via {2}:\n{0}' - ''.format(self.circuit, self.model, self.interface)) + raise OSError("Expyfun: Problem loading circuit.") + logger.info( + f"Expyfun: Circuit loaded to {self.model} via {self.interface}:\n" + f"{self.circuit}" + ) # run circuit if self.rpcox.Run(): - logger.info('Expyfun: TDT circuit running') + logger.info("Expyfun: TDT circuit running") else: - raise SystemError('Expyfun: Problem starting TDT circuit.') + raise SystemError("Expyfun: Problem starting TDT circuit.") time.sleep(0.25) self._set_noise_corr() - self._set_delay(tdt_params['TDT_DELAY'], - tdt_params['TDT_TRIG_DELAY']) + self._set_delay(tdt_params["TDT_DELAY"], tdt_params["TDT_TRIG_DELAY"]) # Set output values to zero (esp. first few) - for tag in ('datainleft', 'datainright'): + for tag in ("datainleft", "datainright"): self.rpcox.ZeroTag(tag) - self.rpcox.SetTagVal('trgname', 0) + self.rpcox.SetTagVal("trgname", 0) self._used_params = tdt_params def _add_keyboard_init(self, ec, force_quit_keys): @@ -206,11 +235,11 @@ def _add_keyboard_init(self, ec, force_quit_keys): # do BaseKeyboard init last, to make sure circuit is running Keyboard.__init__(self, ec, force_quit_keys) -# ############################### AUDIO METHODS ############################### + # ############################### AUDIO METHODS ############################### def _set_noise_corr(self, val=0): """Helper to set the noise correlation, only -1, 0, 1 supported""" assert val in (-1, 0, 1) - self.rpcox.SetTagVal('noise_corr', int(val)) + self.rpcox.SetTagVal("noise_corr", int(val)) def load_buffer(self, data): """Load audio samples into TDT buffer. @@ -223,21 +252,20 @@ def load_buffer(self, data): """ assert data.dtype == np.float32 # Leave the first sample zero so on reset the output goes to zero - self.rpcox.WriteTagVEX('datainleft', 1, 'F32', data[:, 0]) - self.rpcox.WriteTagVEX('datainright', 1, 'F32', data[:, 1]) - self.rpcox.SetTagVal('nsamples', max(data.shape[0] + 1, 1)) + self.rpcox.WriteTagVEX("datainleft", 1, "F32", data[:, 0]) + self.rpcox.WriteTagVEX("datainright", 1, "F32", data[:, 1]) + self.rpcox.SetTagVal("nsamples", max(data.shape[0] + 1, 1)) def play(self): - """Send the soft trigger to start the ring buffer playback. - """ - self.rpcox.SetTagVal('trgname', 1) + """Send the soft trigger to start the ring buffer playback.""" + self.rpcox.SetTagVal("trgname", 1) self._trigger(1) - logger.debug('Expyfun: Starting TDT ring buffer') + logger.debug("Expyfun: Starting TDT ring buffer") @property def playing(self): """Is a sound currently playing""" - return bool(int(self.rpcox.GetTagVal('playing'))) + return bool(int(self.rpcox.GetTagVal("playing"))) def stop(self, wait=True): """Send the soft trigger to stop and reset the ring buffer playback. @@ -248,13 +276,12 @@ def stop(self, wait=True): Unused by the TDT. """ self._trigger(2) - logger.debug('Expyfun: Stopping TDT audio') + logger.debug("Expyfun: Stopping TDT audio") def start_noise(self): - """Send the soft trigger to start the noise generator. - """ + """Send the soft trigger to start the noise generator.""" self._trigger(3) - logger.debug('Expyfun: Starting TDT noise') + logger.debug("Expyfun: Starting TDT noise") def stop_noise(self, wait=True): """Send the soft trigger to stop the noise generator. @@ -265,7 +292,7 @@ def stop_noise(self, wait=True): Unused by the TDT. """ self._trigger(4) - logger.debug('Expyfun: Stopping TDT noise') + logger.debug("Expyfun: Stopping TDT noise") def set_noise_level(self, level): """Set the noise level. @@ -275,21 +302,21 @@ def set_noise_level(self, level): level : float The new level. """ - self.rpcox.SetTagVal('noiselev', level) + self.rpcox.SetTagVal("noiselev", level) def _set_delay(self, delay, delay_trig): - """Set the delay (in ms) of the system - """ + """Set the delay (in ms) of the system""" assert isinstance(delay, int) # this should never happen assert isinstance(delay_trig, int) - self.rpcox.SetTagVal('onsetdel', delay) - logger.info('Expyfun: Setting TDT delay to %s' % delay) - self.rpcox.SetTagVal('trigdel', 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, - is_trial_id=False): + self.rpcox.SetTagVal("onsetdel", delay) + logger.info("Expyfun: Setting TDT delay to %s" % delay) + self.rpcox.SetTagVal("trigdel", 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, is_trial_id=False + ): """Stamp a list of triggers with a given inter-trigger delay. Parameters @@ -308,7 +335,7 @@ def stamp_triggers(self, triggers, delay=None, wait_for_last=True, if delay is None: delay = 0.02 # we have a fixed trig duration of 0.01 for ti, trig in enumerate(triggers): - self.rpcox.SetTagVal('trgname', trig) + self.rpcox.SetTagVal("trgname", trig) self._trigger(6) if ti < len(triggers) - 1 or wait_for_last: self.ec.wait_secs(delay) @@ -322,35 +349,34 @@ def _trigger(self, trig): Trigger number to send to TDT. """ if not self.rpcox.SoftTrg(trig): - logger.warning('SoftTrg failure for trigger: {}'.format(trig)) + logger.warning(f"SoftTrg failure for trigger: {trig}") -# ############################### KEYBOARD METHODS ############################ + # ############################### KEYBOARD METHODS ############################ def _get_timebase(self): - """Return time since circuit was started (in seconds). - """ - return self.rpcox.GetTagVal('masterclock') / float(self.fs) + """Return time since circuit was started (in seconds).""" + return self.rpcox.GetTagVal("masterclock") / float(self.fs) def _clear_events(self): - """Clear keyboard buffers. - """ + """Clear keyboard buffers.""" self._trigger(7) self._clear_keyboard_events() - def _retrieve_events(self, live_keys, type='presses'): - """Values and timestamps currently in keyboard buffer. - """ - if type != 'presses': + def _retrieve_events(self, live_keys, type="presses"): # noqa: A002 + """Values and timestamps currently in keyboard buffer.""" + if type != "presses": raise RuntimeError("TDT Cannot get key release events") # get values from the tdt - press_count = int(round(self.rpcox.GetTagVal('npressabs'))) + press_count = int(round(self.rpcox.GetTagVal("npressabs"))) if press_count > 0: # this one is indexed from zero - press_times = self.rpcox.ReadTagVEX('presstimesabs', 0, - press_count, 'I32', 'I32', 1) + press_times = self.rpcox.ReadTagVEX( + "presstimesabs", 0, press_count, "I32", "I32", 1 + ) # this one is indexed from one (silly) - press_vals = self.rpcox.ReadTagVEX('pressvalsabs', 1, press_count, - 'I32', 'I32', 1) + press_vals = self.rpcox.ReadTagVEX( + "pressvalsabs", 1, press_count, "I32", "I32", 1 + ) press_times = np.array(press_times[0], float) / self.fs press_vals = np.log2(np.array(press_vals[0], float)) + 1 press_vals = [str(int(round(p))) for p in press_vals] @@ -361,7 +387,7 @@ def _retrieve_events(self, live_keys, type='presses'): presses.extend(self._retrieve_keyboard_events([])) return presses - def _correct_presses(self, events, timestamp, relative_to, kind='presses'): + def _correct_presses(self, events, timestamp, relative_to, kind="presses"): """Correct timing of presses and check for quit press""" events = [(k, s + self.time_correction, kind) for k, s in events] self.log_presses(events) @@ -376,9 +402,9 @@ def _correct_presses(self, events, timestamp, relative_to, kind='presses'): def halt(self): """Wrapper for tdt.util.RPcoX.Halt().""" self.rpcox.Halt() - logger.debug('Expyfun: Halting TDT circuit') + logger.debug("Expyfun: Halting TDT circuit") -# ############################ READ-ONLY PROPERTIES ########################### + # ############################ READ-ONLY PROPERTIES ########################### @property def fs(self): """Playback frequency of the audio (samples / second).""" @@ -408,5 +434,11 @@ def get_tdt_rates(): rates : dict The sample rates. """ - return {'6k': 6103.515625, '12k': 12207.03125, '25k': 24414.0625, - '50k': 48828.125, '100k': 97656.25, '200k': 195312.5} + return { + "6k": 6103.515625, + "12k": 12207.03125, + "25k": 24414.0625, + "50k": 48828.125, + "100k": 97656.25, + "200k": 195312.5, + } diff --git a/expyfun/_trigger_controllers.py b/expyfun/_trigger_controllers.py index b735e846..24d43c98 100644 --- a/expyfun/_trigger_controllers.py +++ b/expyfun/_trigger_controllers.py @@ -6,12 +6,13 @@ # License: BSD (3-clause) import sys + import numpy as np -from ._utils import verbose_dec, string_types, logger +from ._utils import logger, verbose_dec -class ParallelTrigger(object): +class ParallelTrigger: """Parallel port and dummy triggering support. .. warning:: When using the parallel port, calling @@ -46,34 +47,42 @@ class ParallelTrigger(object): """ @verbose_dec - def __init__(self, mode='dummy', address=None, trigger_duration=0.01, - ec=None, verbose=None): + def __init__( + self, mode="dummy", address=None, trigger_duration=0.01, ec=None, verbose=None + ): self.ec = ec - if mode == 'parallel': - if sys.platform.startswith('linux'): - address = '/dev/parport0' if address is None else address - if not isinstance(address, string_types): - raise ValueError('addrss must be a string or None, got %s ' - 'of type %s' % (address, type(address))) + if mode == "parallel": + if sys.platform.startswith("linux"): + address = "/dev/parport0" if address is None else address + if not isinstance(address, str): + raise ValueError( + "address must be a string or None, got %s " + "of type %s" % (address, type(address)) + ) from parallel import Parallel - logger.info('Expyfun: Using address %s' % (address,)) + + logger.info("Expyfun: Using address %s" % (address,)) self._port = Parallel(address) self._portname = address self._set_data = self._port.setData - elif sys.platform.startswith('win'): + elif sys.platform.startswith("win"): from ctypes import windll - if not hasattr(windll, 'inpout32'): + + if not hasattr(windll, "inpout32"): raise SystemError( - 'Must have inpout32 installed, see:\n\n' - 'http://www.highrez.co.uk/downloads/inpout32/') + "Must have inpout32 installed, see:\n\n" + "http://www.highrez.co.uk/downloads/inpout32/" + ) - base = '0x378' if address is None else address - logger.info('Expyfun: Using base address %s' % (base,)) - if isinstance(base, string_types): + base = "0x378" if address is None else address + logger.info("Expyfun: Using base address %s" % (base,)) + if isinstance(base, str): base = int(base, 16) if not isinstance(base, int): - raise ValueError('address must be int or None, got %s of ' - 'type %s' % (base, type(base))) + raise ValueError( + "address must be int or None, got %s of " + "type %s" % (base, type(base)) + ) self._port = windll.inpout32 mask = np.uint8(1 << 5 | 1 << 6 | 1 << 7) # Use ECP to put the port into byte mode @@ -87,18 +96,20 @@ def __init__(self, mode='dummy', address=None, trigger_duration=0.01, self._set_data = lambda data: self._port.Out32(base, data) self._portname = str(base) else: - raise NotImplementedError('Parallel port triggering only ' - 'supported on Linux and Windows') + raise NotImplementedError( + "Parallel port triggering only " "supported on Linux and Windows" + ) else: # mode == 'dummy': self._port = self._portname = None self._trigger_list = list() - self._set_data = lambda x: (self._trigger_list.append(x) - if x != 0 else None) + self._set_data = lambda x: ( + self._trigger_list.append(x) if x != 0 else None + ) self.trigger_duration = trigger_duration self.mode = mode def __repr__(self): - return '' % (self.mode, self._portname) + return "" % (self.mode, self._portname) def _stamp_trigger(self, trig): """Fake stamping.""" @@ -106,8 +117,9 @@ 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, - is_trial_id=False): + 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 @@ -132,7 +144,7 @@ def stamp_triggers(self, triggers, delay=None, wait_for_last=True, def close(self): """Release hardware interfaces.""" - if hasattr(self, '_port'): + if hasattr(self, "_port"): del self._port def __del__(self): @@ -160,17 +172,16 @@ def decimals_to_binary(decimals, n_bits): """ decimals = np.array(decimals, int) if decimals.ndim != 1 or (decimals < 0).any(): - raise ValueError('decimals must be 1D with all nonnegative values') + raise ValueError("decimals must be 1D with all nonnegative values") n_bits = np.array(n_bits, int) if decimals.shape != n_bits.shape: - raise ValueError('n_bits must have same shape as decimals') + raise ValueError("n_bits must have same shape as decimals") if (n_bits <= 0).any(): - raise ValueError('all n_bits must be positive') + raise ValueError("all n_bits must be positive") binary = list() for d, b in zip(decimals, n_bits): - if d > 2 ** b - 1: - raise ValueError('cannot convert number {0} using {1} bits' - ''.format(d, b)) + if d > 2**b - 1: + raise ValueError(f"cannot convert number {d} using {b} bits" "") binary.extend([int(bb) for bb in np.binary_repr(d, b)]) assert len(binary) == n_bits.sum() # make sure we didn't do something dumb return binary @@ -192,21 +203,23 @@ def binary_to_decimals(binary, n_bits): Array of integers. """ if not np.array_equal(binary, np.array(binary, bool)): - raise ValueError('binary must only contain zeros and ones') + raise ValueError("binary must only contain zeros and ones") binary = np.array(binary, bool) if binary.ndim != 1: - raise ValueError('binary must be 1 dimensional') + raise ValueError("binary must be 1 dimensional") n_bits = np.atleast_1d(n_bits).astype(int) if np.any(n_bits <= 0): - raise ValueError('n_bits must all be > 0') + raise ValueError("n_bits must all be > 0") if n_bits.sum() != len(binary): - raise ValueError('the sum of n_bits must be equal to the number of ' - 'elements in binary') + raise ValueError( + "the sum of n_bits must be equal to the number of " "elements in binary" + ) offset = 0 outs = [] for nb in n_bits: - outs.append(np.sum(binary[offset:offset + nb] * - (2 ** np.arange(nb - 1, -1, -1)))) + outs.append( + np.sum(binary[offset : offset + nb] * (2 ** np.arange(nb - 1, -1, -1))) + ) offset += nb assert offset == len(binary) return np.array(outs) diff --git a/expyfun/_utils.py b/expyfun/_utils.py index f31c51b7..d40908a7 100644 --- a/expyfun/_utils.py +++ b/expyfun/_utils.py @@ -4,63 +4,48 @@ # # License: BSD (3-clause) -import warnings -import operator -from copy import deepcopy -import subprocess +import atexit +import datetime import importlib +import inspect +import json +import logging +import operator import os import os.path as op -import inspect +import ssl +import subprocess import sys -import time import tempfile +import time import traceback -import ssl -from shutil import rmtree -import atexit -import json +import warnings +from copy import deepcopy from functools import partial -import logging -import datetime -from timeit import default_timer as clock +from shutil import rmtree from threading import Timer +from timeit import default_timer as clock +from urllib.request import urlopen import numpy as np import scipy as sp - -from ._externals import decorator +from decorator import decorator # set this first thing to make sure it "takes" try: import pyglet - pyglet.options['debug_gl'] = False + + pyglet.options["debug_gl"] = False del pyglet except Exception: pass -# for py3k (eventually) -if sys.version.startswith('2'): - string_types = basestring # noqa - input = raw_input # noqa, input is raw_input in py3k - text_type = unicode # noqa - from __builtin__ import reload - from urllib2 import urlopen # noqa - from cStringIO import StringIO # noqa -else: - string_types = str - text_type = str - from urllib.request import urlopen - input = input - from io import StringIO # noqa, analysis:ignore - from importlib import reload # noqa, analysis:ignore - ############################################################################### # LOGGING EXP = 25 -logging.addLevelName(EXP, 'EXP') +logging.addLevelName(EXP, "EXP") def exp(self, message, *args, **kwargs): @@ -69,7 +54,7 @@ def exp(self, message, *args, **kwargs): logging.Logger.exp = exp -logger = logging.getLogger('expyfun') +logger = logging.getLogger("expyfun") def flush_logger(): @@ -94,26 +79,32 @@ def set_log_level(verbose=None, return_old_level=False): If True, return the old verbosity level. """ if verbose is None: - verbose = get_config('EXPYFUN_LOGGING_LEVEL', 'INFO') + verbose = get_config("EXPYFUN_LOGGING_LEVEL", "INFO") elif isinstance(verbose, bool): - verbose = 'INFO' if verbose is True else 'WARNING' - if isinstance(verbose, string_types): + verbose = "INFO" if verbose is True else "WARNING" + if isinstance(verbose, str): verbose = verbose.upper() - logging_types = dict(DEBUG=logging.DEBUG, INFO=logging.INFO, - WARNING=logging.WARNING, ERROR=logging.ERROR, - CRITICAL=logging.CRITICAL) + logging_types = dict( + DEBUG=logging.DEBUG, + INFO=logging.INFO, + WARNING=logging.WARNING, + ERROR=logging.ERROR, + CRITICAL=logging.CRITICAL, + ) if verbose not in logging_types: - raise ValueError('verbose must be of a valid type') + raise ValueError("verbose must be of a valid type") verbose = logging_types[verbose] old_verbose = logger.level logger.setLevel(verbose) - return (old_verbose if return_old_level else None) + return old_verbose if return_old_level else None -def set_log_file(fname=None, - output_format='%(asctime)s - %(levelname)-7s - %(message)s', - overwrite=None): +def set_log_file( + fname=None, + output_format="%(asctime)s - %(levelname)-7s - %(message)s", + overwrite=None, +): """Convenience function for setting the log to print to a file Parameters @@ -138,10 +129,12 @@ def set_log_file(fname=None, logger.removeHandler(h) if fname is not None: if op.isfile(fname) and overwrite is None: - warnings.warn('Log entries will be appended to the file. Use ' - 'overwrite=False to avoid this message in the ' - 'future.') - mode = 'w' if overwrite is True else 'a' + warnings.warn( + "Log entries will be appended to the file. Use " + "overwrite=False to avoid this message in the " + "future." + ) + mode = "w" if overwrite is True else "a" lh = logging.FileHandler(fname, mode=mode) else: """ we should just be able to do: @@ -158,9 +151,10 @@ def set_log_file(fname=None, ############################################################################### # RANDOM UTILITIES -building_doc = any('sphinx-build' in ((''.join(i[4]).lower() + i[1]) - if i[4] is not None else '') - for i in inspect.stack()) +building_doc = any( + "sphinx-build" in (("".join(i[4]).lower() + i[1]) if i[4] is not None else "") + for i in inspect.stack() +) def run_subprocess(command, **kwargs): @@ -176,7 +170,7 @@ def run_subprocess(command, **kwargs): command : list of str Command to run as subprocess (see subprocess.Popen documentation). **kwargs : objects - Keywoard arguments to pass to ``subprocess.Popen``. + Keyword arguments to pass to ``subprocess.Popen``. Returns ------- @@ -198,7 +192,7 @@ def run_subprocess(command, **kwargs): ) if p.returncode: err_fun = subprocess.CalledProcessError.__init__ - if 'output' in _get_args(err_fun): + if "output" in _get_args(err_fun): raise subprocess.CalledProcessError(p.returncode, command, output) else: raise subprocess.CalledProcessError(p.returncode, command) @@ -206,7 +200,7 @@ def run_subprocess(command, **kwargs): return output -class ZeroClock(object): +class ZeroClock: """Clock that uses "clock" function but starts at zero on init.""" def __init__(self): @@ -225,10 +219,10 @@ def date_str(): datestr : str The date string. """ - return str(datetime.datetime.today()).replace(':', '_') + return str(datetime.datetime.today()).replace(":", "_") -class WrapStdOut(object): +class WrapStdOut: """Ridiculous class to work around how doctest captures stdout.""" def __getattr__(self, name): @@ -261,7 +255,7 @@ def __init__(self): def cleanup(self): if self._del_after is True: if self._print_del is True: - print('Deleting {} ...'.format(self._path)) + print(f"Deleting {self._path} ...") rmtree(self._path, ignore_errors=True) @@ -273,10 +267,9 @@ def check_units(units): units : str Must be ``'norm'``, ``'deg'``, ``'pix'``, or ``'cm'``. """ - good_units = ['norm', 'pix', 'deg', 'cm'] + good_units = ["norm", "pix", "deg", "cm"] if units not in good_units: - raise ValueError('"units" must be one of {}, not {}' - ''.format(good_units, units)) + raise ValueError(f'"units" must be one of {good_units}, not {units}' "") ############################################################################### @@ -284,7 +277,8 @@ def check_units(units): # Following deprecated class copied from scikit-learn -class deprecated(object): + +class deprecated: """Decorator to mark a function or class as deprecated. Issue a warning when the function is called/the class is instantiated and @@ -308,14 +302,8 @@ class deprecated(object): # scikit-learn will not import on all platforms b/c it can be # sklearn or scikits.learn, so a self-contained example is used above - def __init__(self, extra=''): - """ - Parameters - ---------- - extra: string - to be added to the deprecation messages - - """ + def __init__(self, extra=""): + # extra: string to be added to the deprecation messages self.extra = extra def __call__(self, obj): @@ -336,9 +324,10 @@ def _decorate_class(self, cls): def wrapped(*args, **kwargs): warnings.warn(msg, category=DeprecationWarning) return init(*args, **kwargs) + cls.__init__ = wrapped - wrapped.__name__ = '__init__' + wrapped.__name__ = "__init__" wrapped.__doc__ = self._update_doc(init.__doc__) wrapped.deprecated_original = init @@ -369,20 +358,28 @@ def _update_doc(self, olddoc): return newdoc -if hasattr(inspect, 'signature'): # py35 +if hasattr(inspect, "signature"): # py35 + def _get_args(function, varargs=False): params = inspect.signature(function).parameters - args = [key for key, param in params.items() - if param.kind not in (param.VAR_POSITIONAL, param.VAR_KEYWORD)] + args = [ + key + for key, param in params.items() + if param.kind not in (param.VAR_POSITIONAL, param.VAR_KEYWORD) + ] if varargs: - varargs = [param.name for param in params.values() - if param.kind == param.VAR_POSITIONAL] + varargs = [ + param.name + for param in params.values() + if param.kind == param.VAR_POSITIONAL + ] if len(varargs) == 0: varargs = None return args, varargs else: return args else: + def _get_args(function, varargs=False): out = inspect.getargspec(function) # args, varargs, keywords, defaults if varargs: @@ -410,13 +407,13 @@ def verbose_dec(function, *args, **kwargs): """ arg_names = _get_args(function) - if len(arg_names) > 0 and arg_names[0] == 'self': - default_level = getattr(args[0], 'verbose', None) + if len(arg_names) > 0 and arg_names[0] == "self": + default_level = getattr(args[0], "verbose", None) else: default_level = None - if 'verbose' in arg_names: - verbose_level = args[arg_names.index('verbose')] + if "verbose" in arg_names: + verbose_level = args[arg_names.index("verbose")] else: verbose_level = default_level @@ -437,7 +434,8 @@ def verbose_dec(function, *args, **kwargs): def _new_pyglet(): import pyglet - return _compare_version(pyglet.version, '>=', '1.4') + + return _compare_version(pyglet.version, ">=", "1.4") def _has_video(raise_error=False): @@ -451,7 +449,7 @@ def _has_video(raise_error=False): good = False else: if raise_error: - print('Found FFmpegSource for new Pyglet') + print("Found FFmpegSource for new Pyglet") else: try: from pyglet.media.avbin import AVbinSource # noqa @@ -464,60 +462,72 @@ def _has_video(raise_error=False): good = False else: if raise_error: - print('Found AVbinSource for old Pyglet 1') + print("Found AVbinSource for old Pyglet 1") else: if raise_error: - print('Found AVbinSource for old Pyglet 2') + print("Found AVbinSource for old Pyglet 2") if raise_error and not good: - raise RuntimeError('Video support not enabled, got exception(s):\n' - '\n***********************\n'.join(exceptions)) + raise RuntimeError( + "Video support not enabled, got exception(s):\n" + "\n***********************\n".join(exceptions) + ) return good def requires_video(): """Require FFmpeg/AVbin.""" import pytest - return pytest.mark.skipif(not _has_video(), reason='Requires FFmpeg/AVbin') + + return pytest.mark.skipif(not _has_video(), reason="Requires FFmpeg/AVbin") def requires_opengl21(func): """Require OpenGL.""" - import pytest import pyglet.gl + import pytest + vendor = pyglet.gl.gl_info.get_vendor() version = pyglet.gl.gl_info.get_version() sufficient = pyglet.gl.gl_info.have_version(2, 0) - return pytest.mark.skipif(not sufficient, - reason='OpenGL too old: %s %s' - % (vendor, version,))(func) + return pytest.mark.skipif( + not sufficient, + reason="OpenGL too old: %s %s" + % ( + vendor, + version, + ), + )(func) def requires_lib(lib): """Requires lib decorator.""" import pytest + try: importlib.import_module(lib) except Exception as exp: val = True - reason = 'Needs %s (%s)' % (lib, exp) + reason = "Needs %s (%s)" % (lib, exp) else: val = False - reason = '' + reason = "" return pytest.mark.skipif(val, reason=reason) def _has_scipy_version(version): - return _compare_version(sp.__version__, '>=', version) + return _compare_version(sp.__version__, ">=", version) def _get_user_home_path(): """Return standard preferences path""" # this has been checked on OSX64, Linux64, and Win32 - val = os.getenv('APPDATA' if 'nt' == os.name.lower() else 'HOME', None) + val = os.getenv("APPDATA" if "nt" == os.name.lower() else "HOME", None) if val is None: - raise ValueError('expyfun config file path could ' - 'not be determined, please report this ' - 'error to expyfun developers') + raise ValueError( + "expyfun config file path could " + "not be determined, please report this " + "error to expyfun developers" + ) return val @@ -535,13 +545,13 @@ def fetch_data_file(fname): fname : str The filename on the local system where the file was downloaded. """ - path = get_config('EXPYFUN_DATA_PATH', op.join(_get_user_home_path(), - '.expyfun', 'data')) + path = get_config( + "EXPYFUN_DATA_PATH", op.join(_get_user_home_path(), ".expyfun", "data") + ) fname_out = op.join(path, fname) if not op.isdir(op.dirname(fname_out)): os.makedirs(op.dirname(fname_out)) - fname_url = ('https://github.com/LABSN/expyfun-data/raw/master/{0}' - ''.format(fname)) + fname_url = f"https://github.com/LABSN/expyfun-data/raw/master/{fname}" "" try: # until we get proper certificates context = ssl._create_unverified_context() @@ -551,7 +561,7 @@ def fetch_data_file(fname): this_urlopen = urlopen if not op.isfile(fname_out): try: - with open(fname_out, 'wb') as fid: + with open(fname_out, "wb") as fid: www = this_urlopen(fname_url, timeout=30.0) try: fid.write(www.read()) @@ -573,40 +583,41 @@ def get_config_path(): will be '%APPDATA%\.expyfun\expyfun.json'. On every other system, this will be $HOME/.expyfun/expyfun.json. """ - val = op.join(_get_user_home_path(), '.expyfun', 'expyfun.json') + val = op.join(_get_user_home_path(), ".expyfun", "expyfun.json") return val # List the known configuration values -known_config_types = ('RESPONSE_DEVICE', - 'AUDIO_CONTROLLER', - 'DB_OF_SINE_AT_1KHZ_1RMS', - 'EXPYFUN_EYELINK', - 'SOUND_CARD_API', - 'SOUND_CARD_API_OPTIONS', - 'SOUND_CARD_BACKEND', - 'SOUND_CARD_FS', - 'SOUND_CARD_NAME', - 'SOUND_CARD_FIXED_DELAY', - 'SOUND_CARD_TRIGGER_CHANNELS', - 'SOUND_CARD_TRIGGER_INSERTION', - 'SOUND_CARD_TRIGGER_SCALE', - 'SOUND_CARD_TRIGGER_ID_AFTER_ONSET', - 'SOUND_CARD_DRIFT_TRIGGER', - 'TDT_CIRCUIT_PATH', - 'TDT_DELAY', - 'TDT_INTERFACE', - 'TDT_MODEL', - 'TDT_TRIG_DELAY', - 'TRIGGER_CONTROLLER', - 'TRIGGER_ADDRESS', - 'WINDOW_SIZE', - 'SCREEN_NUM', - 'SCREEN_WIDTH', - 'SCREEN_DISTANCE', - 'SCREEN_SIZE_PIX', - 'EXPYFUN_LOGGING_LEVEL', - ) +known_config_types = ( + "RESPONSE_DEVICE", + "AUDIO_CONTROLLER", + "DB_OF_SINE_AT_1KHZ_1RMS", + "EXPYFUN_EYELINK", + "SOUND_CARD_API", + "SOUND_CARD_API_OPTIONS", + "SOUND_CARD_BACKEND", + "SOUND_CARD_FS", + "SOUND_CARD_NAME", + "SOUND_CARD_FIXED_DELAY", + "SOUND_CARD_TRIGGER_CHANNELS", + "SOUND_CARD_TRIGGER_INSERTION", + "SOUND_CARD_TRIGGER_SCALE", + "SOUND_CARD_TRIGGER_ID_AFTER_ONSET", + "SOUND_CARD_DRIFT_TRIGGER", + "TDT_CIRCUIT_PATH", + "TDT_DELAY", + "TDT_INTERFACE", + "TDT_MODEL", + "TDT_TRIG_DELAY", + "TRIGGER_CONTROLLER", + "TRIGGER_ADDRESS", + "WINDOW_SIZE", + "SCREEN_NUM", + "SCREEN_WIDTH", + "SCREEN_DISTANCE", + "SCREEN_SIZE_PIX", + "EXPYFUN_LOGGING_LEVEL", +) # These allow for partial matches: 'NAME_1' is okay key if 'NAME' is listed known_config_wildcards = () @@ -631,8 +642,8 @@ def get_config(key=None, default=None, raise_error=False): value : str | None The preference key value. """ - if key is not None and not isinstance(key, string_types): - raise ValueError('key must be a string') + if key is not None and not isinstance(key, str): + raise ValueError("key must be a string") # first, check to see if key is in env if key is not None and key in os.environ: @@ -644,7 +655,7 @@ def get_config(key=None, default=None, raise_error=False): key_found = False val = default else: - with open(config_path, 'r') as fid: + with open(config_path) as fid: config = json.load(fid) if key is None: return config @@ -654,13 +665,14 @@ def get_config(key=None, default=None, raise_error=False): if not key_found and raise_error is True: meth_1 = 'os.environ["%s"] = VALUE' % key meth_2 = 'expyfun.utils.set_config("%s", VALUE)' % key - raise KeyError('Key "%s" not found in environment or in the ' - 'expyfun config file:\n%s\nTry either:\n' - ' %s\nfor a temporary solution, or:\n' - ' %s\nfor a permanent one. You can also ' - 'set the environment variable before ' - 'running python.' - % (key, config_path, meth_1, meth_2)) + raise KeyError( + 'Key "%s" not found in environment or in the ' + "expyfun config file:\n%s\nTry either:\n" + " %s\nfor a temporary solution, or:\n" + " %s\nfor a permanent one. You can also " + "set the environment variable before " + "running python." % (key, config_path, meth_1, meth_2) + ) return val @@ -678,25 +690,27 @@ def set_config(key, value): """ if key is None: return sorted(known_config_types) - if not isinstance(key, string_types): - raise ValueError('key must be a string') + if not isinstance(key, str): + raise ValueError("key must be a string") # While JSON allow non-string types, we allow users to override config # settings using env, which are strings, so we enforce that here - if not isinstance(value, string_types) and value is not None: - raise ValueError('value must be a string or None') - if key not in known_config_types and not \ - any(k in key for k in known_config_wildcards): + if not isinstance(value, str) and value is not None: + raise ValueError("value must be a string or None") + if key not in known_config_types and not any( + k in key for k in known_config_wildcards + ): warnings.warn('Setting non-standard config type: "%s"' % key) # Read all previous values config_path = get_config_path() if op.isfile(config_path): - with open(config_path, 'r') as fid: + with open(config_path) as fid: config = json.load(fid) else: config = dict() - logger.info('Attempting to create new expyfun configuration ' - 'file:\n%s' % config_path) + logger.info( + "Attempting to create new expyfun configuration " "file:\n%s" % config_path + ) if value is None: config.pop(key, None) else: @@ -706,7 +720,7 @@ def set_config(key, value): directory = op.split(config_path)[0] if not op.isdir(directory): os.mkdir(directory) - with open(config_path, 'w') as fid: + with open(config_path, "w") as fid: json.dump(config, fid, sort_keys=True, indent=0) @@ -714,7 +728,7 @@ def set_config(key, value): # MISC -def fake_button_press(ec, button='1', delay=0.): +def fake_button_press(ec, button="1", delay=0.0): """Fake a button press after a delay Notes @@ -723,29 +737,34 @@ def fake_button_press(ec, button='1', delay=0.): It uses threads to ensure that control is passed back, so other commands can be called (like wait_for_presses). """ + def send(): ec._response_handler._on_pyglet_keypress(button, [], True) - Timer(delay, send).start() if delay > 0. else send() + + Timer(delay, send).start() if delay > 0.0 else send() -def fake_mouse_click(ec, pos, button='left', delay=0.): +def fake_mouse_click(ec, pos, button="left", delay=0.0): """Fake a mouse click after a delay""" button = dict(left=1, middle=2, right=4)[button] # trans to pyglet def send(): ec._mouse_handler._on_pyglet_mouse_click(pos[0], pos[1], button, []) - Timer(delay, send).start() if delay > 0. else send() + + Timer(delay, send).start() if delay > 0.0 else send() def _check_pyglet_version(raise_error=False): - """Check pyglet version, return True if usable. - """ + """Check pyglet version, return True if usable.""" import pyglet - is_usable = _compare_version(pyglet.version, '>=', '1.2') + + is_usable = _compare_version(pyglet.version, ">=", "1.2") if raise_error is True and is_usable is False: - raise ImportError('On Linux, you must run at least Pyglet ' - 'version 1.2, and you are running ' - '{0}'.format(pyglet.version)) + raise ImportError( + "On Linux, you must run at least Pyglet " + "version 1.2, and you are running " + f"{pyglet.version}" + ) return is_usable @@ -800,7 +819,7 @@ def running_rms(signal, win_length): # sig2 = signal * signal c1 = np.cumsum(sig2) - out = c1[win_length - 1:].copy() + out = c1[win_length - 1 :].copy() if len(out) == 0: # len(signal) < len(win_length) out = np.array([np.sqrt(c1[-1] / signal.size)]) else: @@ -833,21 +852,23 @@ def _fix_audio_dims(signal, n_channels): signal = np.asarray(np.atleast_2d(signal), dtype=np.float32) # Check dimensionality if signal.ndim != 2: - raise ValueError('Sound data must have one or two dimensions, got %s.' - % (signal.ndim,)) + raise ValueError( + "Sound data must have one or two dimensions, got %s." % (signal.ndim,) + ) # Return data with correct dimensions if n_channels == 2 and signal.shape[0] == 1: signal = np.tile(signal, (n_channels, 1)) if signal.shape[0] != n_channels: - raise ValueError('signal channel count %d did not match required ' - 'channel count %d' % (signal.shape[0], n_channels)) + raise ValueError( + "signal channel count %d did not match required " + "channel count %d" % (signal.shape[0], n_channels) + ) return signal def _sanitize(text_like): - """Cast as string, encode as UTF-8 and sanitize any escape characters. - """ - return text_type(text_like).encode('unicode_escape').decode('utf-8') + """Cast as string, encode as UTF-8 and sanitize any escape characters.""" + return str(text_like).encode("unicode_escape").decode("utf-8") def _sort_keys(x): @@ -858,7 +879,7 @@ def _sort_keys(x): return keys -def object_diff(a, b, pre=''): +def object_diff(a, b, pre=""): """Compute all differences between two python variables Parameters @@ -880,72 +901,76 @@ def object_diff(a, b, pre=''): ----- Taken from mne-python with permission. """ - out = '' + out = "" if type(a) != type(b): - out += pre + ' type mismatch (%s, %s)\n' % (type(a), type(b)) + out += pre + " type mismatch (%s, %s)\n" % (type(a), type(b)) elif isinstance(a, dict): k1s = _sort_keys(a) k2s = _sort_keys(b) m1 = set(k2s) - set(k1s) if len(m1): - out += pre + ' x1 missing keys %s\n' % (m1) + out += pre + " x1 missing keys %s\n" % (m1) for key in k1s: if key not in k2s: - out += pre + ' x2 missing key %s\n' % key + out += pre + " x2 missing key %s\n" % key else: - out += object_diff(a[key], b[key], pre + 'd1[%s]' % repr(key)) + out += object_diff(a[key], b[key], pre + "d1[%s]" % repr(key)) elif isinstance(a, (list, tuple)): if len(a) != len(b): - out += pre + ' length mismatch (%s, %s)\n' % (len(a), len(b)) + out += pre + " length mismatch (%s, %s)\n" % (len(a), len(b)) else: for xx1, xx2 in zip(a, b): - out += object_diff(xx1, xx2, pre='') - elif isinstance(a, (string_types, int, float, bytes)): + out += object_diff(xx1, xx2, pre="") + elif isinstance(a, (str, int, float, bytes)): if a != b: - out += pre + ' value mismatch (%s, %s)\n' % (a, b) + out += pre + " value mismatch (%s, %s)\n" % (a, b) elif a is None: if b is not None: - out += pre + ' a is None, b is not (%s)\n' % (b) + out += pre + " a is None, b is not (%s)\n" % (b) elif isinstance(a, np.ndarray): if not np.array_equal(a, b): - out += pre + ' array mismatch\n' + out += pre + " array mismatch\n" else: - raise RuntimeError(pre + ': unsupported type %s (%s)' % (type(a), a)) + raise RuntimeError(pre + ": unsupported type %s (%s)" % (type(a), a)) return out def _check_skip_backend(backend): - from expyfun._sound_controllers import _import_backend import pytest + + from expyfun._sound_controllers import _import_backend + if isinstance(backend, dict): # actually an AC - backend = backend['SOUND_CARD_BACKEND'] + backend = backend["SOUND_CARD_BACKEND"] try: _import_backend(backend) except Exception as exc: - pytest.skip('Skipping test for backend %s: %s' % (backend, exc)) + pytest.skip("Skipping test for backend %s: %s" % (backend, exc)) def _check_params(params, keys, defaults, name): if not isinstance(params, dict): - raise TypeError('{0} must be a dict, got type {1}' - .format(name, type(params))) + raise TypeError(f"{name} must be a dict, got type {type(params)}") params = deepcopy(params) if not isinstance(params, dict): - raise TypeError('{0} must be a dict, got {1}' - .format(name, type(params))) + raise TypeError(f"{name} must be a dict, got {type(params)}") # Set sensible defaults for values that are not passed for k in keys: params[k] = params.get(k, get_config(k, defaults.get(k, None))) # Check keys for k in params.keys(): if k not in keys: - raise KeyError('Unrecognized key in {0}["{1}"], must be ' - 'one of {2}'.format(name, k, ', '.join(keys))) + raise KeyError( + 'Unrecognized key in {0}["{1}"], must be ' "one of {2}".format( + name, k, ", ".join(keys) + ) + ) return params def _get_display(): import pyglet + try: display = pyglet.canvas.get_display() except AttributeError: # < 1.4 @@ -956,4 +981,5 @@ def _get_display(): # Adapted from MNE-Python def _compare_version(version_a, operator, version_b): from packaging.version import parse # noqa + return eval(f'parse("{version_a}") {operator} parse("{version_b}")') diff --git a/expyfun/_version.py b/expyfun/_version.py index 8933c937..9fc20a7e 100644 --- a/expyfun/_version.py +++ b/expyfun/_version.py @@ -1 +1 @@ -__version__ = '2.0.0.dev0' +__version__ = "2.0.0.dev0" diff --git a/expyfun/analyze/__init__.py b/expyfun/analyze/__init__.py index c5edef1d..87792884 100644 --- a/expyfun/analyze/__init__.py +++ b/expyfun/analyze/__init__.py @@ -6,7 +6,6 @@ """ # -*- coding: utf-8 -*- -from ._analyze import (dprime, logit, sigmoid, fit_sigmoid, - rt_chisq, press_times_to_hmfc) +from ._analyze import dprime, logit, sigmoid, fit_sigmoid, rt_chisq, press_times_to_hmfc from ._viz import barplot, box_off, plot_screen, format_pval from ._recon import restore_values diff --git a/expyfun/analyze/_analyze.py b/expyfun/analyze/_analyze.py index f8119d29..defac1dc 100644 --- a/expyfun/analyze/_analyze.py +++ b/expyfun/analyze/_analyze.py @@ -1,19 +1,14 @@ -# -*- coding: utf-8 -*- -"""Analysis functions (mostly for psychophysics data). -""" +"""Analysis functions (mostly for psychophysics data).""" -from collections import namedtuple import warnings +from collections import namedtuple import numpy as np import scipy.stats as ss from scipy.optimize import curve_fit -from .._utils import string_types - -def press_times_to_hmfc(presses, targets, foils, tmin, tmax, - return_type='counts'): +def press_times_to_hmfc(presses, targets, foils, tmin, tmax, return_type="counts"): """Convert press times to hits/misses/FA/CR and reaction times Parameters @@ -58,15 +53,15 @@ def press_times_to_hmfc(presses, targets, foils, tmin, tmax, press by this function. However, there is no such de-bouncing of responses to "other" times. """ - known_types = ['counts', 'rts'] - if isinstance(return_type, string_types): + known_types = ["counts", "rts"] + if isinstance(return_type, str): singleton = True return_type = [return_type] else: singleton = False for r in return_type: - if not isinstance(r, string_types) or r not in known_types: - raise ValueError('r must be one of %s, got %s' % (known_types, r)) + if not isinstance(r, str) or r not in known_types: + raise ValueError("r must be one of %s, got %s" % (known_types, r)) # Sanity check that targets and foils don't overlap (due to tmin/tmax) targets = np.atleast_1d(targets) foils = np.atleast_1d(foils) @@ -77,7 +72,7 @@ def press_times_to_hmfc(presses, targets, foils, tmin, tmax, order = np.argsort(stim_times) stim_times = stim_times[order] if not np.all(stim_times[:-1] + tmax <= stim_times[1:] + tmin): - raise ValueError('Analysis windows for targets and foils overlap') + raise ValueError("Analysis windows for targets and foils overlap") # figure out what targ/mask times our presses correspond to press_to_stim = np.searchsorted(stim_times, presses - tmin) - 1 if len(press_to_stim) > 0: @@ -88,8 +83,7 @@ def press_times_to_hmfc(presses, targets, foils, tmin, tmax, assert (stim_times <= presses).all() # figure out which presses were valid (to target or masker) - valid_mask = ((presses >= stim_times + tmin) & - (presses <= stim_times + tmax)) + valid_mask = (presses >= stim_times + tmin) & (presses <= stim_times + tmax) n_other = np.sum(~valid_mask) press_to_stim = press_to_stim[valid_mask] presses = presses[valid_mask] @@ -105,14 +99,16 @@ def press_times_to_hmfc(presses, targets, foils, tmin, tmax, del used_map_idx # figure out which valid presses were to target or masker - target_mask = (order <= len(targets)) + target_mask = order <= len(targets) n_hit = np.sum(target_mask) n_fa = len(target_mask) - n_hit n_miss = len(targets) - n_hit n_cr = len(foils) - n_fa - outs = dict(counts=(n_hit, n_miss, n_fa, n_cr, n_other), - rts=(diffs[target_mask], diffs[~target_mask])) - assert outs['counts'][:4:2] == tuple(map(len, outs['rts'])) + outs = dict( + counts=(n_hit, n_miss, n_fa, n_cr, n_other), + rts=(diffs[target_mask], diffs[~target_mask]), + ) + assert outs["counts"][:4:2] == tuple(map(len, outs["rts"])) outs = tuple(outs[r] for r in return_type) if singleton: outs = outs[0] @@ -141,7 +137,7 @@ def logit(prop, max_events=None): """ prop = np.atleast_1d(prop).astype(float) if np.any([prop > 1, prop < 0]): - raise ValueError('Proportions must be in the range [0, 1].') + raise ValueError("Proportions must be in the range [0, 1].") if max_events is not None: # add equivalent of half an event to 0s, and subtract same from 1s max_events = np.atleast_1d(max_events) * np.ones_like(prop) @@ -150,10 +146,10 @@ def logit(prop, max_events=None): prop[loc] = corr_factor[loc] for loc in zip(*np.where(prop == 1)): prop[loc] = 1 - corr_factor[loc] - return np.log(prop / (1. - prop)) + return np.log(prop / (1.0 - prop)) -def sigmoid(x, lower=0., upper=1., midpt=0., slope=1.): +def sigmoid(x, lower=0.0, upper=1.0, midpt=0.0, slope=1.0): """Calculate sigmoidal values along the x-axis Parameters @@ -213,23 +209,21 @@ def fit_sigmoid(x, y, p0=None, fixed=()): # Initial estimates x = np.asarray(x) y = np.asarray(y) - k = 2 * 4. / (np.max(x) - np.min(x)) + k = 2 * 4.0 / (np.max(x) - np.min(x)) if p0 is None: p0 = [None] * 4 p0 = list(p0) - for ii, p in enumerate([np.min(y), np.max(y), - np.mean([np.max(x), np.min(x)]), k]): + for ii, p in enumerate([np.min(y), np.max(y), np.mean([np.max(x), np.min(x)]), k]): p0[ii] = p if p0[ii] is None else p0[ii] p0 = np.array(p0, dtype=np.float64) if p0.size != 4 or p0.ndim != 1: - raise ValueError('p0 must have 4 elements, or be None') + raise ValueError("p0 must have 4 elements, or be None") # Fixing values - p_types = ('lower', 'upper', 'midpt', 'slope') + p_types = ("lower", "upper", "midpt", "slope") for f in fixed: if f not in p_types: - raise ValueError('fixed {0} not in parameter list {1}' - ''.format(f, p_types)) + raise ValueError(f"fixed {f} not in parameter list {p_types}" "") fixed = np.array([(True if f in fixed else False) for f in p_types], bool) kwargs = dict() @@ -243,7 +237,7 @@ def fit_sigmoid(x, y, p0=None, fixed=()): idx.append(ii) p0 = p0[idx] if len(idx) == 0: - raise RuntimeError('cannot fit with all fixed values') + raise RuntimeError("cannot fit with all fixed values") def wrapper(*args): assert len(args) == len(keys) + 1 @@ -255,7 +249,7 @@ def wrapper(*args): assert len(idx) == len(out) for ii, o in zip(idx, out): kwargs[p_types[ii]] = o - return namedtuple('params', p_types)(**kwargs) + return namedtuple("params", p_types)(**kwargs) def rt_chisq(x, axis=None, warn=True): @@ -294,30 +288,29 @@ def rt_chisq(x, axis=None, warn=True): """ x = np.asarray(x) if np.any(np.less(x, 0)): # save the user some pain - raise ValueError('x cannot have negative values') + raise ValueError("x cannot have negative values") if axis is None: df, _, scale = ss.chi2.fit(x, floc=0) else: + def fit(x): return np.array(ss.chi2.fit(x, floc=0)) + params = np.apply_along_axis(fit, axis=axis, arr=x) # df, loc, scale - pmut = np.concatenate((np.atleast_1d(axis), - np.delete(np.arange(x.ndim), axis))) + pmut = np.concatenate((np.atleast_1d(axis), np.delete(np.arange(x.ndim), axis))) df = np.transpose(params, pmut)[0] scale = np.transpose(params, pmut)[2] quartiles = np.percentile(x, (25, 75)) whiskers = quartiles + np.array((-1.5, 1.5)) * np.diff(quartiles) - n_bad = np.sum(np.logical_or(np.less(x, whiskers[0]), - np.greater(x, whiskers[1]))) + n_bad = np.sum(np.logical_or(np.less(x, whiskers[0]), np.greater(x, whiskers[1]))) if n_bad > 0 and warn: - warnings.warn('{0} likely bad values in x (of {1})' - ''.format(n_bad, x.size)) + warnings.warn(f"{n_bad} likely bad values in x (of {x.size})" "") peak = np.maximum(0, (df - 2)) * scale return peak def dprime(hmfc, zero_correction=True, return_bias=False, two_interval=False): - u"""Estimate d′ and bias. + """Estimate d′ and bias. Parameters ---------- @@ -366,13 +359,11 @@ def dprime(hmfc, zero_correction=True, return_bias=False, two_interval=False): """ hmfc = _check_dprime_inputs(hmfc) a = 0.5 if zero_correction else 0.0 - z_hr = ss.norm.ppf((hmfc[..., 0] + a) / - (hmfc[..., 0] + hmfc[..., 1] + 2 * a)) - z_fr = ss.norm.ppf((hmfc[..., 2] + a) / - (hmfc[..., 2] + hmfc[..., 3] + 2 * a)) - cf = 1. / np.sqrt(2) if two_interval else 1. + z_hr = ss.norm.ppf((hmfc[..., 0] + a) / (hmfc[..., 0] + hmfc[..., 1] + 2 * a)) + z_fr = ss.norm.ppf((hmfc[..., 2] + a) / (hmfc[..., 2] + hmfc[..., 3] + 2 * a)) + cf = 1.0 / np.sqrt(2) if two_interval else 1.0 dp = cf * (z_hr - z_fr) - bias = (z_hr + z_fr) / -2. + bias = (z_hr + z_fr) / -2.0 return (dp, bias) if return_bias else dp @@ -386,10 +377,13 @@ def _check_dprime_inputs(hmfc): """ hmfc = np.asarray(hmfc) if hmfc.shape[-1] != 4: - raise ValueError('Array must have last dimension 4') + raise ValueError("Array must have last dimension 4") if hmfc.dtype not in (np.int64, np.int32): - warnings.warn('Argument (%s) to dprime() cast to np.int64; floating ' - 'point values will have been truncated.' % hmfc.dtype, - RuntimeWarning, stacklevel=3) + warnings.warn( + "Argument (%s) to dprime() cast to np.int64; floating " + "point values will have been truncated." % hmfc.dtype, + RuntimeWarning, + stacklevel=3, + ) hmfc = hmfc.astype(np.int64) return hmfc diff --git a/expyfun/analyze/_recon.py b/expyfun/analyze/_recon.py index e49cc2de..12afc1c6 100644 --- a/expyfun/analyze/_recon.py +++ b/expyfun/analyze/_recon.py @@ -1,5 +1,4 @@ -"""Functions for fixing data. -""" +"""Functions for fixing data.""" import numpy as np from scipy import linalg @@ -36,8 +35,10 @@ def restore_values(correct, other, idx): correct = np.array(correct, np.float64) other = np.array(other, np.float64) if correct.ndim != 1 or other.ndim != 1 or other.size > correct.size: - raise RuntimeError('correct and other must be 1D, and correct must ' - 'be at least as long as other') + raise RuntimeError( + "correct and other must be 1D, and correct must " + "be at least as long as other" + ) keep = np.ones(len(correct), bool) for ii in idx: keep[ii] = False @@ -49,7 +50,7 @@ def restore_values(correct, other, idx): X = np.dot(X, other) test = np.dot(np.array((np.ones_like(use), use)).T, X) if not np.allclose(other, test): # validate fit - raise RuntimeError('data could not be fit') + raise RuntimeError("data could not be fit") miss = correct[replace] vals = np.dot(np.array((np.ones_like(miss), miss)).T, X) out = np.zeros(len(correct), np.float64) diff --git a/expyfun/analyze/_viz.py b/expyfun/analyze/_viz.py index ab68f979..5bac4dd1 100644 --- a/expyfun/analyze/_viz.py +++ b/expyfun/analyze/_viz.py @@ -1,13 +1,11 @@ -"""Analysis visualization functions -""" +"""Analysis visualization functions""" -import numpy as np from itertools import chain -from .._utils import string_types +import numpy as np -def format_pval(pval, latex=True, scheme='default'): +def format_pval(pval, latex=True, scheme="default"): """Format a p-value using one of several schemes. Parameters @@ -36,38 +34,42 @@ def format_pval(pval, latex=True, scheme='default'): expon = np.trunc(np.log10(pval)).astype(int) # exponents pv = np.zeros_like(pval, dtype=object) if latex: - wrap = '$' - brk_l = '{{' - brk_r = '}}' + wrap = "$" + brk_l = "{{" + brk_r = "}}" else: - wrap = '' - brk_l = '' - brk_r = '' - if scheme == 'ross': # (exact value up to 4 decimal places) - pv[pval >= 0.0001] = [wrap + 'p = {:.4f}'.format(x) + wrap - for x in pval[pval > 0.0001]] - pv[pval < 0.0001] = [wrap + 'p < 10^' + brk_l + '{}'.format(x) + - brk_r + wrap for x in expon[pval < 0.0001]] - elif scheme == 'stars': - star = '{*}' if latex else '*' - pv[pval >= 0.05] = wrap + '' + wrap + wrap = "" + brk_l = "" + brk_r = "" + if scheme == "ross": # (exact value up to 4 decimal places) + pv[pval >= 0.0001] = [wrap + f"p = {x:.4f}" + wrap for x in pval[pval > 0.0001]] + pv[pval < 0.0001] = [ + wrap + "p < 10^" + brk_l + f"{x}" + brk_r + wrap + for x in expon[pval < 0.0001] + ] + elif scheme == "stars": + star = "{*}" if latex else "*" + pv[pval >= 0.05] = wrap + "" + wrap pv[pval < 0.05] = wrap + star + wrap pv[pval < 0.01] = wrap + star * 2 + wrap pv[pval < 0.001] = wrap + star * 3 + wrap else: # scheme == 'default' - pv[pval >= 0.05] = wrap + 'n.s.' + wrap - pv[pval < 0.05] = wrap + 'p < 0.05' + wrap - pv[pval < 0.01] = wrap + 'p < 0.01' + wrap - pv[pval < 0.001] = wrap + 'p < 0.001' + wrap - pv[pval < 0.0001] = [wrap + 'p < 10^' + brk_l + '{}'.format(x) + - brk_r + wrap for x in expon[pval < 0.0001]] + pv[pval >= 0.05] = wrap + "n.s." + wrap + pv[pval < 0.05] = wrap + "p < 0.05" + wrap + pv[pval < 0.01] = wrap + "p < 0.01" + wrap + pv[pval < 0.001] = wrap + "p < 0.001" + wrap + pv[pval < 0.0001] = [ + wrap + "p < 10^" + brk_l + f"{x}" + brk_r + wrap + for x in expon[pval < 0.0001] + ] if single_value: pv = pv[0] return pv def _instantiate(obj, typ): - """Returns obj if obj is not None, else returns new instance of typ + """Return obj if obj is not None, else returns new instance of typ. + obj : an object An object (most likely one that a user passed into a function) that, if ``None``, should be initiated as an empty object of some other type. @@ -77,13 +79,31 @@ def _instantiate(obj, typ): return typ() if obj is None else obj -def barplot(h, axis=-1, ylim=None, err_bars=None, lines=False, - groups=None, eq_group_widths=False, gap_size=0.2, - brackets=None, bracket_text=None, bracket_inline=False, - bracket_group_lines=False, bar_names=None, group_names=None, - bar_kwargs=None, err_kwargs=None, line_kwargs=None, - bracket_kwargs=None, pval_kwargs=None, figure_kwargs=None, - smart_defaults=True, fname=None, ax=None): +def barplot( + h, + axis=-1, + ylim=None, + err_bars=None, + lines=False, + groups=None, + eq_group_widths=False, + gap_size=0.2, + brackets=None, + bracket_text=None, + bracket_inline=False, + bracket_group_lines=False, + bar_names=None, + group_names=None, + bar_kwargs=None, + err_kwargs=None, + line_kwargs=None, + bracket_kwargs=None, + pval_kwargs=None, + figure_kwargs=None, + smart_defaults=True, + fname=None, + ax=None, +): """Makes barplots w/ optional line overlays, grouping, & signif. brackets. Parameters @@ -198,7 +218,9 @@ def barplot(h, axis=-1, ylim=None, err_bars=None, lines=False, bracket color: dark gray (30%) """ - from matplotlib import pyplot as plt, rcParams + from matplotlib import pyplot as plt + from matplotlib import rcParams + try: from pandas.core.frame import DataFrame except Exception: @@ -210,24 +232,26 @@ def barplot(h, axis=-1, ylim=None, err_bars=None, lines=False, bar_names = h.columns.tolist() if axis == 0 else h.index.tolist() # check arg errors if gap_size < 0 or gap_size >= 1: - raise ValueError('Barplot argument "gap_size" must be in the range ' - '[0, 1).') + raise ValueError('Barplot argument "gap_size" must be in the range ' "[0, 1).") if err_bars is not None: - if isinstance(err_bars, string_types) and \ - err_bars not in ['sd', 'se', 'ci']: - raise ValueError('err_bars must be "sd", "se", or "ci" (or an ' - 'array of error bar magnitudes).') + if isinstance(err_bars, str) and err_bars not in ["sd", "se", "ci"]: + raise ValueError( + 'err_bars must be "sd", "se", or "ci" (or an ' + "array of error bar magnitudes)." + ) if brackets is not None: if any([len(x) != 2 for x in brackets]): - raise ValueError('Each top-level element of brackets must have ' - 'length 2.') + raise ValueError( + "Each top-level element of brackets must have " "length 2." + ) if not len(brackets) == len(bracket_text): - raise ValueError('Mismatch between number of brackets and bracket ' - 'labels.') + raise ValueError( + "Mismatch between number of brackets and bracket " "labels." + ) # handle single-element args - if isinstance(bracket_text, string_types): + if isinstance(bracket_text, str): bracket_text = [bracket_text] - if isinstance(group_names, string_types): + if isinstance(group_names, str): group_names = [group_names] # arg defaults: if arg is None, instantiate as given type brackets = _instantiate(brackets, list) @@ -239,20 +263,20 @@ def barplot(h, axis=-1, ylim=None, err_bars=None, lines=False, bracket_kwargs = _instantiate(bracket_kwargs, dict) # user-supplied Axes if ax is not None: - bar_kwargs['axes'] = ax + bar_kwargs["axes"] = ax # smart defaults if smart_defaults: - if 'color' not in bar_kwargs.keys(): - bar_kwargs['color'] = '0.7' - if 'color' not in line_kwargs.keys(): - line_kwargs['color'] = 'k' - if 'ecolor' not in err_kwargs.keys(): - err_kwargs['ecolor'] = 'k' - if 'color' not in bracket_kwargs.keys(): - bracket_kwargs['color'] = '0.3' + if "color" not in bar_kwargs.keys(): + bar_kwargs["color"] = "0.7" + if "color" not in line_kwargs.keys(): + line_kwargs["color"] = "k" + if "ecolor" not in err_kwargs.keys(): + err_kwargs["ecolor"] = "k" + if "color" not in bracket_kwargs.keys(): + bracket_kwargs["color"] = "0.3" # fix bar alignment (defaults to 'center' in more recent versions of MPL) - if 'align' not in bar_kwargs.keys(): - bar_kwargs['align'] = 'edge' + if "align" not in bar_kwargs.keys(): + bar_kwargs["align"] = "edge" # parse heights h = np.array(h) if len(h.shape) > 2: @@ -265,54 +289,61 @@ def barplot(h, axis=-1, ylim=None, err_bars=None, lines=False, groups = [list(x) for x in groups] # forgive list/tuple mix-ups # calculate bar positions non_gap = 1 - gap_size - offset = gap_size / 2. + offset = gap_size / 2.0 if eq_group_widths: group_sizes = np.array([float(len(_grp)) for _grp in groups], int) group_widths = [non_gap for _ in groups] group_edges = [offset + _ix for _ix in range(len(groups))] group_ixs = list(chain.from_iterable([range(x) for x in group_sizes])) - bar_widths = np.repeat(np.array(group_widths) / group_sizes, - group_sizes).tolist() - bar_edges = (np.repeat(group_edges, group_sizes) + - bar_widths * np.array(group_ixs)).tolist() + bar_widths = np.repeat( + np.array(group_widths) / group_sizes, group_sizes + ).tolist() + bar_edges = ( + np.repeat(group_edges, group_sizes) + bar_widths * np.array(group_ixs) + ).tolist() else: bar_widths = [[non_gap for _ in _grp] for _grp in groups] # next line: offset + cumul. gap widths + cumul. bar widths - bar_edges = [[offset + _ix * gap_size + _bar * non_gap - for _bar in _grp] for _ix, _grp in enumerate(groups)] + bar_edges = [ + [offset + _ix * gap_size + _bar * non_gap for _bar in _grp] + for _ix, _grp in enumerate(groups) + ] group_widths = [np.sum(_width) for _width in bar_widths] group_edges = [_edge[0] for _edge in bar_edges] bar_edges = list(chain.from_iterable(bar_edges)) bar_widths = list(chain.from_iterable(bar_widths)) - bar_centers = np.array(bar_edges) + np.array(bar_widths) / 2. - group_centers = np.array(group_edges) + np.array(group_widths) / 2. + bar_centers = np.array(bar_edges) + np.array(bar_widths) / 2.0 + group_centers = np.array(group_edges) + np.array(group_widths) / 2.0 # calculate error bars err = np.zeros(num_bars) # default if no err_bars if err_bars is not None: if h.ndim == 2: - if err_bars == 'sd': # sample standard deviation + if err_bars == "sd": # sample standard deviation err = h.std(axis) - elif err_bars == 'se': # standard error + elif err_bars == "se": # standard error err = h.std(axis) / np.sqrt(h.shape[axis]) else: # 95% conf int err = 1.96 * h.std(axis) / np.sqrt(h.shape[axis]) else: # h.ndim == 1 - if isinstance(err_bars, string_types): - raise ValueError('string arguments to "err_bars" ignored when ' - '"h" has fewer than 2 dimensions.') + if isinstance(err_bars, str): + raise ValueError( + 'string arguments to "err_bars" ignored when ' + '"h" has fewer than 2 dimensions.' + ) elif not h.shape == np.array(err_bars).shape: - raise ValueError('When "err_bars" is array-like it must have ' - 'the same shape as "h".') + raise ValueError( + 'When "err_bars" is array-like it must have ' + 'the same shape as "h".' + ) err = np.atleast_1d(err_bars) - bar_kwargs['yerr'] = err + bar_kwargs["yerr"] = err # plot (bars and error bars) if ax is None: plt.figure(**figure_kwargs) p = plt.subplot(111) else: p = ax - b = p.bar(bar_edges, heights, bar_widths, error_kw=err_kwargs, - **bar_kwargs) + b = p.bar(bar_edges, heights, bar_widths, error_kw=err_kwargs, **bar_kwargs) # plot within-subject lines if lines: _h = h if axis == 0 else h.T @@ -326,41 +357,53 @@ def barplot(h, axis=-1, ylim=None, err_bars=None, lines=False, brk_min_h = np.diff(p.get_ylim()) * 0.05 # temporarily plot a textbox to get its height t = plt.annotate(bracket_text[0], (0, 0), **pval_kwargs) - t.set_bbox(dict(boxstyle='round, pad=0.25')) + t.set_bbox(dict(boxstyle="round, pad=0.25")) plt.draw() bb = t.get_bbox_patch().get_window_extent() - txth = np.diff(p.transData.inverted().transform(bb), - axis=0).ravel()[-1] + txth = np.diff(p.transData.inverted().transform(bb), axis=0).ravel()[-1] if bracket_inline: - txth = txth / 2. + txth = txth / 2.0 t.remove() # find highest points if lines and h.ndim == 2: # brackets must be above lines & error bars - apex = np.amax(np.r_[np.atleast_2d(heights + err), - np.atleast_2d(np.amax(h, axis))], axis=0) + apex = np.amax( + np.r_[np.atleast_2d(heights + err), np.atleast_2d(np.amax(h, axis))], + axis=0, + ) else: apex = np.atleast_1d(heights + err) apex = np.maximum(apex, 0) # for negative-going bars apex = apex + brk_offset gr_apex = np.array([np.amax(apex[_g]) for _g in groups]) # boolean for whether each half of a bracket is a group - is_group = [[hasattr(_b, 'append') for _b in _br] for _br in brackets] + is_group = [[hasattr(_b, "append") for _b in _br] for _br in brackets] # bracket left & right coords - brk_lr = [[group_centers[groups.index(_ix)] if _g - else bar_centers[_ix] for _ix, _g in zip(_brk, _isg)] - for _brk, _isg in zip(brackets, is_group)] + brk_lr = [ + [ + group_centers[groups.index(_ix)] if _g else bar_centers[_ix] + for _ix, _g in zip(_brk, _isg) + ] + for _brk, _isg in zip(brackets, is_group) + ] # bracket L/R midpoints (label position) brk_c = [np.mean(_lr) for _lr in brk_lr] # bracket bottom coords (first pass) - brk_b = [[gr_apex[groups.index(_ix)] if _g else apex[_ix] - for _ix, _g in zip(_brk, _isg)] - for _brk, _isg in zip(brackets, is_group)] + brk_b = [ + [ + gr_apex[groups.index(_ix)] if _g else apex[_ix] + for _ix, _g in zip(_brk, _isg) + ] + for _brk, _isg in zip(brackets, is_group) + ] # main bracket positioning loop brk_t = [] for _ix, (_brk, _isg) in enumerate(zip(brackets, is_group)): # which bars does this bracket span? - spanned_bars = list(chain.from_iterable( - [_b if hasattr(_b, 'append') else [_b] for _b in _brk])) + spanned_bars = list( + chain.from_iterable( + [_b if hasattr(_b, "append") else [_b] for _b in _brk] + ) + ) spanned_bars = range(min(spanned_bars), max(spanned_bars) + 1) # raise apex a bit extra if prev bracket label centered on bar prev_label_pos = brk_c[_ix - 1] if _ix else -1 @@ -375,41 +418,42 @@ def barplot(h, axis=-1, ylim=None, err_bars=None, lines=False, apex[label_bar_more] += txth gr_apex = np.array([np.amax(apex[_g]) for _g in groups]) # recalc lower tips of bracket: apex / gr_apex may have changed - brk_b[_ix] = [gr_apex[groups.index(_b)] if _g else apex[_b] - for _b, _g in zip(_brk, _isg)] + brk_b[_ix] = [ + gr_apex[groups.index(_b)] if _g else apex[_b] + for _b, _g in zip(_brk, _isg) + ] # calculate top span position _min_t = max(apex[spanned_bars]) + brk_min_h brk_t.append(_min_t) # raise apex on spanned bars to account for bracket - apex[spanned_bars] = np.maximum(apex[spanned_bars], - _min_t) + brk_offset + apex[spanned_bars] = np.maximum(apex[spanned_bars], _min_t) + brk_offset gr_apex = np.array([np.amax(apex[_g]) for _g in groups]) # draw horz line spanning groups if desired if bracket_group_lines: for _brk, _isg, _blr in zip(brackets, is_group, brk_b): for _bk, _g, _b in zip(_brk, _isg, _blr): if _g: - _lr = [bar_centers[_ix] - for _ix in groups[groups.index(_bk)]] + _lr = [bar_centers[_ix] for _ix in groups[groups.index(_bk)]] _lr = (min(_lr), max(_lr)) p.plot(_lr, (_b, _b), **bracket_kwargs) # draw (left, right, bottom-left, bottom-right, top, center, string) - for ((_l, _r), (_bl, _br), _t, _c, _s) in zip(brk_lr, brk_b, brk_t, - brk_c, bracket_text): + for (_l, _r), (_bl, _br), _t, _c, _s in zip( + brk_lr, brk_b, brk_t, brk_c, bracket_text + ): # bracket text _t = np.array(_t).item() # on newer Pandas it can be shape (1,) - defaults = dict(ha='center', annotation_clip=False, - textcoords='offset points') + defaults = dict( + ha="center", annotation_clip=False, textcoords="offset points" + ) for k, v in defaults.items(): if k not in pval_kwargs.keys(): pval_kwargs[k] = v - if 'va' not in pval_kwargs.keys(): - pval_kwargs['va'] = 'center' if bracket_inline else 'baseline' - if 'xytext' not in pval_kwargs.keys(): - pval_kwargs['xytext'] = (0, 0) if bracket_inline else (0, 2) + if "va" not in pval_kwargs.keys(): + pval_kwargs["va"] = "center" if bracket_inline else "baseline" + if "xytext" not in pval_kwargs.keys(): + pval_kwargs["xytext"] = (0, 0) if bracket_inline else (0, 2) txt = p.annotate(_s, (_c, _t), **pval_kwargs) - txt.set_bbox(dict(facecolor='w', alpha=0, - boxstyle='round, pad=0.2')) + txt.set_bbox(dict(facecolor="w", alpha=0, boxstyle="round, pad=0.2")) plt.draw() # bracket lines lline = ((_l, _l), (_bl, _t)) @@ -417,10 +461,9 @@ def barplot(h, axis=-1, ylim=None, err_bars=None, lines=False, tline = ((_l, _r), (_t, _t)) if bracket_inline: bb = txt.get_bbox_patch().get_window_extent() - txtw = np.diff(p.transData.inverted().transform(bb), - axis=0).ravel()[0] - _m = _c - txtw / 2. - _n = _c + txtw / 2. + txtw = np.diff(p.transData.inverted().transform(bb), axis=0).ravel()[0] + _m = _c - txtw / 2.0 + _n = _c + txtw / 2.0 tline = [((_l, _m), (_t, _t)), ((_n, _r), (_t, _t))] else: tline = [((_l, _r), (_t, _t))] @@ -432,17 +475,23 @@ def barplot(h, axis=-1, ylim=None, err_bars=None, lines=False, p.set_ybound(ybnd[0], _t + txth) # annotation box_off(p) - p.tick_params(axis='x', length=0, pad=12) + p.tick_params(axis="x", length=0, pad=12) p.xaxis.set_ticks(bar_centers) if bar_names is not None: - p.xaxis.set_ticklabels(bar_names, va='baseline') + p.xaxis.set_ticklabels(bar_names, va="baseline") if group_names is not None: ymin = ylim[0] if ylim is not None else p.get_ylim()[0] - yoffset = -2.5 * rcParams['font.size'] + yoffset = -2.5 * rcParams["font.size"] for gn, gp in zip(group_names, group_centers): - p.annotate(gn, xy=(gp, ymin), xytext=(0, yoffset), - xycoords='data', textcoords='offset points', - ha='center', va='baseline') + p.annotate( + gn, + xy=(gp, ymin), + xytext=(0, yoffset), + xycoords="data", + textcoords="offset points", + ha="center", + va="baseline", + ) # axis limits p.set_xlim(0, bar_edges[-1] + bar_widths[-1] + gap_size / 2) if ylim is not None: @@ -450,6 +499,7 @@ def barplot(h, axis=-1, ylim=None, err_bars=None, lines=False, # output file if fname is not None: from os.path import splitext + fmt = splitext(fname)[-1][1:] plt.savefig(fname, format=fmt, transparent=True) # return handles for subplot and barplot instances @@ -467,10 +517,10 @@ def box_off(ax): """ ax.get_xaxis().tick_bottom() ax.get_yaxis().tick_left() - ax.tick_params(axis='x', direction='out') - ax.tick_params(axis='y', direction='out') - ax.spines['right'].set_color('none') - ax.spines['top'].set_color('none') + ax.tick_params(axis="x", direction="out") + ax.tick_params(axis="y", direction="out") + ax.spines["right"].set_color("none") + ax.spines["top"].set_color("none") def plot_screen(screen, ax=None): @@ -490,12 +540,13 @@ def plot_screen(screen, ax=None): The axes used to plot the image. """ import matplotlib.pyplot as plt + screen = np.array(screen) if screen.ndim != 3 or screen.shape[2] not in [3, 4]: - raise ValueError('screen must be a 3D array with 3 or 4 channels') + raise ValueError("screen must be a 3D array with 3 or 4 channels") if ax is None: plt.figure() ax = plt.axes([0, 0, 1, 1]) ax.imshow(screen) - ax.axis('off') + ax.axis("off") return ax diff --git a/expyfun/analyze/tests/test_analyze_functions.py b/expyfun/analyze/tests/test_analyze_functions.py index 81cefb7a..f7db6345 100644 --- a/expyfun/analyze/tests/test_analyze_functions.py +++ b/expyfun/analyze/tests/test_analyze_functions.py @@ -2,6 +2,7 @@ import pytest from numpy.testing import assert_allclose, assert_array_equal + try: from scipy.special import logit as splogit except ImportError: @@ -18,10 +19,9 @@ def assert_rts_equal(actual, desired): assert isinstance(desired, (list, tuple)) assert len(actual) == 2 assert len(desired) == 2 - kinds = ['hits', 'fas'] + kinds = ["hits", "fas"] for act, des, kind in zip(actual, desired, kinds): - assert_allclose(act, des, atol=1e-7, - err_msg='{0} mismatch'.format(kind)) + assert_allclose(act, des, atol=1e-7, err_msg=f"{kind} mismatch") def assert_hmfc(presses, targets, foils, hmfco, rts, tmin=0.1, tmax=0.6): @@ -30,14 +30,16 @@ def assert_hmfc(presses, targets, foils, hmfco, rts, tmin=0.1, tmax=0.6): assert_array_equal(out, hmfco) out = ea.press_times_to_hmfc(presses, targets, foils, tmin, tmax) assert_array_equal(out, hmfco) - out = ea.press_times_to_hmfc(presses, targets, foils, tmin, tmax, - return_type=['counts', 'rts']) + out = ea.press_times_to_hmfc( + presses, targets, foils, tmin, tmax, return_type=["counts", "rts"] + ) assert_array_equal(out[0][:4:2], list(map(len, out[1]))) assert_array_equal(out[0], hmfco) assert_rts_equal(out[1], rts) # reversing targets and foils - out = ea.press_times_to_hmfc(presses, foils, targets, tmin, tmax, - return_type=['counts', 'rts']) + out = ea.press_times_to_hmfc( + presses, foils, targets, tmin, tmax, return_type=["counts", "rts"] + ) assert_array_equal(out[0], np.array(hmfco)[[2, 3, 0, 1, 4]]) assert_rts_equal(out[1], rts[::-1]) @@ -45,7 +47,7 @@ def assert_hmfc(presses, targets, foils, hmfco, rts, tmin=0.1, tmax=0.6): def test_presses_to_hmfc(): """Test converting press times to HMFCO and RTs.""" # Simple example - targets = [0., 1.] + targets = [0.0, 1.0] foils = [0.5, 1.5] presses = [0.1, 1.6] # presses right at tmin/tmax @@ -76,8 +78,8 @@ def test_presses_to_hmfc(): # A complicated example: multiple preses to targ targets = [0, 2, 3] foils = [1, 4] - tmin, tmax = 0., 0.5 - presses = [0.111, 0.2, 1.101, 1.3, 2.222, 2.333, 2.7, 5.] + tmin, tmax = 0.0, 0.5 + presses = [0.111, 0.2, 1.101, 1.3, 2.222, 2.333, 2.7, 5.0] hmfco = [2, 1, 1, 1, 2] rts = [[0.111, 0.222], [0.101]] assert_hmfc(presses, targets, foils, hmfco, rts) @@ -95,35 +97,37 @@ def test_presses_to_hmfc(): # lots of presses targets = [1, 2, 5, 6, 7] foils = [0, 3, 4, 8] - presses = [0.201, 2.101, 4.202, 5.102, 6.103, 10.] + presses = [0.201, 2.101, 4.202, 5.102, 6.103, 10.0] hmfco = [3, 2, 2, 2, 1] rts = [[0.101, 0.102, 0.103], [0.201, 0.202]] assert_hmfc(presses, targets, foils, hmfco, rts) # Bad inputs - pytest.raises(ValueError, ea.press_times_to_hmfc, - presses, targets, foils, tmin, 1.1) - pytest.raises(ValueError, ea.press_times_to_hmfc, - presses, targets, foils, tmin, tmax, 'foo') + pytest.raises( + ValueError, ea.press_times_to_hmfc, presses, targets, foils, tmin, 1.1 + ) + pytest.raises( + ValueError, ea.press_times_to_hmfc, presses, targets, foils, tmin, tmax, "foo" + ) def test_dprime(): """Test dprime accuracy.""" - with pytest.warns(RuntimeWarning, match='cast to'): - pytest.raises(IndexError, ea.dprime, 'foo') - pytest.raises(ValueError, ea.dprime, ['foo', 0, 0, 0]) - with pytest.warns(RuntimeWarning, match='truncated'): + with pytest.warns(RuntimeWarning, match="cast to"): + pytest.raises(IndexError, ea.dprime, "foo") + pytest.raises(ValueError, ea.dprime, ["foo", 0, 0, 0]) + with pytest.warns(RuntimeWarning, match="truncated"): ea.dprime((1.1, 0, 0, 0)) for resp, want in ( - ((1, 1, 1, 1), [0, 0]), - ((1, 0, 0, 1), [1.34898, 0.]), - ((0, 1, 0, 1), [0, 0.67449]), - ((0, 0, 1, 1), [0, 0]), - ((1, 0, 1, 0), [0, -0.67449]), - ((0, 1, 1, 0), [-1.34898, 0.]), - ((0, 1, 1, 0), [-1.34898, 0.])): - assert_allclose(ea.dprime(resp, return_bias=True), - want, atol=1e-5) + ((1, 1, 1, 1), [0, 0]), + ((1, 0, 0, 1), [1.34898, 0.0]), + ((0, 1, 0, 1), [0, 0.67449]), + ((0, 0, 1, 1), [0, 0]), + ((1, 0, 1, 0), [0, -0.67449]), + ((0, 1, 1, 0), [-1.34898, 0.0]), + ((0, 1, 1, 0), [-1.34898, 0.0]), + ): + assert_allclose(ea.dprime(resp, return_bias=True), want, atol=1e-5) assert_allclose([np.inf, -np.inf], ea.dprime((1, 0, 2, 1), False, True)) pytest.raises(ValueError, ea.dprime, np.ones((5, 4, 3))) pytest.raises(ValueError, ea.dprime, (1, 2, 3)) @@ -135,7 +139,7 @@ def test_logit(): """Test logit calculations.""" pytest.raises(ValueError, ea.logit, 2) # On some versions, this throws warnings about divide-by-zero - with np.errstate(divide='ignore'): + with np.errstate(divide="ignore"): assert ea.logit(0) == -np.inf assert ea.logit(1) == np.inf assert ea.logit(1, max_events=1) < np.inf @@ -156,14 +160,14 @@ def test_sigmoid(): """Test sigmoidal fitting and generation.""" n_pts = 1000 x = np.random.RandomState(0).randn(n_pts) - p0 = (0., 1., 0., 1.) + p0 = (0.0, 1.0, 0.0, 1.0) y = ea.sigmoid(x, *p0) assert np.all(np.logical_and(y <= 1, y >= 0)) p = ea.fit_sigmoid(x, y) assert_allclose(p, p0, atol=1e-4, rtol=1e-4) with warnings.catch_warnings(record=True): # scipy convergence - warnings.simplefilter('ignore') - p = ea.fit_sigmoid(x, y, (0, 1, None, None), ('upper', 'lower')) + warnings.simplefilter("ignore") + p = ea.fit_sigmoid(x, y, (0, 1, None, None), ("upper", "lower")) assert_allclose(p, p0, atol=1e-4, rtol=1e-4) y += np.random.rand(n_pts) * 0.01 @@ -175,13 +179,13 @@ def test_rt_chisq(): """Test reaction time chi-square fitting.""" # 1D should return single float foo = np.random.RandomState(0).rand(30) - pytest.raises(ValueError, ea.rt_chisq, foo - 1.) + pytest.raises(ValueError, ea.rt_chisq, foo - 1.0) assert_equal(np.array(ea.rt_chisq(foo, warn=False)).shape, ()) # 2D should return array with shape of input but without ``axis`` dimension foo = np.random.rand(30).reshape((2, 3, 5)) for axis in range(-1, foo.ndim): bar = ea.rt_chisq(foo, axis=axis, warn=False) assert_array_equal(np.delete(foo.shape, axis), np.array(bar.shape)) - foo_bad = np.concatenate((np.random.rand(30), [100.])) - with pytest.warns(UserWarning, match='likely bad'): + foo_bad = np.concatenate((np.random.rand(30), [100.0])) + with pytest.warns(UserWarning, match="likely bad"): bar = ea.rt_chisq(foo_bad) diff --git a/expyfun/analyze/tests/test_recon.py b/expyfun/analyze/tests/test_recon.py index e42187c9..35ae0c53 100644 --- a/expyfun/analyze/tests/test_recon.py +++ b/expyfun/analyze/tests/test_recon.py @@ -5,8 +5,7 @@ def test_restore(): - """Test restoring missing values - """ + """Test restoring missing values""" n = 20 x = np.arange(n, dtype=float) y = x * 10 - 1.5 @@ -14,7 +13,7 @@ def test_restore(): keep[[0, 4, -1]] = False missing = np.where(~keep)[0] keep = np.where(keep)[0] - y = x[keep] * 10. - 1.5 + y = x[keep] * 10.0 - 1.5 y2 = restore_values(x, y, missing)[0] - x2 = (y2 + 1.5) / 10. + x2 = (y2 + 1.5) / 10.0 assert_allclose(x, x2, atol=1e-7) diff --git a/expyfun/analyze/tests/test_viz.py b/expyfun/analyze/tests/test_viz.py index 739a51e6..2bbbcaf5 100644 --- a/expyfun/analyze/tests/test_viz.py +++ b/expyfun/analyze/tests/test_viz.py @@ -1,7 +1,7 @@ -import numpy as np -from os import path as op import warnings +from os import path as op +import numpy as np import pytest from numpy.testing import assert_equal @@ -13,16 +13,19 @@ def _check_warnings(w): """Silly helper to deal with MPL deprecation warnings.""" - assert all(['expyfun' not in ww.filename for ww in w]) + assert all(["expyfun" not in ww.filename for ww in w]) -@requires_lib('pandas') +@requires_lib("pandas") def test_barplot_with_pandas(): """Test bar plot function pandas support.""" import pandas as pd - tmp = pd.DataFrame(np.arange(20).reshape((4, 5)), - columns=['a', 'b', 'c', 'd', 'e'], - index=['one', 'two', 'three', 'four']) + + tmp = pd.DataFrame( + np.arange(20).reshape((4, 5)), + columns=["a", "b", "c", "d", "e"], + index=["one", "two", "three", "four"], + ) ea.barplot(tmp) ea.barplot(tmp, axis=0, lines=True) @@ -31,13 +34,14 @@ def test_barplot_with_pandas(): def tmp_err(): # noqa rng = np.random.RandomState(0) tmp = np.ones(4) + rng.rand(4) - err = 0.1 + tmp / 5. + err = 0.1 + tmp / 5.0 return tmp, err def test_barplot_degenerate(tmp_err): """Test bar plot degenerate cases.""" import matplotlib.pyplot as plt + tmp, err = tmp_err # too many data dimensions: pytest.raises(ValueError, ea.barplot, np.arange(8).reshape((2, 2, 2))) @@ -46,73 +50,111 @@ def test_barplot_degenerate(tmp_err): # shape mismatch between data & error bars: pytest.raises(ValueError, ea.barplot, tmp, err_bars=np.arange(3)) # bad err_bar string: - pytest.raises(ValueError, ea.barplot, tmp, err_bars='foo') + pytest.raises(ValueError, ea.barplot, tmp, err_bars="foo") # cannot calculate 'sd' error bars with only 1 value per bar: - pytest.raises(ValueError, ea.barplot, tmp, err_bars='sd') + pytest.raises(ValueError, ea.barplot, tmp, err_bars="sd") # mismatched lengths of brackets & bracket_text: - pytest.raises(ValueError, ea.barplot, tmp, brackets=[(0, 1)], - bracket_text=['foo', 'bar']) + pytest.raises( + ValueError, ea.barplot, tmp, brackets=[(0, 1)], bracket_text=["foo", "bar"] + ) # bad bracket spec: - pytest.raises(ValueError, ea.barplot, tmp, brackets=[(1,)], - bracket_text=['foo']) - plt.close('all') + pytest.raises(ValueError, ea.barplot, tmp, brackets=[(1,)], bracket_text=["foo"]) + plt.close("all") def test_barplot_single(tmp_err): """Test with single data point and single error bar spec.""" import matplotlib.pyplot as plt + tmp, err = tmp_err ea.barplot(2, err_bars=0.2) - plt.close('all') + plt.close("all") @pytest.mark.timeout(15) def test_barplot_single_spec(tmp_err): """Test with one data point per bar and user-specified err ranges.""" import matplotlib.pyplot as plt + tmp, err = tmp_err _, axs = plt.subplots(1, 5, sharey=False) - ea.barplot(tmp, err_bars=err, brackets=([2, 3], [0, 1]), ax=axs[0], - bracket_text=['foo', 'bar'], bracket_inline=True) - ea.barplot(tmp, err_bars=err, brackets=((0, 2), (1, 3)), ax=axs[1], - bracket_text=['foo', 'bar']) - ea.barplot(tmp, err_bars=err, brackets=[[2, 1], [0, 3]], ax=axs[2], - bracket_text=['foo', 'bar']) - ea.barplot(tmp, err_bars=err, brackets=[(0, 1), (0, 2), (0, 3)], - bracket_text=['foo', 'bar', 'baz'], ax=axs[3]) - ea.barplot(tmp, err_bars=err, brackets=[(0, 1), (2, 3), (0, 2), (1, 3)], - bracket_text=['foo', 'bar', 'baz', 'snafu'], ax=axs[4]) - ea.barplot(tmp, groups=[[0, 1, 2], [3]], eq_group_widths=True, - brackets=[(0, 1), (1, 2), ([0, 1, 2], 3)], - bracket_text=['foo', 'bar', 'baz'], - bracket_group_lines=True) - plt.close('all') + ea.barplot( + tmp, + err_bars=err, + brackets=([2, 3], [0, 1]), + ax=axs[0], + bracket_text=["foo", "bar"], + bracket_inline=True, + ) + ea.barplot( + tmp, + err_bars=err, + brackets=((0, 2), (1, 3)), + ax=axs[1], + bracket_text=["foo", "bar"], + ) + ea.barplot( + tmp, + err_bars=err, + brackets=[[2, 1], [0, 3]], + ax=axs[2], + bracket_text=["foo", "bar"], + ) + ea.barplot( + tmp, + err_bars=err, + brackets=[(0, 1), (0, 2), (0, 3)], + bracket_text=["foo", "bar", "baz"], + ax=axs[3], + ) + ea.barplot( + tmp, + err_bars=err, + brackets=[(0, 1), (2, 3), (0, 2), (1, 3)], + bracket_text=["foo", "bar", "baz", "snafu"], + ax=axs[4], + ) + ea.barplot( + tmp, + groups=[[0, 1, 2], [3]], + eq_group_widths=True, + brackets=[(0, 1), (1, 2), ([0, 1, 2], 3)], + bracket_text=["foo", "bar", "baz"], + bracket_group_lines=True, + ) + plt.close("all") @pytest.mark.timeout(10) def test_barplot_multiple(): """Test with multiple data points per bar and calculated ranges.""" import matplotlib.pyplot as plt + rng = np.random.RandomState(0) tmp = (rng.randn(20) + np.arange(20)).reshape((5, 4)) # 2-dim _, axs = plt.subplots(1, 4, sharey=False) - ea.barplot(tmp, lines=True, err_bars='sd', ax=axs[0], smart_defaults=False) - ea.barplot(tmp, lines=True, err_bars='ci', ax=axs[1], axis=0) - ea.barplot(tmp, lines=True, err_bars='se', ax=axs[2], ylim=(0, 30)) - ea.barplot(tmp, lines=True, err_bars='se', ax=axs[3], - groups=[[0, 1, 2], [3, 4]], bracket_group_lines=True, - brackets=[(0, 1), (1, 2), (3, 4), ([0, 1, 2], [3, 4])], - bracket_text=['foo', 'bar', 'baz', 'snafu']) - extns = ['pdf'] # jpg, tif not supported; 'png', 'raw', 'svg' not tested + ea.barplot(tmp, lines=True, err_bars="sd", ax=axs[0], smart_defaults=False) + ea.barplot(tmp, lines=True, err_bars="ci", ax=axs[1], axis=0) + ea.barplot(tmp, lines=True, err_bars="se", ax=axs[2], ylim=(0, 30)) + ea.barplot( + tmp, + lines=True, + err_bars="se", + ax=axs[3], + groups=[[0, 1, 2], [3, 4]], + bracket_group_lines=True, + brackets=[(0, 1), (1, 2), (3, 4), ([0, 1, 2], [3, 4])], + bracket_text=["foo", "bar", "baz", "snafu"], + ) + extns = ["pdf"] # jpg, tif not supported; 'png', 'raw', 'svg' not tested for ext in extns: - fname = op.join(temp_dir, 'temp.' + ext) + fname = op.join(temp_dir, "temp." + ext) with warnings.catch_warnings(record=True) as w: - warnings.simplefilter('always') - ea.barplot(tmp, groups=[[0, 1, 2], [3]], err_bars='sd', axis=0, - fname=fname) + warnings.simplefilter("always") + ea.barplot(tmp, groups=[[0, 1, 2], [3]], err_bars="sd", axis=0, fname=fname) plt.close() _check_warnings(w) - plt.close('all') + plt.close("all") def test_plot_screen(): @@ -126,10 +168,10 @@ def test_plot_screen(): def test_format_pval(): """Test p-value formatting.""" foo = ea.format_pval(1e-10, latex=False) - bar = ea.format_pval(1e-10, scheme='ross') + bar = ea.format_pval(1e-10, scheme="ross") baz = ea.format_pval([0.2, 0.02]) - qux = ea.format_pval(0.002, scheme='stars') - assert_equal(foo, 'p < 10^-9') - assert_equal(bar, '$p < 10^{{-9}}$') - assert_equal(baz[0], '$n.s.$') - assert_equal(qux, '${*}{*}$') + qux = ea.format_pval(0.002, scheme="stars") + assert_equal(foo, "p < 10^-9") + assert_equal(bar, "$p < 10^{{-9}}$") + assert_equal(baz[0], "$n.s.$") + assert_equal(qux, "${*}{*}$") diff --git a/expyfun/codeblocks/__init__.py b/expyfun/codeblocks/__init__.py index 6f3798e6..342c3d60 100644 --- a/expyfun/codeblocks/__init__.py +++ b/expyfun/codeblocks/__init__.py @@ -8,5 +8,4 @@ # Copyright (c) 2014, LABSN. # Distributed under the (new) BSD License. See LICENSE.txt for more info. -from ._pupillometry import (find_pupil_dynamic_range, - find_pupil_tone_impulse_response) +from ._pupillometry import find_pupil_dynamic_range, find_pupil_tone_impulse_response diff --git a/expyfun/codeblocks/_pupillometry.py b/expyfun/codeblocks/_pupillometry.py index 3477d769..fe2946a6 100644 --- a/expyfun/codeblocks/_pupillometry.py +++ b/expyfun/codeblocks/_pupillometry.py @@ -1,12 +1,11 @@ -"""Analysis functions (mostly for psychophysics data). -""" +"""Analysis functions (mostly for psychophysics data).""" import numpy as np -from ..visual import FixationDot -from ..analyze import sigmoid from .._utils import logger, verbose_dec +from ..analyze import sigmoid from ..stimuli import window_edges +from ..visual import FixationDot def _check_pyeparse(): @@ -20,12 +19,13 @@ def _check_pyeparse(): def _load_raw(el, fname): """Helper to load some pupil data""" import pyeparse + fname = el.transfer_remote_file(fname) # Load and parse data - logger.info('Pupillometry: Parsing local file "{0}"'.format(fname)) + logger.info(f'Pupillometry: Parsing local file "{fname}"') raw = pyeparse.RawEDF(fname) raw.remove_blink_artifacts() - events = raw.find_events('SYNCTIME', 1) + events = raw.find_events("SYNCTIME", 1) return raw, events @@ -61,14 +61,17 @@ def find_pupil_dynamic_range(ec, el, prompt=True, verbose=None): """ _check_pyeparse() import pyeparse + if el.recording: el.stop() el.calibrate() if prompt: - ec.screen_prompt('We will now determine the dynamic ' - 'range of your pupil.\n\n' - 'Press a button to continue.') - levels = np.concatenate(([0.], 2 ** np.arange(8) / 255.)) + ec.screen_prompt( + "We will now determine the dynamic " + "range of your pupil.\n\n" + "Press a button to continue." + ) + levels = np.concatenate(([0.0], 2 ** np.arange(8) / 255.0)) fixs = levels + 0.2 n_rep = 2 # inter-rep interval (allow system to reset) @@ -76,15 +79,14 @@ def find_pupil_dynamic_range(ec, el, prompt=True, verbose=None): # amount of time between levels settle_time = 3.0 if not el.dummy_mode else 0.3 fix = FixationDot(ec) - fix.set_colors([fixs[0] * np.ones(3), 'k']) - ec.set_background_color('k') + fix.set_colors([fixs[0] * np.ones(3), "k"]) + ec.set_background_color("k") fix.draw() ec.flip() for ri in range(n_rep): ec.wait_secs(iri) for ii, (lev, fc) in enumerate(zip(levels, fixs)): - ec.identify_trial(ec_id='FPDR_%02i' % (ii + 1), - el_id=[ii + 1], ttl_id=()) + ec.identify_trial(ec_id="FPDR_%02i" % (ii + 1), el_id=[ii + 1], ttl_id=()) bgcolor = np.ones(3) * lev fcolor = np.ones(3) * fc ec.set_background_color(bgcolor) @@ -95,13 +97,12 @@ def find_pupil_dynamic_range(ec, el, prompt=True, verbose=None): ec.check_force_quit() ec.stop() ec.trial_ok() - ec.set_background_color('k') - fix.set_colors([fixs[0] * np.ones(3), 'k']) + ec.set_background_color("k") + fix.set_colors([fixs[0] * np.ones(3), "k"]) fix.draw() ec.flip() el.stop() # stop the recording - ec.screen_prompt('Processing data, please wait...', max_wait=0, - clear_after=False) + ec.screen_prompt("Processing data, please wait...", max_wait=0, clear_after=False) # now we need to parse the data if el.dummy_mode: @@ -115,17 +116,18 @@ def find_pupil_dynamic_range(ec, el, prompt=True, verbose=None): epochs = pyeparse.Epochs(raw, events, 1, -0.5, settle_time) assert len(epochs) == len(levels) * n_rep idx = epochs.n_times // 2 - resp = np.median(epochs.get_data('ps')[:, idx:], 1) + resp = np.median(epochs.get_data("ps")[:, idx:], 1) bgcolor = np.mean(resp.reshape((n_rep, len(levels))), 0) idx = np.argmin(np.diff(bgcolor)) + 1 bgcolor = levels[idx] * np.ones(3) fcolor = fixs[idx] * np.ones(3) - logger.info('Pupillometry: optimal background color {0}'.format(bgcolor)) + logger.info(f"Pupillometry: optimal background color {bgcolor}") return bgcolor, fcolor, np.tile(levels, n_rep), resp -def find_pupil_tone_impulse_response(ec, el, bgcolor, fcolor, prompt=True, - verbose=None, targ_is_fm=True): +def find_pupil_tone_impulse_response( + ec, el, bgcolor, fcolor, prompt=True, verbose=None, targ_is_fm=True +): """Find pupil impulse response using responses to tones Parameters @@ -162,6 +164,7 @@ def find_pupil_tone_impulse_response(ec, el, bgcolor, fcolor, prompt=True, """ _check_pyeparse() import pyeparse + if el.recording: el.stop() @@ -175,7 +178,7 @@ def find_pupil_tone_impulse_response(ec, el, bgcolor, fcolor, prompt=True, delay_range = np.array(delay_range) targ_prop = 0.25 stim_dur = 100e-3 - f0 = 1000. # Hz + f0 = 1000.0 # Hz rng = np.random.RandomState(0) isis = np.linspace(*delay_range, num=n_stimuli) @@ -196,8 +199,9 @@ def find_pupil_tone_impulse_response(ec, el, bgcolor, fcolor, prompt=True, n_samp = int(fs * stim_dur) t = np.arange(n_samp).astype(float) / fs steady = np.sin(2 * np.pi * f0 * t) - wobble = np.sin(np.cumsum(f0 + 100 * np.sin(2 * np.pi * (1 / stim_dur) * t) - ) / fs * 2 * np.pi) + wobble = np.sin( + np.cumsum(f0 + 100 * np.sin(2 * np.pi * (1 / stim_dur) * t)) / fs * 2 * np.pi + ) std_stim, dev_stim = (steady, wobble) if targ_is_fm else (wobble, steady) std_stim = window_edges(std_stim * ec._stim_rms * np.sqrt(2), fs) dev_stim = window_edges(dev_stim * ec._stim_rms * np.sqrt(2), fs) @@ -207,17 +211,23 @@ def find_pupil_tone_impulse_response(ec, el, bgcolor, fcolor, prompt=True, # ec.stop() ec.set_background_color(bgcolor) - targstr, tonestr = ('wobble', 'beep') if targ_is_fm else ('beep', 'wobble') - instr = ('Remember to press the button as quickly as possible following ' - 'each "{}" sound.\n\nPress the response button to ' - 'continue.'.format(targstr)) + targstr, tonestr = ("wobble", "beep") if targ_is_fm else ("beep", "wobble") + instr = ( + "Remember to press the button as quickly as possible following " + f'each "{targstr}" sound.\n\nPress the response button to ' + "continue." + ) if prompt: - notes = [('We will now determine the response of your pupil to sound ' - 'changes.\n\nYour job is to press the response button ' - 'as quickly as possible when you hear a "{1}" instead ' - 'of a "{0}".\n\nPress a button to hear the "{0}".' - ''.format(tonestr, targstr)), - ('Now press a button to hear the "{}".'.format(targstr))] + notes = [ + ( + "We will now determine the response of your pupil to sound " + "changes.\n\nYour job is to press the response button " + f'as quickly as possible when you hear a "{targstr}" instead ' + f'of a "{tonestr}".\n\nPress a button to hear the "{tonestr}".' + "" + ), + (f'Now press a button to hear the "{targstr}".'), + ] for text, stim in zip(notes, (std_stim, dev_stim)): ec.screen_prompt(text) ec.load_buffer(stim) @@ -235,10 +245,12 @@ def find_pupil_tone_impulse_response(ec, el, bgcolor, fcolor, prompt=True, if ii in cal_stim: if ii != 0: el.stop() - perc = round((100. * ii) / n_stimuli) - ec.screen_prompt('Great work! You are {0}% done.\n\nFeel ' - 'free to take a break, then press the ' - 'button to continue.'.format(perc)) + perc = round((100.0 * ii) / n_stimuli) + ec.screen_prompt( + f"Great work! You are {perc}% done.\n\nFeel " + "free to take a break, then press the " + "button to continue." + ) el.calibrate() ec.screen_prompt(instr) # let's put the initial color up to allow the system to settle @@ -247,15 +259,15 @@ def find_pupil_tone_impulse_response(ec, el, bgcolor, fcolor, prompt=True, ec.wait_secs(10.0) # let the pupil settle fix.draw() ec.load_buffer(dev_stim if targ else std_stim) - ec.identify_trial(ec_id='TONE_{0}'.format(int(targ)), - el_id=[int(targ)], ttl_id=[int(targ)]) + ec.identify_trial( + ec_id=f"TONE_{int(targ)}", el_id=[int(targ)], ttl_id=[int(targ)] + ) flip_times.append(ec.start_stimulus()) presses.append(ec.wait_for_presses(isi)) ec.stop() ec.trial_ok() el.stop() # stop the recording - ec.screen_prompt('Processing data, please wait...', max_wait=0, - clear_after=False) + ec.screen_prompt("Processing data, please wait...", max_wait=0, clear_after=False) flip_times = np.array(flip_times) tmin = -0.5 @@ -275,8 +287,7 @@ def find_pupil_tone_impulse_response(ec, el, bgcolor, fcolor, prompt=True, raws.append(raw) events.append(event) assert sum(len(event) for event in events) == n_stimuli - epochs = pyeparse.Epochs(raws, events, 1, - tmin=tmin, tmax=delay_range[0]) + epochs = pyeparse.Epochs(raws, events, 1, tmin=tmin, tmax=delay_range[0]) response = epochs.pupil_zscores() assert response.shape[0] == n_stimuli std_err = np.std(response[~targs], axis=0) diff --git a/expyfun/conftest.py b/expyfun/conftest.py index 6fda1829..f2a38734 100644 --- a/expyfun/conftest.py +++ b/expyfun/conftest.py @@ -1,12 +1,13 @@ -# -*- coding: utf-8 -*- # Author: Eric Larson # # License: BSD (3-clause) import os + import pytest -from expyfun._utils import _get_display + from expyfun._sound_controllers import _AUTO_BACKENDS +from expyfun._utils import _get_display # Unknown pytest problem with readline<->deprecated decorator try: @@ -15,40 +16,43 @@ pass -@pytest.mark.timeout(0) # importing plt will build font cache, slow on Azure -@pytest.fixture(scope='session') +@pytest.fixture(scope="session") def matplotlib_config(): """Configure matplotlib for viz tests.""" import matplotlib - matplotlib.use('agg') # don't pop up windows + + matplotlib.use("agg") # don't pop up windows import matplotlib.pyplot as plt - assert plt.get_backend() == 'agg' + + assert plt.get_backend() == "agg" # overwrite some params that can horribly slow down tests that # users might have changed locally (but should not otherwise affect # functionality) plt.ioff() - plt.rcParams['figure.dpi'] = 100 - os.environ['_EXPYFUN_WIN_INVISIBLE'] = 'true' + plt.rcParams["figure.dpi"] = 100 + os.environ["_EXPYFUN_WIN_INVISIBLE"] = "true" -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def hide_window(): """Hide the expyfun window.""" try: _get_display() except Exception as exp: - pytest.skip('Windowing unavailable (%s)' % exp) + pytest.skip("Windowing unavailable (%s)" % exp) -_SOUND_CARD_ACS = tuple({'TYPE': 'sound_card', 'SOUND_CARD_BACKEND': backend} - for backend in _AUTO_BACKENDS) +_SOUND_CARD_ACS = tuple( + {"TYPE": "sound_card", "SOUND_CARD_BACKEND": backend} for backend in _AUTO_BACKENDS +) for val in _SOUND_CARD_ACS: - if val['SOUND_CARD_BACKEND'] == 'pyglet': - val.update(SOUND_CARD_API=None, SOUND_CARD_NAME=None, - SOUND_CARD_FIXED_DELAY=None) + if val["SOUND_CARD_BACKEND"] == "pyglet": + val.update( + SOUND_CARD_API=None, SOUND_CARD_NAME=None, SOUND_CARD_FIXED_DELAY=None + ) -@pytest.fixture(scope="module", params=('tdt',) + _SOUND_CARD_ACS) +@pytest.fixture(scope="module", params=("tdt",) + _SOUND_CARD_ACS) def ac(request): """Get the backend name.""" yield request.param diff --git a/expyfun/io/__init__.py b/expyfun/io/__init__.py index c655b909..ae3fe8e5 100644 --- a/expyfun/io/__init__.py +++ b/expyfun/io/__init__.py @@ -4,12 +4,10 @@ File reading and writing routines. """ -# -*- coding: utf-8 -*- +from h5io import read_hdf5 as _read_hdf5, write_hdf5 as _write_hdf5 + from ._wav import read_wav, write_wav -from .._externals._h5io import (read_hdf5 as _read_hdf5, - write_hdf5 as _write_hdf5) -from ._parse import (read_tab, reconstruct_tracker, - reconstruct_dealer, read_tab_raw) +from ._parse import read_tab, reconstruct_tracker, reconstruct_dealer, read_tab_raw def read_hdf5(fname): @@ -29,7 +27,7 @@ def read_hdf5(fname): -------- write_hdf5 """ - return _read_hdf5(fname, title='expyfun') + return _read_hdf5(fname, title="expyfun") def write_hdf5(fname, data, overwrite=False, compression=4): @@ -54,4 +52,4 @@ def write_hdf5(fname, data, overwrite=False, compression=4): -------- read_hdf5 """ - return _write_hdf5(fname, data, overwrite, compression, title='expyfun') + return _write_hdf5(fname, data, overwrite, compression, title="expyfun") diff --git a/expyfun/io/_parse.py b/expyfun/io/_parse.py index 2729be71..ea8a0a8a 100644 --- a/expyfun/io/_parse.py +++ b/expyfun/io/_parse.py @@ -1,11 +1,9 @@ -# -*- coding: utf-8 -*- -"""File parsing functions -""" +"""File parsing functions""" import ast -from collections import OrderedDict import csv import json +from collections import OrderedDict import numpy as np @@ -33,23 +31,21 @@ def read_tab_raw(fname, return_params=False): -------- read_tab """ - with open(fname, 'r') as f: - csvr = csv.reader(f, delimiter='\t') + with open(fname) as f: + csvr = csv.reader(f, delimiter="\t") lines = [c for c in csvr] # first two lines are headers - assert len(lines[0]) == 1 and lines[0][0].startswith('# ') + assert len(lines[0]) == 1 and lines[0][0].startswith("# ") if return_params: line = lines[0][0][2:] try: - params = json.loads( - line, object_pairs_hook=OrderedDict) + params = json.loads(line, object_pairs_hook=OrderedDict) except json.decoder.JSONDecodeError: # old format - params = json.loads( - line.replace("'", '"'), object_pairs_hook=OrderedDict) + params = json.loads(line.replace("'", '"'), object_pairs_hook=OrderedDict) else: params = None - assert lines[1] == ['timestamp', 'event', 'value'] + assert lines[1] == ["timestamp", "event", "value"] lines = lines[2:] times = [float(line[0]) for line in lines] @@ -59,8 +55,13 @@ def read_tab_raw(fname, return_params=False): return (data, params) if return_params else data -def read_tab(fname, group_start='trial_id', group_end='trial_ok', - return_params=False, allow_last_missing=False): +def read_tab( + fname, + group_start="trial_id", + group_end="trial_ok", + return_params=False, + allow_last_missing=False, +): """Read .tab file from expyfun output and segment into trials. Parameters @@ -100,28 +101,24 @@ def read_tab(fname, group_start='trial_id', group_end='trial_ok', header = list(set([line[1] for line in lines])) header.sort() if group_start not in header: - raise ValueError('group_start "{0}" not in header: {1}' - ''.format(group_start, header)) + raise ValueError(f'group_start "{group_start}" not in header: {header}' "") if group_end == group_start: - raise ValueError('group_start cannot equal group_end, use ' - 'group_end=None') + raise ValueError("group_start cannot equal group_end, use " "group_end=None") header = [header.pop(header.index(group_start))] + header b1s = np.where([line[1] == group_start for line in lines])[0] if group_end is None: b2s = np.concatenate((b1s[1:], [len(lines)])) else: # group_end is not None if group_end not in header: - raise ValueError('group_end "{0}" not in header ({1})' - ''.format(group_end, header)) + raise ValueError(f'group_end "{group_end}" not in header ({header})' "") header.append(header.pop(header.index(group_end))) b2s = np.where([line[1] == group_end for line in lines])[0] if len(b1s) == len(b2s) + 1 and allow_last_missing: # old expyfun would sometimes not write the last trial_ok :( b2s = np.concatenate([b2s, [len(lines)]]) - lines.append((lines[-1][0] + 0.1, group_end, 'None')) + lines.append((lines[-1][0] + 0.1, group_end, "None")) if len(b1s) != len(b2s) or not np.all(b1s < b2s): - raise RuntimeError('bad bounds in {0}:\n{1}\n{2}' - .format(fname, b1s, b2s)) + raise RuntimeError(f"bad bounds in {fname}:\n{b1s}\n{b2s}") data = [] for b1, b2 in zip(b1s, b2s): assert lines[b1][1] == group_start # prevent stupidity @@ -155,40 +152,42 @@ def reconstruct_tracker(fname): the generation of the file.) If only one tracker is found in the file, it will still be stored in a list and will be accessible as ``tr[0]``. """ - from ..stimuli import TrackerUD, TrackerBinom, TrackerMHW + from ..stimuli import TrackerBinom, TrackerMHW, TrackerUD + # read in raw data raw = read_tab_raw(fname) # find tracker_identify and make list of IDs - tracker_idx = np.where([r[1] == 'tracker_identify' for r in raw])[0] + tracker_idx = np.where([r[1] == "tracker_identify" for r in raw])[0] if len(tracker_idx) == 0: - raise ValueError('There are no Trackers in this file.') + raise ValueError("There are no Trackers in this file.") tr = [] used_dict_idx = [] # they can have repeat names! used_stop_idx = [] for ii in tracker_idx: - tracker_id = ast.literal_eval(raw[ii][2])['tracker_id'] - tracker_type = ast.literal_eval(raw[ii][2])['tracker_type'] + tracker_id = ast.literal_eval(raw[ii][2])["tracker_id"] + tracker_type = ast.literal_eval(raw[ii][2])["tracker_type"] # find tracker_ID_init lines and get dict - init_str = 'tracker_' + str(tracker_id) + '_init' + init_str = "tracker_" + str(tracker_id) + "_init" tracker_dict_idx = np.where([r[1] == init_str for r in raw])[0] tracker_dict_idx = np.setdiff1d(tracker_dict_idx, used_dict_idx) tracker_dict_idx = tracker_dict_idx[0] used_dict_idx.append(tracker_dict_idx) tracker_dict = json.loads(raw[tracker_dict_idx][2]) - td = dict(TrackerUD=TrackerUD, TrackerBinom=TrackerBinom, - TrackerMHW=TrackerMHW) + td = dict(TrackerUD=TrackerUD, TrackerBinom=TrackerBinom, TrackerMHW=TrackerMHW) tr.append(td[tracker_type](**tracker_dict)) tr[-1]._tracker_id = tracker_id # make sure tracker has original ID - stop_str = 'tracker_' + str(tracker_id) + '_stop' + stop_str = "tracker_" + str(tracker_id) + "_stop" tracker_stop_idx = np.where([r[1] == stop_str for r in raw])[0] tracker_stop_idx = np.setdiff1d(tracker_stop_idx, used_stop_idx) if len(tracker_stop_idx) == 0: - raise ValueError('Tracker {} has not stopped. All Trackers ' - 'must be stopped.'.format(tracker_id)) + raise ValueError( + f"Tracker {tracker_id} has not stopped. All Trackers " + "must be stopped." + ) tracker_stop_idx = tracker_stop_idx[0] used_stop_idx.append(tracker_stop_idx) - responses = json.loads(raw[tracker_stop_idx][2])['responses'] + responses = json.loads(raw[tracker_stop_idx][2])["responses"] # feed in responses from tracker_ID_stop for r in responses: tr[-1].respond(r) @@ -214,20 +213,20 @@ def reconstruct_dealer(fname): still be stored in a list and will be assessible as ``td[0]``. """ from ..stimuli import TrackerDealer + raw = read_tab_raw(fname) # find info on dealer - dealer_idx = np.where([r[1] == 'dealer_identify' for r in raw])[0] + dealer_idx = np.where([r[1] == "dealer_identify" for r in raw])[0] if len(dealer_idx) == 0: - raise ValueError('There are no TrackerDealers in this file.') + raise ValueError("There are no TrackerDealers in this file.") dealer = [] for ii in dealer_idx: - dealer_id = ast.literal_eval(raw[ii][2])['dealer_id'] - dealer_init_str = 'dealer_' + str(dealer_id) + '_init' - dealer_dict_idx = np.where([r[1] == dealer_init_str - for r in raw])[0][0] + dealer_id = ast.literal_eval(raw[ii][2])["dealer_id"] + dealer_init_str = "dealer_" + str(dealer_id) + "_init" + dealer_dict_idx = np.where([r[1] == dealer_init_str for r in raw])[0][0] dealer_dict = ast.literal_eval(raw[dealer_dict_idx][2]) - dealer_trackers = dealer_dict['trackers'] + dealer_trackers = dealer_dict["trackers"] # match up tracker objects to id trackers = reconstruct_tracker(fname) @@ -237,22 +236,24 @@ def reconstruct_dealer(fname): tr_objects.append(trackers[idx]) # make the dealer object - max_lag = dealer_dict['max_lag'] - pace_rule = dealer_dict['pace_rule'] + max_lag = dealer_dict["max_lag"] + pace_rule = dealer_dict["pace_rule"] dealer.append(TrackerDealer(None, tr_objects, max_lag, pace_rule)) # force input responses/log data - dealer_stop_str = 'dealer_' + str(dealer_id) + '_stop' + dealer_stop_str = "dealer_" + str(dealer_id) + "_stop" dealer_stop_idx = np.where([r[1] == dealer_stop_str for r in raw])[0] if len(dealer_stop_idx) == 0: - raise ValueError('TrackerDealer {} has not stopped. All dealers ' - 'must be stopped.'.format(dealer_id)) + raise ValueError( + f"TrackerDealer {dealer_id} has not stopped. All dealers " + "must be stopped." + ) dealer_stop_log = json.loads(raw[dealer_stop_idx[0]][2]) - shape = tuple(dealer_dict['shape']) - log_response_history = dealer_stop_log['response_history'] - log_x_history = dealer_stop_log['x_history'] - log_tracker_history = dealer_stop_log['tracker_history'] + shape = tuple(dealer_dict["shape"]) + log_response_history = dealer_stop_log["response_history"] + log_x_history = dealer_stop_log["x_history"] + log_tracker_history = dealer_stop_log["tracker_history"] dealer[-1]._shape = shape dealer[-1]._trackers.shape = shape diff --git a/expyfun/io/_wav.py b/expyfun/io/_wav.py index e888a1e1..3daf2ef1 100644 --- a/expyfun/io/_wav.py +++ b/expyfun/io/_wav.py @@ -1,13 +1,12 @@ -# -*- coding: utf-8 -*- -"""WAV file IO functions -""" +"""WAV file IO functions""" + +import warnings +from os import path as op import numpy as np from scipy.io import wavfile -from os import path as op -import warnings -from .._utils import verbose_dec, logger, _has_scipy_version +from .._utils import _has_scipy_version, logger, verbose_dec @verbose_dec @@ -36,7 +35,7 @@ def read_wav(fname, verbose=None): orig_dtype = data.dtype max_val = _get_dtype_norm(orig_dtype) data = np.ascontiguousarray(data.astype(np.float64) / max_val) - _print_wav_info('Read', data, orig_dtype) + _print_wav_info("Read", data, orig_dtype) return data, fs @@ -61,26 +60,30 @@ def write_wav(fname, data, fs, dtype=np.int16, overwrite=False, verbose=None): If not None, override default verbose level. """ if not overwrite and op.isfile(fname): - raise IOError('File {} exists, overwrite=True must be ' - 'used'.format(op.basename(fname))) - if not np.dtype(type(fs)).kind == 'i': + raise OSError( + f"File {op.basename(fname)} exists, overwrite=True must be " "used" + ) + if not np.dtype(type(fs)).kind == "i": fs = int(fs) - warnings.warn('Warning: sampling rate is being cast to integer and ' - 'may be truncated.') + warnings.warn( + "Warning: sampling rate is being cast to integer and " "may be truncated." + ) data = np.atleast_2d(data) - if np.dtype(dtype).kind not in ['i', 'f']: - raise TypeError('dtype must be integer or float') - if np.dtype(dtype).kind == 'f': - if not _has_scipy_version('0.13'): - raise RuntimeError('cannot write float datatype unless ' - 'scipy >= 0.13 is installed') + if np.dtype(dtype).kind not in ["i", "f"]: + raise TypeError("dtype must be integer or float") + if np.dtype(dtype).kind == "f": + if not _has_scipy_version("0.13"): + raise RuntimeError( + "cannot write float datatype unless " "scipy >= 0.13 is installed" + ) elif np.dtype(dtype).itemsize == 8: - raise RuntimeError('Writing 64-bit integers is not supported') - if np.dtype(data.dtype).kind == 'f': - if np.dtype(dtype).kind == 'i' and np.max(np.abs(data)) > 1.: - raise ValueError('Data must be between -1 and +1 when saving ' - 'with an integer dtype') - _print_wav_info('Writing', data, dtype) + raise RuntimeError("Writing 64-bit integers is not supported") + if np.dtype(data.dtype).kind == "f": + if np.dtype(dtype).kind == "i" and np.max(np.abs(data)) > 1.0: + raise ValueError( + "Data must be between -1 and +1 when saving " "with an integer dtype" + ) + _print_wav_info("Writing", data, dtype) max_val = _get_dtype_norm(dtype) data = (data * max_val).astype(dtype) wavfile.write(fname, fs, data.T) @@ -88,15 +91,16 @@ def write_wav(fname, data, fs, dtype=np.int16, overwrite=False, verbose=None): def _print_wav_info(pre, data, dtype): """Helper to print WAV info""" - logger.info('{0} WAV file with {1} channel{3} and {2} samples ' - '(format {4})'.format(pre, data.shape[0], data.shape[1], - 's' if data.shape[0] != 1 else '', - dtype)) + logger.info( + "{0} WAV file with {1} channel{3} and {2} samples " "(format {4})".format( + pre, data.shape[0], data.shape[1], "s" if data.shape[0] != 1 else "", dtype + ) + ) def _get_dtype_norm(dtype): """Helper to get normalization factor for a given datatype""" - if np.dtype(dtype).kind == 'i': + if np.dtype(dtype).kind == "i": info = np.iinfo(dtype) maxval = min(-info.min, info.max) else: # == 'f' diff --git a/expyfun/io/tests/test_parse.py b/expyfun/io/tests/test_parse.py index 93a2cc7f..17f33ecd 100644 --- a/expyfun/io/tests/test_parse.py +++ b/expyfun/io/tests/test_parse.py @@ -3,71 +3,78 @@ from numpy.testing import assert_equal from expyfun import ExperimentController, __version__ -from expyfun.io import read_tab, reconstruct_tracker, reconstruct_dealer from expyfun._utils import _TempDir -from expyfun.stimuli import TrackerUD, TrackerBinom, TrackerDealer +from expyfun.io import read_tab, reconstruct_dealer, reconstruct_tracker +from expyfun.stimuli import TrackerBinom, TrackerDealer, TrackerUD temp_dir = _TempDir() -std_args = ['test'] # experiment name -std_kwargs = dict(output_dir=temp_dir, full_screen=False, window_size=(1, 1), - participant='foo', session='01', stim_db=0.0, noise_db=0.0, - verbose=True, version='dev') +std_args = ["test"] # experiment name +std_kwargs = dict( + output_dir=temp_dir, + full_screen=False, + window_size=(1, 1), + participant="foo", + session="01", + stim_db=0.0, + noise_db=0.0, + verbose=True, + version="dev", +) @pytest.mark.timeout(20) def test_parse_basic(hide_window, tmpdir): """Test .tab parsing.""" with ExperimentController(*std_args, **std_kwargs) as ec: - ec.identify_trial(ec_id='one', ttl_id=[0]) + ec.identify_trial(ec_id="one", ttl_id=[0]) ec.start_stimulus() - ec.write_data_line('misc', 'trial one') + ec.write_data_line("misc", "trial one") ec.stop() ec.trial_ok() - ec.write_data_line('misc', 'between trials') - ec.identify_trial(ec_id='two', ttl_id=[1]) + ec.write_data_line("misc", "between trials") + ec.identify_trial(ec_id="two", ttl_id=[1]) ec.start_stimulus() - ec.write_data_line('misc', 'trial two') + ec.write_data_line("misc", "trial two") ec.stop() ec.trial_ok() - ec.write_data_line('misc', 'end of experiment') + ec.write_data_line("misc", "end of experiment") - pytest.raises(ValueError, read_tab, ec.data_fname, group_start='foo') - pytest.raises(ValueError, read_tab, ec.data_fname, group_end='foo') - pytest.raises(ValueError, read_tab, ec.data_fname, group_end='trial_id') - pytest.raises(RuntimeError, read_tab, ec.data_fname, group_end='misc') + pytest.raises(ValueError, read_tab, ec.data_fname, group_start="foo") + pytest.raises(ValueError, read_tab, ec.data_fname, group_end="foo") + pytest.raises(ValueError, read_tab, ec.data_fname, group_end="trial_id") + pytest.raises(RuntimeError, read_tab, ec.data_fname, group_end="misc") data = read_tab(ec.data_fname) keys = list(data[0].keys()) assert_equal(len(keys), 6) - for key in ['trial_id', 'flip', 'play', 'stop', 'misc', 'trial_ok']: + for key in ["trial_id", "flip", "play", "stop", "misc", "trial_ok"]: assert key in keys - assert_equal(len(data[0]['misc']), 1) - assert_equal(len(data[1]['misc']), 1) + assert_equal(len(data[0]["misc"]), 1) + assert_equal(len(data[1]["misc"]), 1) data, params = read_tab(ec.data_fname, group_end=None, return_params=True) - assert_equal(len(data[0]['misc']), 2) # includes between-trials stuff - assert_equal(len(data[1]['misc']), 2) - assert_equal(params['version'], 'dev') - assert_equal(params['version_used'], __version__) - assert (params['file'].endswith('test_parse.py')) + assert_equal(len(data[0]["misc"]), 2) # includes between-trials stuff + assert_equal(len(data[1]["misc"]), 2) + assert_equal(params["version"], "dev") + assert_equal(params["version_used"], __version__) + assert params["file"].endswith("test_parse.py") # handle old files where the last trial_ok was missing - bad_fname = str(tmpdir.join('bad.tab')) - with open(ec.data_fname, 'r') as fid: + bad_fname = str(tmpdir.join("bad.tab")) + with open(ec.data_fname) as fid: lines = fid.readlines() - assert 'trial_ok' in lines[-3] - with open(bad_fname, 'w') as fid: + assert "trial_ok" in lines[-3] + with open(bad_fname, "w") as fid: # we used to write JSON badly fid.write(lines[0].replace('"', "'")) # and then sometimes missed the last trial_ok for line in lines[1:-3]: fid.write(line) - with pytest.raises(RuntimeError, match='bad bounds'): + with pytest.raises(RuntimeError, match="bad bounds"): read_tab(bad_fname) data, params = read_tab(ec.data_fname, return_params=True) - data_2, params_2 = read_tab( - bad_fname, return_params=True, allow_last_missing=True) + data_2, params_2 = read_tab(bad_fname, return_params=True, allow_last_missing=True) assert params == params_2 - t = data[-1].pop('trial_ok') - t_2 = data_2[-1].pop('trial_ok') + t = data[-1].pop("trial_ok") + t_2 = data_2[-1].pop("trial_ok") assert t != t_2 assert data_2 == data @@ -81,24 +88,24 @@ def test_reconstruct(hide_window): tr.respond(np.random.rand() < tr.x_current) tracker = reconstruct_tracker(ec.data_fname)[0] - assert (tracker.stopped) + assert tracker.stopped tracker.x_current # test with one TrackerBinom with ExperimentController(*std_args, **std_kwargs) as ec: - tr = TrackerBinom(ec, .05, .5, 10) + tr = TrackerBinom(ec, 0.05, 0.5, 10) while not tr.stopped: tr.respond(True) tracker = reconstruct_tracker(ec.data_fname)[0] - assert (tracker.stopped) + assert tracker.stopped tracker.x_current # tracker not stopped with ExperimentController(*std_args, **std_kwargs) as ec: tr = TrackerUD(ec, 1, 1, 3, 1, 5, np.inf, 3) tr.respond(np.random.rand() < tr.x_current) - assert (not tr.stopped) + assert not tr.stopped pytest.raises(ValueError, reconstruct_tracker, ec.data_fname) # test with dealer @@ -110,20 +117,20 @@ def test_reconstruct(hide_window): td.respond(np.random.rand() < x_current) dealer = reconstruct_dealer(ec.data_fname)[0] - assert (all(td._x_history == dealer._x_history)) - assert (all(td._tracker_history == dealer._tracker_history)) - assert (all(td._response_history == dealer._response_history)) - assert (td.shape == dealer.shape) - assert (td.trackers.shape == dealer.trackers.shape) + assert all(td._x_history == dealer._x_history) + assert all(td._tracker_history == dealer._tracker_history) + assert all(td._response_history == dealer._response_history) + assert td.shape == dealer.shape + assert td.trackers.shape == dealer.trackers.shape # no tracker/dealer in file with ExperimentController(*std_args, **std_kwargs) as ec: - ec.identify_trial(ec_id='one', ttl_id=[0]) + ec.identify_trial(ec_id="one", ttl_id=[0]) ec.start_stimulus() - ec.write_data_line('misc', 'trial one') + ec.write_data_line("misc", "trial one") ec.stop() ec.trial_ok() - ec.write_data_line('misc', 'end') + ec.write_data_line("misc", "end") pytest.raises(ValueError, reconstruct_tracker, ec.data_fname) pytest.raises(ValueError, reconstruct_dealer, ec.data_fname) diff --git a/expyfun/io/tests/test_wav.py b/expyfun/io/tests/test_wav.py index ad77f48b..46a901de 100644 --- a/expyfun/io/tests/test_wav.py +++ b/expyfun/io/tests/test_wav.py @@ -1,9 +1,8 @@ -# -*- coding: utf-8 -*- +from os import path as op + import numpy as np import pytest -from numpy.testing import (assert_array_almost_equal, assert_array_equal, - assert_equal) -from os import path as op +from numpy.testing import assert_array_almost_equal, assert_array_equal, assert_equal from expyfun._utils import _has_scipy_version from expyfun.io import read_wav, write_wav @@ -11,7 +10,7 @@ def test_read_write_wav(tmpdir): """Test reading and writing WAV files.""" - fname = op.join(str(tmpdir), 'temp.wav') + fname = op.join(str(tmpdir), "temp.wav") data = np.r_[np.random.rand(1000), 1, -1] fs = 44100 @@ -25,12 +24,13 @@ def test_read_write_wav(tmpdir): pytest.raises(IOError, write_wav, fname, data, fs) # test forcing fs dtype to int - with pytest.warns(UserWarning, match='rate is being cast'): + with pytest.warns(UserWarning, match="rate is being cast"): write_wav(fname, data, float(fs), overwrite=True) # Use 64-bit int: not supported - pytest.raises(RuntimeError, write_wav, fname, data, fs, dtype=np.int64, - overwrite=True) + pytest.raises( + RuntimeError, write_wav, fname, data, fs, dtype=np.int64, overwrite=True + ) # Use 32-bit int: better write_wav(fname, data, fs, dtype=np.int32, overwrite=True) @@ -38,7 +38,7 @@ def test_read_write_wav(tmpdir): assert_equal(fs_read, fs) assert_array_almost_equal(data[np.newaxis, :], data_read, 7) - if _has_scipy_version('0.13'): + if _has_scipy_version("0.13"): # Use 32-bit float: better write_wav(fname, data, fs, dtype=np.float32, overwrite=True) data_read, fs_read = read_wav(fname) @@ -51,8 +51,9 @@ def test_read_write_wav(tmpdir): assert_equal(fs_read, fs) assert_array_equal(data[np.newaxis, :], data_read) else: - pytest.raises(RuntimeError, write_wav, fname, data, fs, - dtype=np.float32, overwrite=True) + pytest.raises( + RuntimeError, write_wav, fname, data, fs, dtype=np.float32, overwrite=True + ) # Now try multi-dimensional data data = np.tile(data[np.newaxis, :], (2, 1)) diff --git a/expyfun/stimuli/__init__.py b/expyfun/stimuli/__init__.py index 9531df4b..d71b6b7c 100644 --- a/expyfun/stimuli/__init__.py +++ b/expyfun/stimuli/__init__.py @@ -16,8 +16,13 @@ from ._tracker import TrackerUD, TrackerBinom, TrackerDealer, TrackerMHW from .._tdt_controller import get_tdt_rates from ._texture import texture_ERB -from ._crm import (crm_sentence, crm_response_menu, crm_prepare_corpus, - crm_info, CRMPreload) +from ._crm import ( + crm_sentence, + crm_response_menu, + crm_prepare_corpus, + crm_info, + CRMPreload, +) # for backward compat (not great to do this...) from ..io import read_wav, write_wav diff --git a/expyfun/stimuli/_crm.py b/expyfun/stimuli/_crm.py index 7d4ef047..33b7be21 100644 --- a/expyfun/stimuli/_crm.py +++ b/expyfun/stimuli/_crm.py @@ -1,60 +1,45 @@ -"""Functions for using the Coordinate Response Measure (CRM) corpus. -""" +"""Functions for using the Coordinate Response Measure (CRM) corpus.""" # Author: Ross Maddox # # License: BSD (3-clause) -from multiprocessing import cpu_count import os +from multiprocessing import cpu_count from os.path import join from zipfile import ZipFile import numpy as np -from ..io import read_wav, write_wav +from .. import visual as vis from .._parallel import parallel_func +from .._utils import _get_user_home_path, fetch_data_file +from ..io import read_wav, write_wav from ._stimuli import window_edges -from .. import visual as vis -from .._utils import fetch_data_file, _get_user_home_path _fs_binary = 40e3 # the sampling rate of the original corpus binaries _rms_binary = 0.099977227591239365 # the RMS of the original corpus binaries _rms_prepped = 0.01 # the RMS for preparation of the whole corpus at an fs -_sexes = { - 'male': 0, - 'female': 1, - 'm': 0, - 'f': 1, - 0: 0, - 1: 1} -_talker_nums = { - '0': 0, - '1': 1, - '2': 2, - '3': 3, - 0: 0, - 1: 1, - 2: 2, - 3: 3} +_sexes = {"male": 0, "female": 1, "m": 0, "f": 1, 0: 0, 1: 1} +_talker_nums = {"0": 0, "1": 1, "2": 2, "3": 3, 0: 0, 1: 1, 2: 2, 3: 3} _callsigns = { - 'charlie': 0, - 'ringo': 1, - 'laker': 2, - 'hopper': 3, - 'arrow': 4, - 'tiger': 5, - 'eagle': 6, - 'baron': 7, - 'c': 0, - 'r': 1, - 'l': 2, - 'h': 3, - 'a': 4, - 't': 5, - 'e': 6, - 'b': 7, + "charlie": 0, + "ringo": 1, + "laker": 2, + "hopper": 3, + "arrow": 4, + "tiger": 5, + "eagle": 6, + "baron": 7, + "c": 0, + "r": 1, + "l": 2, + "h": 3, + "a": 4, + "t": 5, + "e": 6, + "b": 7, 0: 0, 1: 1, 2: 2, @@ -62,37 +47,39 @@ 4: 4, 5: 5, 6: 6, - 7: 7} + 7: 7, +} _colors = { - 'blue': 0, - 'red': 1, - 'white': 2, - 'green': 3, - 'b': 0, - 'r': 1, - 'w': 2, - 'g': 3, + "blue": 0, + "red": 1, + "white": 2, + "green": 3, + "b": 0, + "r": 1, + "w": 2, + "g": 3, 0: 0, 1: 1, 2: 2, - 3: 3} + 3: 3, +} _numbers = { - 'one': 0, - 'two': 1, - 'three': 2, - 'four': 3, - 'five': 4, - 'six': 5, - 'seven': 6, - 'eight': 7, - '1': 0, - '2': 1, - '3': 2, - '4': 3, - '5': 4, - '6': 5, - '7': 6, - '8': 7, + "one": 0, + "two": 1, + "three": 2, + "four": 3, + "five": 4, + "six": 5, + "seven": 6, + "eight": 7, + "1": 0, + "2": 1, + "3": 2, + "4": 3, + "5": 4, + "6": 5, + "7": 6, + "8": 7, 0: 0, 1: 1, 2: 2, @@ -100,7 +87,8 @@ 4: 4, 5: 5, 6: 6, - 7: 7} + 7: 7, +} _n_sexes = 2 _n_talkers = 4 @@ -110,64 +98,72 @@ def _check(name, value): - if name.lower() == 'sex': + if name.lower() == "sex": param_dict = _sexes - elif name.lower() == 'talker_num': + elif name.lower() == "talker_num": param_dict = _talker_nums - elif name.lower() == 'callsign': + elif name.lower() == "callsign": param_dict = _callsigns - elif name.lower() == 'color': + elif name.lower() == "color": param_dict = _colors - elif name.lower() == 'number': + elif name.lower() == "number": param_dict = _numbers if isinstance(value, str): value = value.lower() - if value in param_dict.keys(): + if value in param_dict: return param_dict[value] else: - raise ValueError('{} is not a valid {}. Legal values are: {}' - .format(value, name, - sorted(k for k in param_dict.keys() - if isinstance(k, int)))) + raise ValueError( + f"{value} is not a valid {name}. Legal values are: " + f"{sorted(k for k in param_dict if isinstance(k, int))}" + ) def _get_talker_zip_file(sex, talker_num): talker_num_raw = _n_talkers * _sexes[sex] + _talker_nums[talker_num] - fn = fetch_data_file('crm/Talker%i.zip' % talker_num_raw) + fn = fetch_data_file("crm/Talker%i.zip" % talker_num_raw) return fn # Read a raw binary CRM file -def _read_binary(zip_file, callsign, color, number, - ramp_dur=0.01): +def _read_binary(zip_file, callsign, color, number, ramp_dur=0.01): talk_path = zip_file.filelist[0].orig_filename[:8] - raw = zip_file.read(talk_path + '/%02i%02i%02i.BIN' % ( - _callsigns[callsign], _colors[color], _numbers[number])) - x = np.frombuffer(raw, ' max_wait: - raise ValueError('min_wait must be <= max_wait') + raise ValueError("min_wait must be <= max_wait") start_time = ec.current_time mouse_cursor = ec.window._mouse_cursor cursor = ec.window.get_system_mouse_cursor(ec.window.CURSOR_HAND) colors = [c.lower() for c in colors] - units = 'norm' + units = "norm" vert = float(ec.window_size_pix[0]) / ec.window_size_pix[1] h_spacing = 0.1 v_spacing = h_spacing * vert @@ -395,57 +444,55 @@ def crm_response_menu(ec, colors=['blue', 'red', 'white', 'green'], colors_rgb = [[0, 0, 1], [1, 0, 0], [1, 1, 1], [0, 0.85, 0]] n_numbers = len(numbers) n_colors = len(colors) - h_start = -(n_numbers - 1) * h_spacing / 2. - v_start = (n_colors - 1) * v_spacing / 2. + h_start = -(n_numbers - 1) * h_spacing / 2.0 + v_start = (n_colors - 1) * v_spacing / 2.0 font_size = (72 / ec.dpi) * height * ec.window_size_pix[1] / 2 - h_nudge = h_spacing / 8. - v_nudge = v_spacing / 20. + h_nudge = h_spacing / 8.0 + v_nudge = v_spacing / 20.0 - colors = [_check('color', color) for color in colors] - numbers = [str(_check('number', number) + 1) for number in numbers] + colors = [_check("color", color) for color in colors] + numbers = [str(_check("number", number) + 1) for number in numbers] - if (len(colors) != len(np.unique(colors)) or - len(numbers) != len(np.unique(numbers))): - raise ValueError('There can be no repeated colors or numbers in the ' - 'menu.') + if len(colors) != len(np.unique(colors)) or len(numbers) != len(np.unique(numbers)): + raise ValueError("There can be no repeated colors or numbers in the " "menu.") # Draw the buttons rects = [] for ni, number in enumerate(numbers): for ci, color in enumerate(colors): - pos = [ni * h_spacing + h_start, - -ci * v_spacing + v_start, - width, height] - rects += [vis.Rectangle( - ec, pos, units=units, - fill_color=colors_rgb[color])] + pos = [ni * h_spacing + h_start, -ci * v_spacing + v_start, width, height] + rects += [vis.Rectangle(ec, pos, units=units, fill_color=colors_rgb[color])] rects[-1].draw() - ec.screen_text(number, [pos[0] + h_nudge, pos[1] + v_nudge], - color='black', - wrap=False, units=units, font_size=font_size) + ec.screen_text( + number, + [pos[0] + h_nudge, pos[1] + v_nudge], + color="black", + wrap=False, + units=units, + font_size=font_size, + ) ec.flip() - ec.write_data_line('crm_menu') + ec.write_data_line("crm_menu") # Wait for min_wait and get the click while ec.current_time - start_time < min_wait: ec.check_force_quit() ec.window.set_mouse_cursor(cursor) max_wait = np.maximum(0, max_wait - (ec.current_time - start_time)) - but = ec.wait_for_click_on(rects, max_wait=max_wait, - live_buttons='left')[1] + but = ec.wait_for_click_on(rects, max_wait=max_wait, live_buttons="left")[1] ec.flip() ec.window.set_mouse_cursor(mouse_cursor) if but is not None: sub = np.unravel_index(but, (n_numbers, n_colors)) - resp = ('brwg'[colors[sub[1]]], numbers[sub[0]]) - ec.write_data_line('crm_response', resp[0] + ',' + resp[1]) + resp = ("brwg"[colors[sub[1]]], numbers[sub[0]]) + ec.write_data_line("crm_response", resp[0] + "," + resp[1]) return resp else: - ec.write_data_line('crm_timeout') + ec.write_data_line("crm_timeout") return (None, None) -class CRMPreload(object): +class CRMPreload: """Store the CRM corpus in memory for fast access. Parameters @@ -466,13 +513,13 @@ class CRMPreload(object): where the raw CRM originals are stored. """ - def __init__(self, fs, ref_rms=0.01, ramp_dur=0.01, stereo=False, - path=None): + def __init__(self, fs, ref_rms=0.01, ramp_dur=0.01, stereo=False, path=None): if path is None: - path = join(_get_user_home_path(), '.expyfun', 'data', 'crm') + path = join(_get_user_home_path(), ".expyfun", "data", "crm") if not os.path.isdir(join(path, str(fs))): - raise RuntimeError('prepare_corpus has not yet been run ' - 'for sampling rate of %i' % fs) + raise RuntimeError( + "prepare_corpus has not yet been run " "for sampling rate of %i" % fs + ) self._excluded = [] self._all_stim = {} for sex in range(_n_sexes): @@ -480,12 +527,20 @@ def __init__(self, fs, ref_rms=0.01, ramp_dur=0.01, stereo=False, for cal in range(_n_callsigns): for col in range(_n_colors): for num in range(_n_numbers): - stim_id = '%i%i%i%i%i' % (sex, tal, cal, col, num) + stim_id = "%i%i%i%i%i" % (sex, tal, cal, col, num) try: - self._all_stim[stim_id] = \ - crm_sentence(fs, sex, tal, cal, col, num, - ref_rms, ramp_dur, stereo, - path) + self._all_stim[stim_id] = crm_sentence( + fs, + sex, + tal, + cal, + col, + num, + ref_rms, + ramp_dur, + stereo, + path, + ) except Exception: self._excluded += [stim_id] @@ -528,11 +583,15 @@ def sentence(self, sex, talker_num, callsign, color, number): index of ``'1'`` is 0, so care must be taken if using indices for the number argument. """ - stim_id = '%i%i%i%i%i' % ( - _check('sex', sex), _check('talker_num', talker_num), - _check('callsign', callsign), _check('color', color), - _check('number', number)) + stim_id = "%i%i%i%i%i" % ( + _check("sex", sex), + _check("talker_num", talker_num), + _check("callsign", callsign), + _check("color", color), + _check("number", number), + ) if stim_id in self._excluded: - raise RuntimeError('prepare_corpus has not yet been run for the ' - 'requested talker') + raise RuntimeError( + "prepare_corpus has not yet been run for the " "requested talker" + ) return self._all_stim[stim_id].copy() diff --git a/expyfun/stimuli/_hrtf.py b/expyfun/stimuli/_hrtf.py index 55049432..85477da5 100644 --- a/expyfun/stimuli/_hrtf.py +++ b/expyfun/stimuli/_hrtf.py @@ -1,11 +1,10 @@ -"""Stimulus generation functions -""" +"""Stimulus generation functions""" import numpy as np -from ..io import read_hdf5 from .._fixes import irfft -from .._utils import fetch_data_file, _fix_audio_dims +from .._utils import _fix_audio_dims, fetch_data_file +from ..io import read_hdf5 # This was used to generate "barb_anech.gz": # @@ -54,42 +53,42 @@ def _get_hrtf(angle, source, fs, interp=False): Functions", Australian Government Department of Defence: Defence Science and Technology Organization, Melbourne, Victoria, Australia, 2007. """ - fname = fetch_data_file('hrtf/{0}_{1}.hdf5'.format(source, fs)) + fname = fetch_data_file(f"hrtf/{source}_{fs}.hdf5") data = read_hdf5(fname) - angles = data['angles'] + angles = data["angles"] leftward = False read_angle = float(angle) if angle < 0: leftward = True read_angle = float(-angle) if read_angle not in angles and not interp: - raise ValueError('angle "{0}" must be one of +/-{1}' - ''.format(angle, list(angles))) - brir = data['brir'] + raise ValueError(f'angle "{angle}" must be one of +/-{list(angles)}' "") + brir = data["brir"] if read_angle in angles: interp = False if not interp: idx = np.where(angles == read_angle)[0] if len(idx) != 1: - raise ValueError('angle "{0}" not uniquely found in angles' - ''.format(angle)) + raise ValueError(f'angle "{angle}" not uniquely found in angles' "") brir = brir[idx[0]] else: # interpolation - if source != 'cipic': - raise ValueError('source must be ''cipic'' when interp=True') + if source != "cipic": + raise ValueError("source must be " "cipic" " when interp=True") # pull in files containing known hrtfs and extract magnitude and phase - fname = fetch_data_file('hrtf/pair_cipic_{0}.hdf5'.format(fs)) + fname = fetch_data_file(f"hrtf/pair_cipic_{fs}.hdf5") data = read_hdf5(fname) - hrtf_amp = data['hrtf_amp'] - hrtf_phase = data['hrtf_phase'] - pairs = data['pairs'] + hrtf_amp = data["hrtf_amp"] + hrtf_phase = data["hrtf_phase"] + pairs = data["pairs"] # isolate appropriate pair of amplitude and phase idx = np.searchsorted(angles, read_angle) if idx > len(pairs): - raise ValueError('angle magnitude "{0}" must be smaller than "{1}"' - ''.format(read_angle, pairs[-1][-1])) + raise ValueError( + f'angle magnitude "{read_angle}" must be smaller than "{pairs[-1][-1]}"' + "" + ) knowns = np.array([angles[idx - 1], angles[idx]]) index = np.where(pairs == knowns)[0][0] hrtf_amp = hrtf_amp[index] @@ -98,20 +97,18 @@ def _get_hrtf(angle, source, fs, interp=False): # weighted averages of log magnitude and unwrapped phase step = float(knowns[1] - knowns[0]) weights = (step - np.abs(read_angle - knowns)) / step - hrtf_amp = np.prod(hrtf_amp ** weights[:, np.newaxis, np.newaxis], - axis=0) - hrtf_phase = np.sum(hrtf_phase * weights[:, np.newaxis, np.newaxis], - axis=0) + hrtf_amp = np.prod(hrtf_amp ** weights[:, np.newaxis, np.newaxis], axis=0) + hrtf_phase = np.sum(hrtf_phase * weights[:, np.newaxis, np.newaxis], axis=0) # reconstruct hrtf and convert to time domain hrtf = hrtf_amp * np.exp(1j * hrtf_phase) brir = irfft(hrtf, int(hrtf.shape[-1])) - return brir, data['fs'], leftward + return brir, data["fs"], leftward -def convolve_hrtf(data, fs, angle, source='cipic', interp=False): - """Convolve a signal with a head-related transfer function +def convolve_hrtf(data, fs, angle, source="cipic", interp=False): + """Convolve a signal with a head-related transfer function. Technically we will be convolving with binaural room impulse responses (BRIRs), but HRTFs (freq-domain equiv. representations) @@ -147,7 +144,7 @@ def convolve_hrtf(data, fs, angle, source='cipic', interp=False): Additional documentation: - http://earlab.bu.edu/databases/collections/cipic/documentation/hrir_data_documentation.pdf # noqa + http://earlab.bu.edu/databases/collections/cipic/documentation/hrir_data_documentation.pdf The data were modified to suit our experimental needs. Below is the licensing information for the CIPIC data: @@ -183,16 +180,17 @@ def convolve_hrtf(data, fs, angle, source='cipic', interp=False): CIPIC- Center for Image Processing and Integrated Computing University of California 1 Shields Avenue Davis, CA 95616-8553 - """ + """ # noqa: E501 fs = float(fs) angle = float(angle) - known_sources = ['barb', 'cipic'] + known_sources = ["barb", "cipic"] known_fs = [24414, 44100] # must be sorted if source not in known_sources: - raise ValueError('Source "{0}" unknown, must be one of {1}' - ''.format(source, known_sources)) + raise ValueError( + f'Source "{source}" unknown, must be one of {known_sources}' "" + ) if not isinstance(interp, bool): - raise ValueError('interp must be bool') + raise ValueError("interp must be bool") data = np.array(data, np.float64) data = _fix_audio_dims(data, n_channels=1).ravel() @@ -205,6 +203,7 @@ def convolve_hrtf(data, fs, angle, source='cipic', interp=False): order = [1, 0] if leftward else [0, 1] if not np.allclose(brir_fs, fs, rtol=0, atol=0.5): from mne.filter import resample + brir = [resample(b, fs, brir_fs) for b in brir] out = np.array([np.convolve(data, brir[o]) for o in order]) return out diff --git a/expyfun/stimuli/_mls.py b/expyfun/stimuli/_mls.py index cac8d35b..f5e453cb 100644 --- a/expyfun/stimuli/_mls.py +++ b/expyfun/stimuli/_mls.py @@ -1,26 +1,25 @@ -# -*- coding: utf-8 -*- # Copyright (c) 2014, LABSN. # Distributed under the (new) BSD License. See LICENSE.txt for more info. -"""Maximum-length sequence (MLS) impulse-response finding functions -""" +"""Maximum-length sequence (MLS) impulse-response finding functions""" from os import path as op + import numpy as np from .._fixes import irfft, rfft -from .._utils import verbose_dec, logger +from .._utils import logger, verbose_dec -_mls_file = op.join(op.dirname(__file__), '..', 'data', 'mls.bin') +_mls_file = op.join(op.dirname(__file__), "..", "data", "mls.bin") _max_bits = 14 # determined by how the file was made, see _max_len_wrapper def _check_n_bits(n_bits): """Helper to make sure we have a usable number of bits""" if not isinstance(n_bits, int): - raise TypeError('n_bits must be an integer') + raise TypeError("n_bits must be an integer") if n_bits < 2 or n_bits > _max_bits: - raise ValueError('n_bits must be between 2 and %s' % _max_bits) + raise ValueError("n_bits must be between 2 and %s" % _max_bits) def _max_len_wrapper(n_bits): @@ -40,21 +39,21 @@ def _max_len_wrapper(n_bits): n_bits = int(n_bits) _check_n_bits(n_bits) # This was used to generate the sequences: - #from scipy.signal import max_len_seq - #_mlss = np.concatenate([max_len_seq(n) > 0 + # from scipy.signal import max_len_seq + # _mlss = np.concatenate([max_len_seq(n) > 0 # for n in range(2, _max_bits + 1)]) - #with open(_mls_file, 'wb') as fid: + # with open(_mls_file, 'wb') as fid: # fid.write(_mlss.tostring()) - _lims = np.cumsum([0] + [2 ** n - 1 for n in range(2, 15)]) + _lims = np.cumsum([0] + [2**n - 1 for n in range(2, 15)]) _mlss = np.fromfile(_mls_file, dtype=bool) _mlss = [_mlss[l1:l2].copy() for l1, l2 in zip(_lims[:-1], _lims[1:])] - return _mlss[n_bits - 2] * 2. - 1 + return _mlss[n_bits - 2] * 2.0 - 1 # Once this is in upstream scipy, we can add this: -#try: +# try: # from scipy.signal import max_len_seq as _max_len_seq -#except: +# except: _max_len_seq = _max_len_wrapper @@ -69,11 +68,10 @@ def repeated_mls(n_samp, n_repeats): The number of repeats to use. """ if not isinstance(n_samp, int) or not isinstance(n_repeats, int): - raise TypeError('n_samp and n_repeats must both be integers') + raise TypeError("n_samp and n_repeats must both be integers") n_bits = max(int(np.ceil(np.log2(n_samp + 1))), 2) if n_bits > _max_bits: - raise ValueError('Only lengths up to %s supported' - % (2 ** _max_bits - 1)) + raise ValueError("Only lengths up to %s supported" % (2**_max_bits - 1)) mls = 0.5 * _max_len_seq(n_bits) + 0.5 n_resp = len(mls) * (n_repeats + 1) - 1 mls = np.tile(mls, n_repeats) @@ -96,37 +94,43 @@ def compute_mls_impulse_response(response, mls, n_repeats, verbose=None): If not ``None``, override default verbose level. """ if mls.ndim != 1 or response.ndim != 1: - raise ValueError('response and mls must both be one-dimensional') + raise ValueError("response and mls must both be one-dimensional") if not isinstance(n_repeats, int): - raise TypeError('n_repeats must be an integer') + raise TypeError("n_repeats must be an integer") if not np.array_equal(np.sort(np.unique(mls)), [0, 1]): - raise ValueError('MLS must be sequence of 0s and 1s') + raise ValueError("MLS must be sequence of 0s and 1s") if mls.size % n_repeats != 0: - raise ValueError('MLS length (%s) is not a multiple of the number ' - 'of repeats (%s)' % (mls.size, n_repeats)) + raise ValueError( + "MLS length (%s) is not a multiple of the number " + "of repeats (%s)" % (mls.size, n_repeats) + ) mls_len = mls.size // n_repeats n_bits = int(np.round(np.log2(mls_len + 1))) - n_check = 2 ** n_bits + n_check = 2**n_bits if n_check != mls_len + 1: - raise RuntimeError('length of MLS must be one shorter than a power ' - 'of 2, got %s (close to %s)' % (mls_len, n_check)) - logger.info('MLS using %s bits detected' % n_bits) + raise RuntimeError( + "length of MLS must be one shorter than a power " + "of 2, got %s (close to %s)" % (mls_len, n_check) + ) + logger.info("MLS using %s bits detected" % n_bits) n_len = response.size + 1 if n_len % mls_len != 0: n_rep = int(np.round(n_len / float(mls_len))) n_len = mls_len * n_rep - 1 - raise ValueError('length of data must be one shorter than a ' - 'multiple of the MLS length (%s), found a length ' - 'of %s which is close to %s (%s repeats)' - % (mls_len, response.size, n_len, n_rep)) + raise ValueError( + "length of data must be one shorter than a " + "multiple of the MLS length (%s), found a length " + "of %s which is close to %s (%s repeats)" + % (mls_len, response.size, n_len, n_rep) + ) # Now that we know our signal, we can actually deconvolve. # First, wrap the end back to the beginning - resp_wrap = response[:n_repeats * mls_len].copy() - resp_wrap[:mls_len - 1] += response[n_repeats * mls_len:] + resp_wrap = response[: n_repeats * mls_len].copy() + resp_wrap[: mls_len - 1] += response[n_repeats * mls_len :] # Compute the circular crosscorrelation, w/correction for MLS scaling correction = np.empty(len(mls) // 2 + 1) - correction.fill(1. / (2 ** (n_bits - 2) * n_repeats)) - correction[0] = 1. / ((4 ** (n_bits - 1)) * n_repeats) + correction.fill(1.0 / (2 ** (n_bits - 2) * n_repeats)) + correction[0] = 1.0 / ((4 ** (n_bits - 1)) * n_repeats) y = irfft(correction * rfft(resp_wrap) * rfft(mls).conj()) # Average out repeats h_est = np.mean(np.reshape(y, (n_repeats, mls_len)), axis=0) diff --git a/expyfun/stimuli/_stimuli.py b/expyfun/stimuli/_stimuli.py index 4f4689a5..0adbbd45 100644 --- a/expyfun/stimuli/_stimuli.py +++ b/expyfun/stimuli/_stimuli.py @@ -1,17 +1,17 @@ -# -*- coding: utf-8 -*- """Generic stimulus generation functions.""" import warnings +from threading import Timer + import numpy as np from scipy import signal -from threading import Timer -from ..io import read_wav from .._sound_controllers import SoundPlayer -from .._utils import _wait_secs, string_types +from .._utils import _wait_secs +from ..io import read_wav -def window_edges(sig, fs, dur=0.01, axis=-1, window='hann', edges='both'): +def window_edges(sig, fs, dur=0.01, axis=-1, window="hann", edges="both"): """Window the edges of a signal (e.g., to prevent "pops") Parameters @@ -40,25 +40,27 @@ def window_edges(sig, fs, dur=0.01, axis=-1, window='hann', edges='both'): sig_len = sig.shape[axis] win_len = int(dur * fs) if win_len > sig_len: - raise RuntimeError('cannot create window of size {0} samples (dur={1})' - 'for signal with length {2}' - ''.format(win_len, dur, sig_len)) - if window == 'dpss': + raise RuntimeError( + f"cannot create window of size {win_len} samples (dur={dur})" + f"for signal with length {sig_len}" + "" + ) + if window == "dpss": from mne.time_frequency.multitaper import dpss_windows + win = dpss_windows(2 * win_len + 1, 1, 1)[0][0][:win_len] win -= win[0] win /= win.max() else: win = signal.windows.get_window(window, 2 * win_len)[:win_len] - valid_edges = ('leading', 'trailing', 'both') + valid_edges = ("leading", "trailing", "both") if edges not in valid_edges: - raise ValueError('edges must be one of {0}, not "{1}"' - ''.format(valid_edges, edges)) + raise ValueError(f'edges must be one of {valid_edges}, not "{edges}"' "") # now we can actually do the calculation flattop = np.ones(sig_len, dtype=np.float64) - if edges in ('trailing', 'both'): # eliminate trailing + if edges in ("trailing", "both"): # eliminate trailing flattop[-win_len:] *= win[::-1] - if edges in ('leading', 'both'): # eliminate leading + if edges in ("leading", "both"): # eliminate leading flattop[:win_len] *= win shape = np.ones_like(sig.shape) shape[axis] = sig.shape[axis] @@ -82,7 +84,7 @@ def rms(data, axis=-1, keepdims=False): return np.sqrt(np.mean(data * data, axis=axis, keepdims=keepdims)) -def play_sound(sound, fs=None, norm=True, wait=False, backend='auto'): +def play_sound(sound, fs=None, norm=True, wait=False, backend="auto"): """Play a sound Parameters @@ -108,20 +110,20 @@ def play_sound(sound, fs=None, norm=True, wait=False, backend='auto'): """ sound = np.array(sound) fs_default = 44100 - if isinstance(sound, string_types): + if isinstance(sound, str): sound, fs_default = read_wav(sound) if fs is None: fs = fs_default if sound.ndim == 1: # make it stereo sound = np.array((sound, sound)) if sound.ndim != 2: - raise ValueError('sound must be 1- or 2-dimensional') + raise ValueError("sound must be 1- or 2-dimensional") if norm: m = np.abs(sound).max() * 1.000001 m = m if m != 0 else 1 sound /= m - if np.abs(sound).max() > 1.: - warnings.warn('Sound exceeds +/-1, will clip') + if np.abs(sound).max() > 1.0: + warnings.warn("Sound exceeds +/-1, will clip") # For rtmixer it's possible this will fail on some configurations if # resampling isn't built in to the backend; when we hit this we can # try/except here and do the resampling ourselves. @@ -133,12 +135,12 @@ def play_sound(sound, fs=None, norm=True, wait=False, backend='auto'): _wait_secs(dur) else: del_wait += dur - if hasattr(snd, 'delete'): # for backward compatibility + if hasattr(snd, "delete"): # for backward compatibility Timer(del_wait, snd.delete).start() return snd -def add_pad(sounds, alignment='start'): +def add_pad(sounds, alignment="start"): """Add sounds of different lengths and channel counts together Parameters @@ -162,28 +164,27 @@ def add_pad(sounds, alignment='start'): Even if the original sounds were all 0- or 1-dimensional, the output will be 2-dimensional (channels, samples). """ - if alignment not in ['start', 'center', 'end']: - raise ValueError("alignment must be either 'start', 'center', " - "or 'end'") + if alignment not in ["start", "center", "end"]: + raise ValueError("alignment must be either 'start', 'center', " "or 'end'") x = [np.atleast_2d(y) for y in sounds] if not np.all(y.ndim == 2 for y in x): - raise ValueError('Sound data must have no more than 2 dimensions.') + raise ValueError("Sound data must have no more than 2 dimensions.") shapes = [y.shape for y in x] ch_max, len_max = np.max(shapes, axis=0) if ch_max > 2: - raise ValueError('Only 1- and 2-channel sounds are supported.') + raise ValueError("Only 1- and 2-channel sounds are supported.") for xi, (ch, length) in enumerate(shapes): if length < len_max: - if alignment == 'start': + if alignment == "start": n_pre = 0 n_post = len_max - length - elif alignment == 'center': + elif alignment == "center": n_pre = (len_max - length) // 2 n_post = len_max - length - n_pre - elif alignment == 'end': + elif alignment == "end": n_pre = len_max - length n_post = 0 - x[xi] = np.pad(x[xi], ((0, 0), (n_pre, n_post)), 'constant') + x[xi] = np.pad(x[xi], ((0, 0), (n_pre, n_post)), "constant") if ch < ch_max: x[xi] = np.tile(x[xi], [ch_max, 1]) return np.sum(x, 0) diff --git a/expyfun/stimuli/_texture.py b/expyfun/stimuli/_texture.py index bff7c1cd..ba754754 100644 --- a/expyfun/stimuli/_texture.py +++ b/expyfun/stimuli/_texture.py @@ -1,14 +1,14 @@ #!/usr/bin/env python2 -# -*- coding: utf-8 -*- """Texture (ERB-spaced) stimulus generation functions.""" # adapted (with permission) from code by Hari Bharadwaj -import numpy as np import warnings -from ._stimuli import rms, window_edges +import numpy as np + from .._fixes import irfft +from ._stimuli import rms, window_edges def _cams(f): @@ -18,7 +18,7 @@ def _cams(f): def _inv_cams(E): """Compute cams inverse.""" - return (10 ** (E / 21.4) - 1.) / 0.00437 + return (10 ** (E / 21.4) - 1.0) / 0.00437 def _scale_sound(x): @@ -28,21 +28,30 @@ def _scale_sound(x): def _make_narrow_noise(bw, f_c, dur, fs, ramp_dur, rng): """Make narrow-band noise using FFT.""" - f_min, f_max = f_c - bw / 2., f_c + bw / 2. + f_min, f_max = f_c - bw / 2.0, f_c + bw / 2.0 t = np.arange(int(round(dur * fs))) / fs # Make Noise - f_step = 1. / dur # Frequency bin size + f_step = 1.0 / dur # Frequency bin size h_min = int(np.ceil(f_min / f_step)) h_max = int(np.floor(f_max / f_step)) + 1 phase = rng.rand(h_max - h_min) * 2 * np.pi noise = np.zeros(len(t) // 2 + 1, np.complex128) noise[h_min:h_max] = np.exp(1j * phase) - return window_edges(irfft(noise)[:len(t)], fs, ramp_dur, window='dpss') - - -def texture_ERB(n_freqs=20, n_coh=None, rho=1., seq=('inc', 'nb', 'inc', 'nb'), - fs=24414.0625, dur=1., SAM_freq=7., random_state=None, - freq_lims=(200, 8000), verbose=True): + return window_edges(irfft(noise)[: len(t)], fs, ramp_dur, window="dpss") + + +def texture_ERB( + n_freqs=20, + n_coh=None, + rho=1.0, + seq=("inc", "nb", "inc", "nb"), + fs=24414.0625, + dur=1.0, + SAM_freq=7.0, + random_state=None, + freq_lims=(200, 8000), + verbose=True, +): """Create ERB texture stimulus Parameters @@ -83,14 +92,16 @@ def texture_ERB(n_freqs=20, n_coh=None, rho=1., seq=('inc', 'nb', 'inc', 'nb'), """ from mne.time_frequency.multitaper import dpss_windows from mne.utils import check_random_state + if not isinstance(seq, (list, tuple, np.ndarray)): - raise TypeError('seq must be list, tuple, or ndarray, got %s' - % type(seq)) - known_seqs = ('inc', 'nb', 'sam') + raise TypeError("seq must be list, tuple, or ndarray, got %s" % type(seq)) + known_seqs = ("inc", "nb", "sam") for si, s in enumerate(seq): if s not in known_seqs: - raise ValueError('all entries in seq must be one of %s, got ' - 'seq[%s]=%s' % (known_seqs, si, s)) + raise ValueError( + "all entries in seq must be one of %s, got " + "seq[%s]=%s" % (known_seqs, si, s) + ) fs = float(fs) rng = check_random_state(random_state) n_coh = int(np.round(n_freqs * 0.8)) if n_coh is None else n_coh @@ -102,10 +113,12 @@ def texture_ERB(n_freqs=20, n_coh=None, rho=1., seq=('inc', 'nb', 'inc', 'nb'), del f_max spacing_ERBs = n_ERBs / float(n_freqs - 1) if verbose: - print('This stim will have successive tones separated by %2.2f ERBs' - % spacing_ERBs) + print( + "This stim will have successive tones separated by %2.2f ERBs" + % spacing_ERBs + ) if spacing_ERBs < 1.0: - warnings.warn('The spacing between tones is LESS THAN 1 ERB!') + warnings.warn("The spacing between tones is LESS THAN 1 ERB!") # Make a filter whose impulse response is purely positive (to avoid phase # jumps) so that the filtered envelope is purely positive. Use a DPSS @@ -113,49 +126,51 @@ def texture_ERB(n_freqs=20, n_coh=None, rho=1., seq=('inc', 'nb', 'inc', 'nb'), # filterlength, we need to restrict time-bandwidth product to a minimum. # Thus we need a length*bw = 2 => length = 2/bw (second). Hence filter # coefficients are calculated as follows: - b = dpss_windows(int(np.floor(2 * fs / 100.)), 1., 1)[0][0] + b = dpss_windows(int(np.floor(2 * fs / 100.0)), 1.0, 1)[0][0] b -= b[0] b /= b.sum() # Incoherent envrate = 14 bw = 20 - incoh = 0. + incoh = 0.0 for k in range(n_freqs): f = _inv_cams(_cams(f_min) + spacing_ERBs * k) env = _make_narrow_noise(bw, envrate, dur, fs, rise, rng) env[env < 0] = 0 - env = np.convolve(b, env)[:len(t)] - incoh += _scale_sound(window_edges( - env * np.sin(2 * np.pi * f * t), fs, rise, window='dpss')) + env = np.convolve(b, env)[: len(t)] + incoh += _scale_sound( + window_edges(env * np.sin(2 * np.pi * f * t), fs, rise, window="dpss") + ) incoh /= rms(incoh) # Coherent (noise band) - stims = dict(inc=0., nb=0., sam=0.) + stims = dict(inc=0.0, nb=0.0, sam=0.0) group = np.sort(rng.permutation(np.arange(n_freqs))[:n_coh]) for kind in known_seqs: - if kind == 'nb': # noise band + if kind == "nb": # noise band env_coh = _make_narrow_noise(bw, envrate, dur, fs, rise, rng) else: # 'nb' or 'inc' - env_coh = 0.5 + np.sin(2 * np.pi * SAM_freq * t) / 2. - env_coh = window_edges(env_coh, fs, rise, window='dpss') + env_coh = 0.5 + np.sin(2 * np.pi * SAM_freq * t) / 2.0 + env_coh = window_edges(env_coh, fs, rise, window="dpss") env_coh[env_coh < 0] = 0 - env_coh = np.convolve(b, env_coh)[:len(t)] - if kind == 'inc': + env_coh = np.convolve(b, env_coh)[: len(t)] + if kind == "inc": use_group = [] # no coherent ones else: # 'nb' or 'sam' use_group = group for k in range(n_freqs): f = _inv_cams(_cams(f_min) + spacing_ERBs * k) env_inc = _make_narrow_noise(bw, envrate, dur, fs, rise, rng) - env_inc[env_inc < 0] = 0. - env_inc = np.convolve(b, env_inc)[:len(t)] + env_inc[env_inc < 0] = 0.0 + env_inc = np.convolve(b, env_inc)[: len(t)] if k in use_group: - env = np.sqrt(rho) * env_coh + np.sqrt(1 - rho ** 2) * env_inc + env = np.sqrt(rho) * env_coh + np.sqrt(1 - rho**2) * env_inc else: env = env_inc - stims[kind] += _scale_sound(window_edges( - env * np.sin(2 * np.pi * f * t), fs, rise, window='dpss')) + stims[kind] += _scale_sound( + window_edges(env * np.sin(2 * np.pi * f * t), fs, rise, window="dpss") + ) stims[kind] /= rms(stims[kind]) stim = np.concatenate([stims[s] for s in seq]) stim = 0.01 * stim / rms(stim) diff --git a/expyfun/stimuli/_tracker.py b/expyfun/stimuli/_tracker.py index 8b1583d9..bd599ed6 100644 --- a/expyfun/stimuli/_tracker.py +++ b/expyfun/stimuli/_tracker.py @@ -1,15 +1,15 @@ -"""Adaptive tracks for psychophysics (individual, or multiple randomly dealt) -""" +"""Adaptive tracks for psychophysics (individual, or multiple randomly dealt)""" # Author: Ross Maddox # # License: BSD (3-clause) -import numpy as np -import time -from scipy.stats import binom import json +import time import warnings +import numpy as np +from scipy.stats import binom + from .. import ExperimentController @@ -17,29 +17,29 @@ # Set up the logging callback (use write_data_line or do nothing) # ============================================================================= def _callback_dummy(event_type, value=None, timestamp=None): - """Take the arguments of write_data_line, but do nothing. - """ + """Take the arguments of write_data_line, but do nothing.""" pass def _check_callback(callback): - """Check to see if the callback is of an allowable type. - """ + """Check to see if the callback is of an allowable type.""" if callback is None: callback = _callback_dummy elif isinstance(callback, ExperimentController): callback = callback.write_data_line if not callable(callback): - raise TypeError('callback must be a callable, None, or an instance of ' - 'ExperimentController.') + raise TypeError( + "callback must be a callable, None, or an instance of " + "ExperimentController." + ) return callback # ============================================================================= # Define the TrackerUD Class # ============================================================================= -class TrackerUD(object): +class TrackerUD: r"""Up-down adaptive tracker This class implements a standard up-down adaptive tracker object. Based on @@ -126,22 +126,34 @@ class TrackerUD(object): None. """ - def __init__(self, callback, up, down, step_size_up, step_size_down, - stop_reversals, stop_trials, start_value, change_indices=None, - change_rule='reversals', x_min=None, x_max=None, - repeat_limit='reversals'): + def __init__( + self, + callback, + up, + down, + step_size_up, + step_size_down, + stop_reversals, + stop_trials, + start_value, + change_indices=None, + change_rule="reversals", + x_min=None, + x_max=None, + repeat_limit="reversals", + ): self._callback = _check_callback(callback) if not isinstance(up, int): - raise ValueError('up must be an integer') + raise ValueError("up must be an integer") self._up = up if not isinstance(down, int): - raise ValueError('down must be an integer') + raise ValueError("down must be an integer") self._down = down - if stop_reversals != np.inf and type(stop_reversals) != int: - raise ValueError('stop_reversals must be an integer or np.inf') + if stop_reversals != np.inf and not isinstance(stop_reversals, int): + raise ValueError("stop_reversals must be an integer or np.inf") self._stop_reversals = stop_reversals - if stop_trials != np.inf and type(stop_trials) != int: - raise ValueError('stop_trials must be an integer or np.inf') + if stop_trials != np.inf and not isinstance(stop_trials, int): + raise ValueError("stop_trials must be an integer or np.inf") self._stop_trials = stop_trials self._start_value = start_value self._x_min = -np.inf if x_min is None else float(x_min) @@ -150,34 +162,41 @@ def __init__(self, callback, up, down, step_size_up, step_size_down, if change_indices is None: change_indices = [0] if not np.isscalar(step_size_up): - raise ValueError('If step_size_up is longer than 1, you must ' - 'specify change indices.') + raise ValueError( + "If step_size_up is longer than 1, you must " + "specify change indices." + ) if not np.isscalar(step_size_down): - raise ValueError('If step_size_down is longer than 1, you must' - ' specify change indices.') + raise ValueError( + "If step_size_down is longer than 1, you must" + " specify change indices." + ) self._change_indices = np.asarray(change_indices) - if change_rule not in ['trials', 'reversals']: - raise ValueError("change_rule must be either 'trials' or " - "'reversals'") + if change_rule not in ["trials", "reversals"]: + raise ValueError("change_rule must be either 'trials' or " "'reversals'") self._change_rule = change_rule step_size_up = np.atleast_1d(step_size_up) if change_indices != [0]: if len(step_size_up) != len(change_indices) + 1: - raise ValueError('If step_size_up is not scalar it must be one' - ' element longer than change_indices.') + raise ValueError( + "If step_size_up is not scalar it must be one" + " element longer than change_indices." + ) self._step_size_up = np.asarray(step_size_up, dtype=float) step_size_down = np.atleast_1d(step_size_down) if change_indices != [0]: if len(step_size_down) != len(change_indices) + 1: - raise ValueError('If step_size_down is not scalar it must be ' - 'one element longer than change_indices.') + raise ValueError( + "If step_size_down is not scalar it must be " + "one element longer than change_indices." + ) self._step_size_down = np.asarray(step_size_down, dtype=float) self._x = np.asarray([start_value], dtype=float) if not np.isscalar(start_value): - raise TypeError('start_value must be a scalar') + raise TypeError("start_value must be a scalar") self._x_current = float(start_value) self._responses = np.asarray([], dtype=bool) self._reversals = np.asarray([], dtype=int) @@ -193,25 +212,32 @@ def __init__(self, callback, up, down, step_size_up, step_size_down, self._limit_count = 0 # Now write the initialization data out - self._tracker_id = '%s-%s' % (id(self), int(round(time.time() * 1e6))) - self._callback('tracker_identify', json.dumps(dict( - tracker_id=self._tracker_id, - tracker_type='TrackerUD'))) - - self._callback('tracker_%s_init' % self._tracker_id, json.dumps(dict( - callback=None, - up=self._up, - down=self._down, - step_size_up=[float(s) for s in self._step_size_up], - step_size_down=[float(s) for s in self._step_size_down], - stop_reversals=self._stop_reversals, - stop_trials=self._stop_trials, - start_value=self._start_value, - change_indices=[int(s) for s in self._change_indices], - change_rule=self._change_rule, - x_min=self._x_min, - x_max=self._x_max, - repeat_limit=self._repeat_limit))) + self._tracker_id = "%s-%s" % (id(self), int(round(time.time() * 1e6))) + self._callback( + "tracker_identify", + json.dumps(dict(tracker_id=self._tracker_id, tracker_type="TrackerUD")), + ) + + self._callback( + "tracker_%s_init" % self._tracker_id, + json.dumps( + dict( + callback=None, + up=self._up, + down=self._down, + step_size_up=[float(s) for s in self._step_size_up], + step_size_down=[float(s) for s in self._step_size_down], + stop_reversals=self._stop_reversals, + stop_trials=self._stop_trials, + start_value=self._start_value, + change_indices=[int(s) for s in self._change_indices], + change_rule=self._change_rule, + x_min=self._x_min, + x_max=self._x_max, + repeat_limit=self._repeat_limit, + ) + ), + ) def respond(self, correct): """Update the tracker based on the last response. @@ -222,7 +248,7 @@ def respond(self, correct): Was the most recent subject response correct? """ if self._stopped: - raise RuntimeError('Tracker is stopped.') + raise RuntimeError("Tracker is stopped.") bound = False bad = False @@ -262,11 +288,9 @@ def respond(self, correct): if step_dir == 0: self._x = np.append(self._x, self._x[-1]) elif step_dir < 0: - self._x = np.append(self._x, self._x[-1] - - self._current_step_size_down) + self._x = np.append(self._x, self._x[-1] - self._current_step_size_down) elif step_dir > 0: - self._x = np.append(self._x, self._x[-1] + - self._current_step_size_up) + self._x = np.append(self._x, self._x[-1] + self._current_step_size_up) if self._x_min is not -np.inf: if self._x[-1] < self._x_min: @@ -274,7 +298,7 @@ def respond(self, correct): self._limit_count += 1 if bound: bad = True - if self._repeat_limit == 'reversals': + if self._repeat_limit == "reversals": reversal = True self._n_reversals += 1 if self._x_max is not np.inf: @@ -283,7 +307,7 @@ def respond(self, correct): self._limit_count += 1 if bound: bad = True - if self._repeat_limit == 'reversals': + if self._repeat_limit == "reversals": reversal = True self._n_reversals += 1 @@ -299,15 +323,19 @@ def respond(self, correct): if not self._stopped: self._x_current = self._x[-1] - self._callback('tracker_%s_respond' % self._tracker_id, - correct) + self._callback("tracker_%s_respond" % self._tracker_id, correct) else: self._x = self._x[:-1] self._callback( - 'tracker_%s_stop' % self._tracker_id, json.dumps(dict( - responses=[int(s) for s in self._responses], - reversals=[int(s) for s in self._reversals], - x=[float(s) for s in self._x]))) + "tracker_%s_stop" % self._tracker_id, + json.dumps( + dict( + responses=[int(s) for s in self._responses], + reversals=[int(s) for s in self._reversals], + x=[float(s) for s in self._x], + ) + ), + ) def check_valid(self, n_reversals): """If last reversals contain reversals exceeding x_min or x_max. @@ -323,8 +351,7 @@ def check_valid(self, n_reversals): True if none of the reversals are at x_min or x_max and False otherwise. """ - self._valid = (not self._bad_reversals[self._reversals != 0] - [-n_reversals:].any()) + self._valid = not self._bad_reversals[self._reversals != 0][-n_reversals:].any() return self._valid def _stop_here(self): @@ -335,14 +362,16 @@ def _stop_here(self): else: self._n_stop = False if self._n_stop and self._limit_count > 0: - warnings.warn('Tracker {} exceeded x_min or x_max bounds {} times.' - ''.format(self._tracker_id, self._limit_count)) + warnings.warn( + f"Tracker {self._tracker_id} exceeded x_min or x_max bounds " + f"{self._limit_count} times." + ) return self._n_stop def _step_index(self): - if self._change_rule.lower() == 'reversals': + if self._change_rule.lower() == "reversals": self._n_change = self._n_reversals - elif self._change_rule.lower() == 'trials': + elif self._change_rule.lower() == "trials": self._n_change = self._n_trials step_index = np.where(self._n_change >= self._change_indices)[0] if len(step_index) == 0 or np.array_equal(self._change_indices, [0]): @@ -404,44 +433,37 @@ def repeat_limit(self): @property def stopped(self): - """Has the tracker stopped - """ + """Has the tracker stopped""" return self._stopped @property def x(self): - """The staircase - """ + """The staircase""" return self._x @property def x_current(self): - """The current level - """ + """The current level""" return self._x_current @property def responses(self): - """The response history - """ + """The response history""" return self._responses @property def n_trials(self): - """The number of trials so far - """ + """The number of trials so far""" return self._n_trials @property def n_reversals(self): - """The number of reversals so far - """ + """The number of reversals so far""" return self._n_reversals @property def reversals(self): - """The reversal history (0 where there was no reversal) - """ + """The reversal history (0 where there was no reversal)""" return self._reversals @property @@ -475,20 +497,22 @@ def plot(self, ax=None, threshold=True, n_skip=2): The handles to the staircase line and the reversal dots. """ import matplotlib.pyplot as plt + if ax is None: fig, ax = plt.subplots(1) else: fig = ax.figure - line = ax.plot(1 + np.arange(self._n_trials), self._x, 'k.-') - line[0].set_label('Trials') - dots = ax.plot(1 + np.where(self._reversals > 0)[0], - self._x[self._reversals > 0], 'ro') - dots[0].set_label('Reversals') - ax.set(xlabel='Trial number', ylabel='Level') + line = ax.plot(1 + np.arange(self._n_trials), self._x, "k.-") + line[0].set_label("Trials") + dots = ax.plot( + 1 + np.where(self._reversals > 0)[0], self._x[self._reversals > 0], "ro" + ) + dots[0].set_label("Reversals") + ax.set(xlabel="Trial number", ylabel="Level") if threshold: thresh = self.plot_thresh(n_skip, ax) - thresh[0].set_label('Estimated Threshold') + thresh[0].set_label("Estimated Threshold") ax.legend() return fig, ax, line + dots @@ -509,10 +533,12 @@ def plot_thresh(self, n_skip=2, ax=None): The handle to the threshold line, as returned from ``plt.plot``. """ import matplotlib.pyplot as plt + if ax is None: ax = plt.gca() - h = ax.plot([1, self._n_trials], [self.threshold(n_skip)] * 2, - '--', color='gray') + h = ax.plot( + [1, self._n_trials], [self.threshold(n_skip)] * 2, "--", color="gray" + ) return h def threshold(self, n_skip=2): @@ -543,17 +569,20 @@ def threshold(self, n_skip=2): return np.nan else: if self._bad_reversals[rev_inds].any(): - raise ValueError('Cannot calculate thresholds with reversals ' - 'attempting to exceed x_min or x_max. Try ' - 'increasing n_skip.') - return (np.mean(self._x[rev_inds[0::2]]) + - np.mean(self._x[rev_inds[1::2]])) / 2 + raise ValueError( + "Cannot calculate thresholds with reversals " + "attempting to exceed x_min or x_max. Try " + "increasing n_skip." + ) + return ( + np.mean(self._x[rev_inds[0::2]]) + np.mean(self._x[rev_inds[1::2]]) + ) / 2 # ============================================================================= # Define the TrackerBinom Class # ============================================================================= -class TrackerBinom(object): +class TrackerBinom: """Binomial hypothesis testing tracker This class implements a tracker that runs a test at each trial with the @@ -608,8 +637,16 @@ class TrackerBinom(object): of following them. """ - def __init__(self, callback, alpha, chance, max_trials, min_trials=0, - stop_early=True, x_current=np.nan): + def __init__( + self, + callback, + alpha, + chance, + max_trials, + min_trials=0, + stop_early=True, + x_current=np.nan, + ): self._callback = _check_callback(callback) self._alpha = alpha self._chance = chance @@ -629,18 +666,25 @@ def __init__(self, callback, alpha, chance, max_trials, min_trials=0, # Now write the initialization data out self._tracker_id = id(self) - self._callback('tracker_identify', json.dumps(dict( - tracker_id=self._tracker_id, - tracker_type='TrackerBinom'))) - - self._callback('tracker_%s_init' % self._tracker_id, json.dumps(dict( - callback=None, - alpha=self._alpha, - chance=self._chance, - max_trials=self._max_trials, - min_trials=self._min_trials, - stop_early=self._stop_early, - x_current=self._x_current))) + self._callback( + "tracker_identify", + json.dumps(dict(tracker_id=self._tracker_id, tracker_type="TrackerBinom")), + ) + + self._callback( + "tracker_%s_init" % self._tracker_id, + json.dumps( + dict( + callback=None, + alpha=self._alpha, + chance=self._chance, + max_trials=self._max_trials, + min_trials=self._min_trials, + stop_early=self._stop_early, + x_current=self._x_current, + ) + ), + ) def respond(self, correct): """Update the tracker based on the last response. @@ -657,15 +701,16 @@ def respond(self, correct): else: self._n_correct += 1 self._pc = float(self._n_correct) / self._n_trials - self._p_val = binom.cdf(self._n_wrong, self._n_trials, - 1 - self._chance) - self._min_p_val = binom.cdf(self._n_wrong, self._max_trials, - 1 - self._chance) - self._max_p_val = binom.cdf(self._n_wrong + (self._max_trials - - self._n_trials), - self._max_trials, 1 - self._chance) - if ((self._p_val <= self._alpha) or - (self._min_p_val >= self._alpha and self._stop_early)): + self._p_val = binom.cdf(self._n_wrong, self._n_trials, 1 - self._chance) + self._min_p_val = binom.cdf(self._n_wrong, self._max_trials, 1 - self._chance) + self._max_p_val = binom.cdf( + self._n_wrong + (self._max_trials - self._n_trials), + self._max_trials, + 1 - self._chance, + ) + if (self._p_val <= self._alpha) or ( + self._min_p_val >= self._alpha and self._stop_early + ): if self._n_trials >= self._min_trials: self._stopped = True if self._n_trials == self._max_trials: @@ -673,12 +718,17 @@ def respond(self, correct): if self._stopped: self._callback( - 'tracker_%s_stop' % self._tracker_id, json.dumps(dict( - responses=[int(s) for s in self._responses], - p_val=self._p_val, - success=int(self.success)))) + "tracker_%s_stop" % self._tracker_id, + json.dumps( + dict( + responses=[int(s) for s in self._responses], + p_val=self._p_val, + success=int(self.success), + ) + ), + ) else: - self._callback('tracker_%s_respond' % self._tracker_id, correct) + self._callback("tracker_%s_respond" % self._tracker_id, correct) # ========================================================================= # Define all the public properties @@ -717,55 +767,47 @@ def n_trials(self): @property def n_wrong(self): - """The number of incorrect trials so far - """ + """The number of incorrect trials so far""" return self._n_wrong @property def n_correct(self): - """The number of correct trials so far - """ + """The number of correct trials so far""" return self._n_correct @property def pc(self): - """Proportion correct (0-1, NaN before any responses made) - """ + """Proportion correct (0-1, NaN before any responses made)""" return self._pc @property def responses(self): - """The response history - """ + """The response history""" return self._responses @property def stopped(self): - """Is the tracker stopped - """ + """Is the tracker stopped""" return self._stopped @property def success(self): - """Has the p-value reached significance - """ + """Has the p-value reached significance""" return self._p_val <= self._alpha @property def x_current(self): - """Included only for compatibility with TrackerDealer - """ + """Included only for compatibility with TrackerDealer""" return self._x_current @property def x(self): - """Included only for compatibility with TrackerDealer - """ + """Included only for compatibility with TrackerDealer""" return np.array([self._x_current for _ in range(self._n_trials)]) @property def stop_rule(self): - return 'trials' + return "trials" # ============================================================================= @@ -774,9 +816,10 @@ def stop_rule(self): # TODO: Make it so you can add a list of values for each dimension (such as the # phase in a BMLD task) and have it return that + # TODO: eventually, make a BaseTracker class so that TrackerDealer can make # sure it has the methods / properties it needs -class TrackerDealer(object): +class TrackerDealer: """Class for selecting and pacing independent simultaneous trackers Parameters @@ -817,36 +860,44 @@ class TrackerDealer(object): pace. """ - def __init__(self, callback, trackers, max_lag=1, pace_rule='reversals', - rand=None): + def __init__(self, callback, trackers, max_lag=1, pace_rule="reversals", rand=None): # dim will only be used for user output. Will be stored as 0-d self._callback = _check_callback(callback) self._trackers = np.asarray(trackers) for ti, t in enumerate(self._trackers.flat): if not isinstance(t, (TrackerUD, TrackerBinom)): - raise TypeError('trackers.ravel()[%d] is type %s, must be ' - 'TrackerUD or TrackerBinom' % (ti, type(t))) + raise TypeError( + "trackers.ravel()[%d] is type %s, must be " + "TrackerUD or TrackerBinom" % (ti, type(t)) + ) if isinstance(t, TrackerBinom) and t.stop_early: - raise ValueError('stop_early for trackers.flat[%d] must be ' - 'False to deal trials from a TrackerBinom ' - 'object' % (ti,)) + raise ValueError( + "stop_early for trackers.flat[%d] must be " + "False to deal trials from a TrackerBinom " + "object" % (ti,) + ) self._shape = self._trackers.shape self._n = np.prod(self._shape) self._max_lag = max_lag self._pace_rule = pace_rule - if any([isinstance(t, TrackerBinom) for t in - self._trackers]) and pace_rule == 'reversals': - raise ValueError('pace_rule must be ''trials'' to deal trials from' - ' a TrackerBinom object') + if ( + any([isinstance(t, TrackerBinom) for t in self._trackers]) + and pace_rule == "reversals" + ): + raise ValueError( + "pace_rule must be " + "trials" + " to deal trials from" + " a TrackerBinom object" + ) if rand is None: self._seed = int(time.time()) rand = np.random.RandomState(self._seed) else: self._seed = None if not isinstance(rand, np.random.RandomState): - raise TypeError('rand must be of type ' - 'numpy.random.RandomState') + raise TypeError("rand must be of type " "numpy.random.RandomState") self._rand = rand self._trial_complete = True self._tracker_history = np.array([], dtype=int) @@ -854,14 +905,19 @@ def __init__(self, callback, trackers, max_lag=1, pace_rule='reversals', self._x_history = np.array([], dtype=float) self._dealer_id = id(self) - self._callback('dealer_identify', json.dumps(dict( - dealer_id=self._dealer_id))) - - self._callback('dealer_%s_init' % self._dealer_id, json.dumps(dict( - trackers=[s._tracker_id for s in self._trackers.ravel()], - shape=self._shape, - max_lag=self._max_lag, - pace_rule=self._pace_rule))) + self._callback("dealer_identify", json.dumps(dict(dealer_id=self._dealer_id))) + + self._callback( + "dealer_%s_init" % self._dealer_id, + json.dumps( + dict( + trackers=[s._tracker_id for s in self._trackers.ravel()], + shape=self._shape, + max_lag=self._max_lag, + pace_rule=self._pace_rule, + ) + ), + ) def __iter__(self): return self @@ -880,12 +936,10 @@ def next(self): raise StopIteration if not self._trial_complete: # Chose a new tracker before responding, so record non-response - self._response_history = np.append(self._response_history, - np.nan) + self._response_history = np.append(self._response_history, np.nan) self._trial_complete = False self._current_tracker = self._pick() - self._tracker_history = np.append(self._tracker_history, - self._current_tracker) + self._tracker_history = np.append(self._tracker_history, self._current_tracker) ss = np.unravel_index(self._current_tracker, self.shape) level = self._trackers.flat[self._current_tracker].x_current self._x_history = np.append(self._x_history, level) @@ -895,15 +949,14 @@ def __next__(self): # for py3k compatibility return self.next() def _pick(self): - """Decide which tracker from which to draw a trial - """ + """Decide which tracker from which to draw a trial""" if self.stopped: - raise RuntimeError('All trackers have stopped.') + raise RuntimeError("All trackers have stopped.") active = np.where([not t.stopped for t in self._trackers.flat])[0] - if self._pace_rule == 'reversals': + if self._pace_rule == "reversals": pace = np.asarray([t.n_reversals for t in self._trackers.flat]) - elif self._pace_rule == 'trials': + elif self._pace_rule == "trials": pace = np.asarray([t.n_trials for t in self._trackers.flat]) pace = pace[active] lag = pace.max() - pace @@ -927,17 +980,21 @@ def respond(self, correct): Was the most recent subject response correct? """ if self._trial_complete: - raise RuntimeError('You must get a trial before you can respond.') + raise RuntimeError("You must get a trial before you can respond.") self._trackers.flat[self._current_tracker].respond(correct) self._trial_complete = True self._response_history = np.append(self._response_history, correct) if self.stopped: self._callback( - 'dealer_%s_stop' % self._dealer_id, json.dumps(dict( - tracker_history=[int(s) for s in self._tracker_history], - response_history=[float(s) for s in - self._response_history], - x_history=[float(s) for s in self._x_history]))) + "dealer_%s_stop" % self._dealer_id, + json.dumps( + dict( + tracker_history=[int(s) for s in self._tracker_history], + response_history=[float(s) for s in self._response_history], + x_history=[float(s) for s in self._x_history], + ) + ), + ) def history(self, include_skips=False): """The history of the dealt trials and the responses @@ -959,12 +1016,14 @@ def history(self, include_skips=False): The response history (i.e., correct or incorrect) """ if include_skips: - return (self._tracker_history, self._x_history, - self._response_history) + return (self._tracker_history, self._x_history, self._response_history) else: inds = np.invert(np.isnan(self._response_history)) - return (self._tracker_history[inds], self._x_history[inds], - self._response_history[inds].astype(bool)) + return ( + self._tracker_history[inds], + self._x_history[inds], + self._response_history[inds].astype(bool), + ) @property def shape(self): @@ -972,21 +1031,19 @@ def shape(self): @property def stopped(self): - """Are all the trackers stopped - """ + """Are all the trackers stopped""" return all(t.stopped for t in self._trackers.flat) @property def trackers(self): - """All of the tracker objects in the container - """ + """All of the tracker objects in the container""" return self._trackers # ============================================================================= # Define the TrackerMHW Class # ============================================================================= -class TrackerMHW(object): +class TrackerMHW: """Up-down adaptive tracker for the modified Hughson-Westlake Procedure This class implements a standard up-down adaptive tracker object. It is @@ -1039,9 +1096,18 @@ class TrackerMHW(object): and finding threshold. """ - def __init__(self, callback, x_min, x_max, base_step=5, factor_down=2, - factor_up_nr=4, start_value=40, n_up_stop=2, - repeat_limit='reversals'): + def __init__( + self, + callback, + x_min, + x_max, + base_step=5, + factor_down=2, + factor_up_nr=4, + start_value=40, + n_up_stop=2, + repeat_limit="reversals", + ): self._callback = _check_callback(callback) self._x_min = x_min self._x_max = x_max @@ -1052,25 +1118,25 @@ def __init__(self, callback, x_min, x_max, base_step=5, factor_down=2, self._n_up_stop = n_up_stop self._repeat_limit = repeat_limit - if type(x_min) != int and type(x_min) != float: - raise TypeError('x_min must be a float or integer') - if type(x_max) != int and type(x_max) != float: - raise TypeError('x_max must be a float or integer') + if not isinstance(x_min, (int, float)): + raise TypeError("x_min must be a float or integer") + if not isinstance(x_max, (int, float)): + raise TypeError("x_max must be a float or integer") self._x = np.asarray([start_value], dtype=float) if not np.isscalar(start_value): - raise TypeError('start_value must be a scalar') + raise TypeError("start_value must be a scalar") else: if start_value % base_step != 0: - raise ValueError('start_value must be a multiple of base_step') + raise ValueError("start_value must be a multiple of base_step") else: if (x_min - start_value) % base_step != 0: - raise ValueError('x_min must be a multiple of base_step') + raise ValueError("x_min must be a multiple of base_step") if (x_max - start_value) % base_step != 0: - raise ValueError('x_max must be a multiple of base_step') + raise ValueError("x_max must be a multiple of base_step") - if type(n_up_stop) != int: - raise TypeError('n_up_stop must be an integer') + if not isinstance(n_up_stop, int): + raise TypeError("n_up_stop must be an integer") self._x_current = float(start_value) self._responses = np.asarray([], dtype=bool) @@ -1090,21 +1156,28 @@ def __init__(self, callback, x_min, x_max, base_step=5, factor_down=2, self._threshold = np.nan # Now write the initialization data out - self._tracker_id = '%s-%s' % (id(self), int(round(time.time() * 1e6))) - self._callback('tracker_identify', json.dumps(dict( - tracker_id=self._tracker_id, - tracker_type='TrackerMHW'))) - - self._callback('tracker_%s_init' % self._tracker_id, json.dumps(dict( - callback=None, - base_step=self._base_step, - factor_down=self._factor_down, - factor_up_nr=self._factor_up_nr, - start_value=self._start_value, - x_min=self._x_min, - x_max=self._x_max, - n_up_stop=self._n_up_stop, - repeat_limit=self._repeat_limit))) + self._tracker_id = "%s-%s" % (id(self), int(round(time.time() * 1e6))) + self._callback( + "tracker_identify", + json.dumps(dict(tracker_id=self._tracker_id, tracker_type="TrackerMHW")), + ) + + self._callback( + "tracker_%s_init" % self._tracker_id, + json.dumps( + dict( + callback=None, + base_step=self._base_step, + factor_down=self._factor_down, + factor_up_nr=self._factor_up_nr, + start_value=self._start_value, + x_min=self._x_min, + x_max=self._x_max, + n_up_stop=self._n_up_stop, + repeat_limit=self._repeat_limit, + ) + ), + ) def respond(self, correct): """Update the tracker based on the last response. @@ -1115,7 +1188,7 @@ def respond(self, correct): Was the most recent subject response correct? """ if self._stopped: - raise RuntimeError('Tracker is stopped.') + raise RuntimeError("Tracker is stopped.") bound = False bad = False @@ -1153,12 +1226,14 @@ def respond(self, correct): if step_dir == 0: self._x = np.append(self._x, self._x[-1]) elif step_dir < 0: - self._x = np.append(self._x, self._x[-1] - - self._factor_down * self._base_step) + self._x = np.append( + self._x, self._x[-1] - self._factor_down * self._base_step + ) elif step_dir > 0: if self._n_correct == 0: - self._x = np.append(self._x, self._x[-1] + - self._factor_up_nr * self._base_step) + self._x = np.append( + self._x, self._x[-1] + self._factor_up_nr * self._base_step + ) else: self._x = np.append(self._x, self._x[-1] + self._base_step) @@ -1167,10 +1242,10 @@ def respond(self, correct): self._limit_count += 1 if bound: bad = True - if self._repeat_limit == 'reversals': + if self._repeat_limit == "reversals": reversal = True self._n_reversals += 1 - if self._repeat_limit == 'ignore': + if self._repeat_limit == "ignore": reversal = False self._direction = 0 if self._x[-1] >= self._x_max: @@ -1178,10 +1253,10 @@ def respond(self, correct): self._limit_count += 1 if bound: bad = True - if self._repeat_limit == 'reversals': + if self._repeat_limit == "reversals": reversal = True self._n_reversals += 1 - if self._repeat_limit == 'ignore': + if self._repeat_limit == "ignore": reversal = False self._direction = 0 @@ -1197,18 +1272,23 @@ def respond(self, correct): if not self._stopped: self._x_current = self._x[-1] - self._callback('tracker_%s_respond' % self._tracker_id, - correct) + self._callback("tracker_%s_respond" % self._tracker_id, correct) else: self._x = self._x[:-1] self._callback( - 'tracker_%s_stop' % self._tracker_id, json.dumps(dict( - responses=[int(s) for s in self._responses], - reversals=[int(s) for s in self._reversals], - x=[float(s) for s in self._x], - threshold=self._threshold, - n_correct_levels={int(k): v for k, v in - self._n_correct_levels.items()}))) + "tracker_%s_stop" % self._tracker_id, + json.dumps( + dict( + responses=[int(s) for s in self._responses], + reversals=[int(s) for s in self._reversals], + x=[float(s) for s in self._x], + threshold=self._threshold, + n_correct_levels={ + int(k): v for k, v in self._n_correct_levels.items() + }, + ) + ), + ) def check_valid(self, n_reversals): """If last reversals contain reversals exceeding x_min or x_max. @@ -1224,15 +1304,18 @@ def check_valid(self, n_reversals): True if none of the reversals are at x_min or x_max and False otherwise. """ - self._valid = (not self._bad_reversals[self._reversals != 0] - [-n_reversals:].any()) + self._valid = not self._bad_reversals[self._reversals != 0][-n_reversals:].any() return self._valid def _stop_here(self): - self._threshold_reached = [self._n_correct_levels[level] == - self._n_up_stop for level in self._levels] - if self._n_correct == 0 and self._x[ - -2] == self._x_max and self._x[-1] == self._x_max: + self._threshold_reached = [ + self._n_correct_levels[level] == self._n_up_stop for level in self._levels + ] + if ( + self._n_correct == 0 + and self._x[-2] == self._x_max + and self._x[-1] == self._x_max + ): self._n_stop = True self._threshold = np.nan elif len(self._x) > 3 and (self._x == self._x_max).sum() >= 4: @@ -1246,8 +1329,10 @@ def _stop_here(self): else: self._n_stop = False if self._n_stop and self._limit_count > 0: - warnings.warn('Tracker {} exceeded x_min or x_max bounds {} times.' - ''.format(self._tracker_id, self._limit_count)) + warnings.warn( + f"Tracker {self._tracker_id} exceeded x_min or x_max bounds " + f"{self._limit_count} times." + ) return self._n_stop # ========================================================================= @@ -1295,44 +1380,37 @@ def threshold(self): @property def stopped(self): - """Has the tracker stopped - """ + """Has the tracker stopped""" return self._stopped @property def x(self): - """The staircase - """ + """The staircase""" return self._x @property def x_current(self): - """The current level - """ + """The current level""" return self._x_current @property def responses(self): - """The response history - """ + """The response history""" return self._responses @property def n_trials(self): - """The number of trials so far - """ + """The number of trials so far""" return self._n_trials @property def n_reversals(self): - """The number of reversals so far - """ + """The number of reversals so far""" return self._n_reversals @property def reversals(self): - """The reversal history (0 where there was no reversal) - """ + """The reversal history (0 where there was no reversal)""" return self._reversals @property @@ -1369,20 +1447,22 @@ def plot(self, ax=None, threshold=True): The handles to the staircase line and the reversal dots. """ import matplotlib.pyplot as plt + if ax is None: fig, ax = plt.subplots(1) else: fig = ax.figure - line = ax.plot(1 + np.arange(self._n_trials), self._x, 'k.-') - line[0].set_label('Trials') - dots = ax.plot(1 + np.where(self._reversals > 0)[0], - self._x[self._reversals > 0], 'ro') - dots[0].set_label('Reversals') - ax.set(xlabel='Trial number', ylabel='Level (dB)') + line = ax.plot(1 + np.arange(self._n_trials), self._x, "k.-") + line[0].set_label("Trials") + dots = ax.plot( + 1 + np.where(self._reversals > 0)[0], self._x[self._reversals > 0], "ro" + ) + dots[0].set_label("Reversals") + ax.set(xlabel="Trial number", ylabel="Level (dB)") if threshold: thresh = self.plot_thresh(ax) - thresh[0].set_label('Threshold') + thresh[0].set_label("Threshold") ax.legend() return fig, ax, line + dots @@ -1401,8 +1481,8 @@ def plot_thresh(self, ax=None): The handle to the threshold line, as returned from ``plt.plot``. """ import matplotlib.pyplot as plt + if ax is None: ax = plt.gca() - h = ax.plot([1, self._n_trials], [self._threshold] * 2, '--', - color='gray') + h = ax.plot([1, self._n_trials], [self._threshold] * 2, "--", color="gray") return h diff --git a/expyfun/stimuli/_vocoder.py b/expyfun/stimuli/_vocoder.py index 321f05b0..d68a4027 100644 --- a/expyfun/stimuli/_vocoder.py +++ b/expyfun/stimuli/_vocoder.py @@ -1,11 +1,10 @@ -# -*- coding: utf-8 -*- -"""Vocoder functions -""" +"""Vocoder functions""" -import numpy as np -from scipy.signal import butter, lfilter, filtfilt import warnings +import numpy as np +from scipy.signal import butter, filtfilt, lfilter + from .._utils import verbose_dec @@ -20,8 +19,9 @@ def _erbn_to_freq(e): @verbose_dec -def get_band_freqs(fs, n_bands=16, freq_lims=(200., 8000.), scale='erb', - verbose=None): +def get_band_freqs( + fs, n_bands=16, freq_lims=(200.0, 8000.0), scale="erb", verbose=None +): """Calculate frequency band edges. Parameters @@ -45,21 +45,20 @@ def get_band_freqs(fs, n_bands=16, freq_lims=(200., 8000.), scale='erb', """ freq_lims = np.array(freq_lims, float) fs = float(fs) - if np.any(freq_lims >= fs / 2.): - raise ValueError('frequency limits must not exceed Nyquist') + if np.any(freq_lims >= fs / 2.0): + raise ValueError("frequency limits must not exceed Nyquist") assert freq_lims.ndim == 1 and freq_lims.size == 2 - if scale not in ('erb', 'log', 'hz'): + if scale not in ("erb", "log", "hz"): raise ValueError('Frequency scale must be "erb", "hz", or "log".') - if scale == 'erb': + if scale == "erb": freq_lims_erbn = _freq_to_erbn(freq_lims) delta_erb = np.diff(freq_lims_erbn) / n_bands - cutoffs = _erbn_to_freq(freq_lims_erbn[0] + - delta_erb * np.arange(n_bands + 1)) + cutoffs = _erbn_to_freq(freq_lims_erbn[0] + delta_erb * np.arange(n_bands + 1)) assert np.allclose(cutoffs[[0, -1]], freq_lims) # should be - elif scale == 'log': + elif scale == "log": freq_lims_log = np.log2(freq_lims) delta = np.diff(freq_lims_log) / n_bands - cutoffs = 2. ** (freq_lims_log[0] + delta * np.arange(n_bands + 1)) + cutoffs = 2.0 ** (freq_lims_log[0] + delta * np.arange(n_bands + 1)) assert np.allclose(cutoffs[[0, -1]], freq_lims) # should be else: # scale == 'hz' delta = np.diff(freq_lims) / n_bands @@ -100,7 +99,7 @@ def get_bands(data, fs, edges, order=2, zero_phase=False, axis=-1): filts = [] for lf, hf in edges: # band-pass - b, a = butter(order, [2 * lf / fs, 2 * hf / fs], 'bandpass') + b, a = butter(order, [2 * lf / fs, 2 * hf / fs], "bandpass") filt = filtfilt if zero_phase else lfilter band = filt(b, a, data, axis=axis) bands.append(band) @@ -108,7 +107,7 @@ def get_bands(data, fs, edges, order=2, zero_phase=False, axis=-1): return bands, filts -def get_env(data, fs, lp_order=4, lp_cutoff=160., zero_phase=False, axis=-1): +def get_env(data, fs, lp_order=4, lp_cutoff=160.0, zero_phase=False, axis=-1): """Calculate a low-pass envelope of a signal Parameters @@ -133,18 +132,17 @@ def get_env(data, fs, lp_order=4, lp_cutoff=160., zero_phase=False, axis=-1): filt : tuple The filter coefficients (numerator, denominator). """ - if lp_cutoff >= fs / 2.: - raise ValueError('frequency limits must not exceed Nyquist') + if lp_cutoff >= fs / 2.0: + raise ValueError("frequency limits must not exceed Nyquist") cutoff = 2 * lp_cutoff / float(fs) - data[data < 0] = 0. # half-wave rectify - b, a = butter(lp_order, cutoff, 'lowpass') + data[data < 0] = 0.0 # half-wave rectify + b, a = butter(lp_order, cutoff, "lowpass") filt = filtfilt if zero_phase else lfilter env = filt(b, a, data, axis=axis) return env, (b, a) -def get_carriers(data, fs, edges, order=2, axis=-1, mode='tone', rate=None, - seed=None): +def get_carriers(data, fs, edges, order=2, axis=-1, mode="tone", rate=None, seed=None): """Generate carriers for frequency bands of a signal Parameters @@ -178,9 +176,8 @@ def get_carriers(data, fs, edges, order=2, axis=-1, mode='tone', rate=None, List of numpy ndarrays of the carrier signals. """ # check args - if mode not in ('noise', 'tone', 'poisson'): - raise ValueError('mode must be "noise", "tone", or "poisson", not {0}' - ''.format(mode)) + if mode not in ("noise", "tone", "poisson"): + raise ValueError(f'mode must be "noise", "tone", or "poisson", not {mode}' "") if isinstance(seed, np.random.RandomState): rng = seed elif seed is None: @@ -188,38 +185,53 @@ def get_carriers(data, fs, edges, order=2, axis=-1, mode='tone', rate=None, elif isinstance(seed, int): rng = np.random.RandomState(seed) else: - raise TypeError('"seed" must be an int, an instance of ' - 'numpy.random.RandomState, or None.') + raise TypeError( + '"seed" must be an int, an instance of ' + "numpy.random.RandomState, or None." + ) carrs = [] fs = float(fs) n_samp = data.shape[axis] for lf, hf in edges: - if mode == 'tone': - cf = (lf + hf) / 2. + if mode == "tone": + cf = (lf + hf) / 2.0 carrier = np.sin(2 * np.pi * cf * np.arange(n_samp) / fs) carrier *= np.sqrt(2) # rms of 1 shape = np.ones_like(data.shape) shape[axis] = n_samp carrier.shape = shape else: - if mode == 'noise': + if mode == "noise": carrier = rng.rand(*data.shape) else: # mode == 'poisson' prob = rate / fs with warnings.catch_warnings(record=True): # numpy silliness - carrier = rng.choice([0., 1.], n_samp, p=[1 - prob, prob]) - b, a = butter(order, [2 * lf / fs, 2 * hf / fs], 'bandpass') + carrier = rng.choice([0.0, 1.0], n_samp, p=[1 - prob, prob]) + b, a = butter(order, [2 * lf / fs, 2 * hf / fs], "bandpass") carrier = lfilter(b, a, carrier, axis=axis) - carrier /= np.sqrt(np.mean(carrier * carrier, axis=axis, - keepdims=True)) # rms of 1 + carrier /= np.sqrt( + np.mean(carrier * carrier, axis=axis, keepdims=True) + ) # rms of 1 carrs.append(carrier) return carrs @verbose_dec -def vocode(data, fs, n_bands=16, freq_lims=(200., 8000.), scale='erb', - order=2, lp_cutoff=160., lp_order=4, mode='noise', - rate=200, seed=None, axis=-1, verbose=None): +def vocode( + data, + fs, + n_bands=16, + freq_lims=(200.0, 8000.0), + scale="erb", + order=2, + lp_cutoff=160.0, + lp_order=4, + mode="noise", + rate=200, + seed=None, + axis=-1, + verbose=None, +): """Vocode stimuli using a variety of methods Parameters @@ -268,14 +280,17 @@ def vocode(data, fs, n_bands=16, freq_lims=(200., 8000.), scale='erb', The default settings are adapted from a cochlear implant simulation algorithm described by Zachary Smith (Cochlear Corp.). """ - edges = get_band_freqs(fs, n_bands=n_bands, freq_lims=freq_lims, - scale=scale) + edges = get_band_freqs(fs, n_bands=n_bands, freq_lims=freq_lims, scale=scale) bands, filts = get_bands(data, fs, edges, order=order, axis=axis) - envs, env_filts = zip(*[get_env(x, fs, lp_order=lp_order, - lp_cutoff=lp_cutoff, axis=axis) - for x in bands]) - carrs = get_carriers(data, fs, edges, order=order, axis=axis, mode=mode, - rate=rate, seed=seed) + envs, env_filts = zip( + *[ + get_env(x, fs, lp_order=lp_order, lp_cutoff=lp_cutoff, axis=axis) + for x in bands + ] + ) + carrs = get_carriers( + data, fs, edges, order=order, axis=axis, mode=mode, rate=rate, seed=seed + ) # reconstruct voc = np.zeros_like(data) for carr, env in zip(carrs, envs): diff --git a/expyfun/stimuli/tests/test_mls.py b/expyfun/stimuli/tests/test_mls.py index e223c04b..a2644946 100644 --- a/expyfun/stimuli/tests/test_mls.py +++ b/expyfun/stimuli/tests/test_mls.py @@ -2,12 +2,11 @@ import pytest from numpy.testing import assert_allclose -from expyfun.stimuli import repeated_mls, compute_mls_impulse_response +from expyfun.stimuli import compute_mls_impulse_response, repeated_mls def test_mls_ir(): - """Test computing impulse response with MLS - """ + """Test computing impulse response with MLS""" # test simple stuff for _ in range(5): # make sure our signals have some DC @@ -17,20 +16,20 @@ def test_mls_ir(): mls, n_resp = repeated_mls(len(kernel), n_repeats) resp = np.zeros(n_resp) - resp[:len(mls) + len(kernel) - 1] = np.convolve(mls, kernel) + resp[: len(mls) + len(kernel) - 1] = np.convolve(mls, kernel) est_kernel = compute_mls_impulse_response(resp, mls, n_repeats) kernel_pad = np.zeros(len(est_kernel)) - kernel_pad[:len(kernel)] = kernel + kernel_pad[: len(kernel)] = kernel assert_allclose(kernel_pad, est_kernel, atol=1e-5, rtol=1e-5) # failure modes - pytest.raises(TypeError, repeated_mls, 'foo', n_repeats) - pytest.raises(ValueError, compute_mls_impulse_response, resp[:-1], mls, - n_repeats) - pytest.raises(ValueError, compute_mls_impulse_response, resp, mls[:-1], - n_repeats) - pytest.raises(ValueError, compute_mls_impulse_response, resp, - mls * 2. - 1., n_repeats) - pytest.raises(ValueError, compute_mls_impulse_response, resp, - mls[np.newaxis, :], n_repeats) + pytest.raises(TypeError, repeated_mls, "foo", n_repeats) + pytest.raises(ValueError, compute_mls_impulse_response, resp[:-1], mls, n_repeats) + pytest.raises(ValueError, compute_mls_impulse_response, resp, mls[:-1], n_repeats) + pytest.raises( + ValueError, compute_mls_impulse_response, resp, mls * 2.0 - 1.0, n_repeats + ) + pytest.raises( + ValueError, compute_mls_impulse_response, resp, mls[np.newaxis, :], n_repeats + ) diff --git a/expyfun/stimuli/tests/test_stimuli.py b/expyfun/stimuli/tests/test_stimuli.py index 0fbb0c77..3b73dfeb 100644 --- a/expyfun/stimuli/tests/test_stimuli.py +++ b/expyfun/stimuli/tests/test_stimuli.py @@ -1,86 +1,112 @@ -# -*- coding: utf-8 -*- - import os import numpy as np import pytest -from numpy.testing import (assert_array_equal, assert_array_almost_equal, - assert_allclose, assert_equal) +from numpy.testing import ( + assert_allclose, + assert_array_almost_equal, + assert_array_equal, + assert_equal, +) from scipy.signal import butter, lfilter -from expyfun._sound_controllers import _BACKENDS -from expyfun._utils import requires_lib, requires_opengl21, _check_skip_backend -from expyfun.stimuli import (rms, play_sound, convolve_hrtf, window_edges, - vocode, texture_ERB, crm_info, crm_prepare_corpus, - crm_sentence, crm_response_menu, CRMPreload, - add_pad) from expyfun import ExperimentController - - -std_kwargs = dict(output_dir=None, full_screen=False, window_size=(340, 480), - participant='foo', session='01', stim_db=0.0, noise_db=0.0, - verbose=True, version='dev') - - -@requires_lib('mne') +from expyfun._sound_controllers import _BACKENDS +from expyfun._utils import _check_skip_backend, requires_lib, requires_opengl21 +from expyfun.stimuli import ( + CRMPreload, + add_pad, + convolve_hrtf, + crm_info, + crm_prepare_corpus, + crm_response_menu, + crm_sentence, + play_sound, + rms, + texture_ERB, + vocode, + window_edges, +) + +std_kwargs = dict( + output_dir=None, + full_screen=False, + window_size=(340, 480), + participant="foo", + session="01", + stim_db=0.0, + noise_db=0.0, + verbose=True, + version="dev", +) + + +@requires_lib("mne") def test_textures(): """Test stimulus textures.""" texture_ERB() # smoke test - pytest.raises(TypeError, texture_ERB, seq='foo') - pytest.raises(ValueError, texture_ERB, seq=('foo',)) - with pytest.warns(UserWarning, match='LESS THAN 1 ERB'): + pytest.raises(TypeError, texture_ERB, seq="foo") + pytest.raises(ValueError, texture_ERB, seq=("foo",)) + with pytest.warns(UserWarning, match="LESS THAN 1 ERB"): x = texture_ERB(freq_lims=(200, 500)) - assert_allclose(len(x) / 24414., 4., rtol=1e-5) + assert_allclose(len(x) / 24414.0, 4.0, rtol=1e-5) @pytest.mark.timeout(15) -@requires_lib('h5py') +@requires_lib("h5py") def test_hrtf_convolution(): """Test HRTF convolution.""" data = np.random.randn(2, 10000) pytest.raises(ValueError, convolve_hrtf, data, 44100, 0, interp=False) data = data[0] pytest.raises(ValueError, convolve_hrtf, data, 44100, 0.5, interp=False) - pytest.raises(ValueError, convolve_hrtf, data, 44100, 0, - source='foo', interp=False) + pytest.raises(ValueError, convolve_hrtf, data, 44100, 0, source="foo", interp=False) pytest.raises(ValueError, convolve_hrtf, data, 44100, 90.5, interp=True) - pytest.raises(ValueError, convolve_hrtf, data, 44100, 0, interp='foo') + pytest.raises(ValueError, convolve_hrtf, data, 44100, 0, interp="foo") # invalid angle when interp=False for interp in [True, False]: - for source in ['barb', 'cipic']: - if interp and source == 'barb': + for source in ["barb", "cipic"]: + if interp and source == "barb": # raise an error when trying to interp with 'barb' - pytest.raises(ValueError, convolve_hrtf, data, 44100, 2.5, - source=source, interp=interp) + pytest.raises( + ValueError, + convolve_hrtf, + data, + 44100, + 2.5, + source=source, + interp=interp, + ) else: - out = convolve_hrtf(data, 44100, 0, source=source, - interp=interp) - out_2 = convolve_hrtf(data, 24414, 0, source=source, - interp=interp) + out = convolve_hrtf(data, 44100, 0, source=source, interp=interp) + out_2 = convolve_hrtf(data, 24414, 0, source=source, interp=interp) assert_equal(out.ndim, 2) assert_equal(out.shape[0], 2) - assert (out.shape[1] > data.size) - assert (out_2.shape[1] < out.shape[1]) + assert out.shape[1] > data.size + assert out_2.shape[1] < out.shape[1] if interp: - out_3 = convolve_hrtf(data, 44100, 2.5, source=source, - interp=interp) - out_4 = convolve_hrtf(data, 44100, -2.5, source=source, - interp=interp) + out_3 = convolve_hrtf( + data, 44100, 2.5, source=source, interp=interp + ) + out_4 = convolve_hrtf( + data, 44100, -2.5, source=source, interp=interp + ) assert_equal(out_3.ndim, 2) assert_equal(out_4.ndim, 2) # ensure that, at least for zero degrees, it's close - out = convolve_hrtf(data, 44100, 0, source=source, - interp=interp)[:, 1024:-1024] + out = convolve_hrtf(data, 44100, 0, source=source, interp=interp)[ + :, 1024:-1024 + ] assert_allclose(np.mean(rms(out)), rms(data), rtol=1e-1) - out = convolve_hrtf(data, 44100, -90, source=source, - interp=interp) + out = convolve_hrtf(data, 44100, -90, source=source, interp=interp) rmss = rms(out) - assert (rmss[0] > 4 * rmss[1]) + assert rmss[0] > 4 * rmss[1] -@pytest.mark.skipif(os.getenv('AZURE_CI_WINDOWS', '') == 'true', - reason='Azure CI Windows has problems') -@pytest.mark.parametrize('backend', ('auto',) + _BACKENDS) +@pytest.mark.skipif( + os.getenv("AZURE_CI_WINDOWS", "") == "true", reason="Azure CI Windows has problems" +) +@pytest.mark.parametrize("backend", ("auto",) + _BACKENDS) def test_play_sound(backend, hide_window): # only works if windowing works """Test playing a sound.""" _check_skip_backend(backend) @@ -88,7 +114,7 @@ def test_play_sound(backend, hide_window): # only works if windowing works data = np.zeros((2, 100)) play_sound(data, fs=fs).stop() play_sound(data[0], norm=False, wait=True, fs=fs) - with pytest.raises(ValueError, match='sound must be'): + with pytest.raises(ValueError, match="sound must be"): play_sound(data[:, :, np.newaxis], fs=fs) # Make sure each backend can handle a lot of sounds for _ in range(10): @@ -105,40 +131,40 @@ def test_window_edges(): """Test windowing signal edges.""" sig = np.ones((2, 1000)) fs = 44100 - pytest.raises(ValueError, window_edges, sig, fs, window='foo') # bad win + pytest.raises(ValueError, window_edges, sig, fs, window="foo") # bad win pytest.raises(RuntimeError, window_edges, sig, fs, dur=1.0) # too long - pytest.raises(ValueError, window_edges, sig, fs, edges='foo') # bad type - x = window_edges(sig, fs, edges='leading') - y = window_edges(sig, fs, edges='trailing') + pytest.raises(ValueError, window_edges, sig, fs, edges="foo") # bad type + x = window_edges(sig, fs, edges="leading") + y = window_edges(sig, fs, edges="trailing") z = window_edges(sig, fs) - assert (np.all(x[:, 0] < 1)) # make sure we actually reduced amp - assert (np.all(x[:, -1] == 1)) - assert (np.all(y[:, 0] == 1)) - assert (np.all(y[:, -1] < 1)) + assert np.all(x[:, 0] < 1) # make sure we actually reduced amp + assert np.all(x[:, -1] == 1) + assert np.all(y[:, 0] == 1) + assert np.all(y[:, -1] < 1) assert_allclose(x + y, z + 1) def _voc_similarity(orig, voc): """Quantify envelope similarity after vocoding.""" - return np.correlate(orig, voc, mode='full').max() + return np.correlate(orig, voc, mode="full").max() def test_vocoder(): """Test noise, tone, and click vocoding.""" data = np.random.randn(10000) env = np.random.randn(10000) - b, a = butter(4, 0.001, 'lowpass') + b, a = butter(4, 0.001, "lowpass") data *= lfilter(b, a, env) # bad limits pytest.raises(ValueError, vocode, data, 44100, freq_lims=(200, 30000)) # bad mode - pytest.raises(ValueError, vocode, data, 44100, mode='foo') + pytest.raises(ValueError, vocode, data, 44100, mode="foo") # bad seed - pytest.raises(TypeError, vocode, data, 44100, seed='foo') - pytest.raises(ValueError, vocode, data, 44100, scale='foo') - voc1 = vocode(data, 20000, mode='noise', scale='log') - voc2 = vocode(data, 20000, mode='tone', order=4, seed=0, scale='hz') - voc3 = vocode(data, 20000, mode='poisson', seed=np.random.RandomState(123)) + pytest.raises(TypeError, vocode, data, 44100, seed="foo") + pytest.raises(ValueError, vocode, data, 44100, scale="foo") + voc1 = vocode(data, 20000, mode="noise", scale="log") + voc2 = vocode(data, 20000, mode="tone", order=4, seed=0, scale="hz") + voc3 = vocode(data, 20000, mode="poisson", seed=np.random.RandomState(123)) # XXX This is about the best we can do for now... assert_array_equal(voc1.shape, data.shape) assert_array_equal(voc2.shape, data.shape) @@ -148,13 +174,13 @@ def test_vocoder(): def test_rms(): """Test RMS calculation.""" # Test a couple trivial things we know - sin = np.sin(2 * np.pi * 1000 * np.arange(10000, dtype=float) / 10000.) - assert_array_almost_equal(rms(sin), 1. / np.sqrt(2)) + sin = np.sin(2 * np.pi * 1000 * np.arange(10000, dtype=float) / 10000.0) + assert_array_almost_equal(rms(sin), 1.0 / np.sqrt(2)) assert_array_almost_equal(rms(np.ones((100, 2)) * 2, 0), [2, 2]) @pytest.mark.timeout(120) # can be slow to load on CIs -@requires_lib('mne') +@requires_lib("mne") def test_crm(tmpdir): """Test CRM Corpus functions.""" fs = 40000 # native rate, to avoid large resampling delay in testing @@ -162,63 +188,58 @@ def test_crm(tmpdir): tempdir = str(tmpdir) # corpus prep - talkers = [dict(sex='f', talker_num=0)] + talkers = [dict(sex="f", talker_num=0)] - crm_prepare_corpus(fs, path_out=tempdir, talker_list=talkers, - n_jobs=1) - crm_prepare_corpus(fs, path_out=tempdir, talker_list=talkers, n_jobs=1, - overwrite=True) + crm_prepare_corpus(fs, path_out=tempdir, talker_list=talkers, n_jobs=1) + crm_prepare_corpus( + fs, path_out=tempdir, talker_list=talkers, n_jobs=1, overwrite=True + ) # no overwrite pytest.raises(RuntimeError, crm_prepare_corpus, fs, path_out=tempdir) # load sentence from hard drive - crm_sentence(fs, 'f', 0, 0, 0, 0, 0, ramp_dur=0, path=tempdir) - crm_sentence(fs, 1, '0', 'charlie', 'red', '5', stereo=True, path=tempdir) + crm_sentence(fs, "f", 0, 0, 0, 0, 0, ramp_dur=0, path=tempdir) + crm_sentence(fs, 1, "0", "charlie", "red", "5", stereo=True, path=tempdir) # bad value requested - pytest.raises(ValueError, crm_sentence, fs, 1, 0, 0, 'periwinkle', 0, - path=tempdir) + pytest.raises(ValueError, crm_sentence, fs, 1, 0, 0, "periwinkle", 0, path=tempdir) # unprepared talker - pytest.raises(RuntimeError, crm_sentence, fs, 'm', 0, 0, 0, 0, - path=tempdir) + pytest.raises(RuntimeError, crm_sentence, fs, "m", 0, 0, 0, 0, path=tempdir) # unprepared sampling rate - pytest.raises(RuntimeError, crm_sentence, fs + 1, 0, 0, 0, 0, 0, - path=tempdir) + pytest.raises(RuntimeError, crm_sentence, fs + 1, 0, 0, 0, 0, 0, path=tempdir) # CRMPreload class crm = CRMPreload(fs, path=tempdir) - crm.sentence('f', 0, 0, 0, 0) + crm.sentence("f", 0, 0, 0, 0) # unprepared sampling rate pytest.raises(RuntimeError, CRMPreload, fs + 1) # bad value requested - pytest.raises(ValueError, crm.sentence, 1, 0, 0, 'periwinkle', 0) + pytest.raises(ValueError, crm.sentence, 1, 0, 0, "periwinkle", 0) # unprepared talker - pytest.raises(RuntimeError, crm.sentence, 'm', 0, 0, 0, 0) + pytest.raises(RuntimeError, crm.sentence, "m", 0, 0, 0, 0) # try to specify parameters like fs, stereo, etc. - pytest.raises(TypeError, crm.sentence, fs, '1', '0', 'charlie', 'red', '5') + pytest.raises(TypeError, crm.sentence, fs, "1", "0", "charlie", "red", "5") # add_pad x1 = np.zeros(10) x2 = np.ones((2, 5)) x = add_pad([x1, x2]) - assert (np.sum(x[..., -1] == 0)) - x = add_pad((x1, x2), 'center') - assert (np.sum(x[..., -1] == 0) and np.sum(x[..., 0] == 0)) - x = add_pad((x1, x2), 'end') - assert (np.sum(x[..., 0] == 0)) + assert np.sum(x[..., -1] == 0) + x = add_pad((x1, x2), "center") + assert np.sum(x[..., -1] == 0) and np.sum(x[..., 0] == 0) + x = add_pad((x1, x2), "end") + assert np.sum(x[..., 0] == 0) @pytest.mark.timeout(15) @requires_opengl21 def test_crm_response_menu(hide_window): """Test the CRM Response menu function.""" - with ExperimentController('crm_menu', **std_kwargs) as ec: + with ExperimentController("crm_menu", **std_kwargs) as ec: resp = crm_response_menu(ec, max_wait=0.05) crm_response_menu(ec, numbers=[0, 1, 2], max_wait=0.05) - crm_response_menu(ec, colors=['blue'], max_wait=0.05) - crm_response_menu(ec, colors=['r'], numbers=['7'], max_wait=0.05) + crm_response_menu(ec, colors=["blue"], max_wait=0.05) + crm_response_menu(ec, colors=["r"], numbers=["7"], max_wait=0.05) assert_equal(resp, (None, None)) - pytest.raises(ValueError, crm_response_menu, ec, - max_wait=0, min_wait=1) - pytest.raises(ValueError, crm_response_menu, ec, - colors=['g', 'g']) + pytest.raises(ValueError, crm_response_menu, ec, max_wait=0, min_wait=1) + pytest.raises(ValueError, crm_response_menu, ec, colors=["g", "g"]) diff --git a/expyfun/stimuli/tests/test_tracker.py b/expyfun/stimuli/tests/test_tracker.py index 906d6e9b..f50cc26d 100644 --- a/expyfun/stimuli/tests/test_tracker.py +++ b/expyfun/stimuli/tests/test_tracker.py @@ -1,10 +1,10 @@ import numpy as np - -from expyfun.stimuli import TrackerUD, TrackerBinom, TrackerDealer, TrackerMHW -from expyfun import ExperimentController import pytest from numpy.testing import assert_equal + +from expyfun import ExperimentController from expyfun._utils import requires_opengl21 +from expyfun.stimuli import TrackerBinom, TrackerDealer, TrackerMHW, TrackerUD def callback(event_type, value=None, timestamp=None): @@ -12,11 +12,20 @@ def callback(event_type, value=None, timestamp=None): print(event_type, value, timestamp) -std_kwargs = dict(output_dir=None, full_screen=False, window_size=(1, 1), - participant='foo', session='01', stim_db=0.0, noise_db=0.0, - trigger_controller='dummy', response_device='keyboard', - audio_controller='sound_card', - verbose=True, version='dev') +std_kwargs = dict( + output_dir=None, + full_screen=False, + window_size=(1, 1), + participant="foo", + session="01", + stim_db=0.0, + noise_db=0.0, + trigger_controller="dummy", + response_device="keyboard", + audio_controller="sound_card", + verbose=True, + version="dev", +) @pytest.mark.timeout(15) @@ -24,8 +33,9 @@ def callback(event_type, value=None, timestamp=None): def test_tracker_ud(hide_window): """Test TrackerUD""" import matplotlib.pyplot as plt + tr = TrackerUD(callback, 3, 1, 1, 1, np.inf, 10, 1) - with ExperimentController('test', **std_kwargs) as ec: + with ExperimentController("test", **std_kwargs) as ec: tr = TrackerUD(ec, 3, 1, 1, 1, np.inf, 10, 1) tr = TrackerUD(None, 3, 1, 1, 1, 10, np.inf, 1) rand = np.random.RandomState(0) @@ -70,89 +80,100 @@ def test_tracker_ud(hide_window): tr.check_valid(2) # bad callback type - with pytest.raises(TypeError, - match="callback must be a callable, None, or an"): - TrackerUD('foo', 3, 1, 1, 1, 10, np.inf, 1) + with pytest.raises(TypeError, match="callback must be a callable, None, or an"): + TrackerUD("foo", 3, 1, 1, 1, 10, np.inf, 1) # test dynamic step size and error conditions - tr = TrackerUD(None, 3, 1, [1, 0.5], [1, 0.5], 10, np.inf, 1, - change_indices=[2]) + tr = TrackerUD(None, 3, 1, [1, 0.5], [1, 0.5], 10, np.inf, 1, change_indices=[2]) tr.respond(True) - tr = TrackerUD(None, 1, 1, 0.75, 0.75, np.inf, 9, 1, - x_min=0, x_max=2) + tr = TrackerUD(None, 1, 1, 0.75, 0.75, np.inf, 9, 1, x_min=0, x_max=2) responses = [True, True, True, False, False, False, False, True, False] - with pytest.warns(UserWarning, match='exceeded x_min'): + with pytest.warns(UserWarning, match="exceeded x_min"): for r in responses: # run long enough to encounter change_indices tr.respond(r) assert tr.check_valid(1) # make sure checking validity is good assert not tr.check_valid(3) - with pytest.raises(ValueError, - match="with reversals attempting to exceed x_min"): + with pytest.raises(ValueError, match="with reversals attempting to exceed x_min"): tr.threshold(1) tr.threshold(3) assert_equal(tr.n_trials, tr.stop_trials) # run tests with ignore too--should generate warnings, but no error - tr = TrackerUD(None, 1, 1, 0.75, 0.25, np.inf, 8, 1, - x_min=0, x_max=2, repeat_limit='ignore') + tr = TrackerUD( + None, 1, 1, 0.75, 0.25, np.inf, 8, 1, x_min=0, x_max=2, repeat_limit="ignore" + ) responses = [False, True, False, False, True, True, False, True] - with pytest.warns(UserWarning, match='exceeded x_min'): + with pytest.warns(UserWarning, match="exceeded x_min"): for r in responses: # run long enough to encounter change_indices tr.respond(r) tr.threshold(0) # bad stop_trials - with pytest.raises(ValueError, - match="stop_trials must be an integer or np.inf"): - TrackerUD(None, 3, 1, 1, 1, 10, 'foo', 1) + with pytest.raises(ValueError, match="stop_trials must be an integer or np.inf"): + TrackerUD(None, 3, 1, 1, 1, 10, "foo", 1) # bad stop_reversals - with pytest.raises(ValueError, - match="stop_reversals must be an integer or np.inf"): - TrackerUD(None, 3, 1, 1, 1, 'foo', 10, 1) + with pytest.raises(ValueError, match="stop_reversals must be an integer or np.inf"): + TrackerUD(None, 3, 1, 1, 1, "foo", 10, 1) # change_indices too long - with pytest.raises(ValueError, - match="one element longer than change_indices"): - TrackerUD(None, 3, 1, [1, 0.5], [1, 0.5], 10, np.inf, 1, - change_indices=[1, 2]) + with pytest.raises(ValueError, match="one element longer than change_indices"): + TrackerUD(None, 3, 1, [1, 0.5], [1, 0.5], 10, np.inf, 1, change_indices=[1, 2]) # step_size_up length mismatch - with pytest.raises(ValueError, - match="step_size_up is not scalar it must be one"): + with pytest.raises(ValueError, match="step_size_up is not scalar it must be one"): TrackerUD(None, 3, 1, [1], [1, 0.5], 10, np.inf, 1, change_indices=[2]) # step_size_down length mismatch - with pytest.raises(ValueError, - match="If step_size_down is not scalar it must be one"): + with pytest.raises( + ValueError, match="If step_size_down is not scalar it must be one" + ): TrackerUD(None, 3, 1, [1, 0.5], [1], 10, np.inf, 1, change_indices=[2]) # bad change_rule - with pytest.raises(ValueError, - match="must be either 'trials' or 'reversals"): - TrackerUD(None, 3, 1, [1, 0.5], [1, 0.5], 10, np.inf, 1, - change_indices=[2], change_rule='foo') + with pytest.raises(ValueError, match="must be either 'trials' or 'reversals"): + TrackerUD( + None, + 3, + 1, + [1, 0.5], + [1, 0.5], + 10, + np.inf, + 1, + change_indices=[2], + change_rule="foo", + ) # no change_indices (i.e. change_indices=None) - with pytest.raises(ValueError, - match="If step_size_up is longer than 1, you must"): + with pytest.raises(ValueError, match="If step_size_up is longer than 1, you must"): TrackerUD(None, 3, 1, [1, 0.5], [1, 0.5], 10, np.inf, 1) # start_value scalar type checking with pytest.raises(TypeError, match="start_value must be a scalar"): - TrackerUD(None, 3, 1, [1, 0.5], [1, 0.5], 10, np.inf, [9, 5], - change_indices=[2]) + TrackerUD( + None, 3, 1, [1, 0.5], [1, 0.5], 10, np.inf, [9, 5], change_indices=[2] + ) with pytest.raises(TypeError, match="start_value must be a scalar"): - TrackerUD(None, 3, 1, [1, 0.5], [1, 0.5], 10, np.inf, None, - change_indices=[2]) + TrackerUD(None, 3, 1, [1, 0.5], [1, 0.5], 10, np.inf, None, change_indices=[2]) # test with multiple change_indices - tr = TrackerUD(None, 3, 1, [3, 2, 1], [3, 2, 1], 10, np.inf, 1, - change_indices=[2, 4], change_rule='reversals') + tr = TrackerUD( + None, + 3, + 1, + [3, 2, 1], + [3, 2, 1], + 10, + np.inf, + 1, + change_indices=[2, 4], + change_rule="reversals", + ) @requires_opengl21 def test_tracker_binom(hide_window): """Test TrackerBinom""" tr = TrackerBinom(callback, 0.05, 0.1, 5) - with ExperimentController('test', **std_kwargs) as ec: + with ExperimentController("test", **std_kwargs) as ec: tr = TrackerBinom(ec, 0.05, 0.1, 5) tr = TrackerBinom(None, 0.05, 0.5, 2, stop_early=False) while not tr.stopped: @@ -191,29 +212,38 @@ def test_tracker_binom(hide_window): def test_tracker_dealer(): """Test TrackerDealer.""" # test TrackerDealer with TrackerUD - trackers = [[TrackerUD(None, 1, 1, 0.06, 0.02, 20, np.inf, - 1) for _ in range(2)] for _ in range(3)] + trackers = [ + [TrackerUD(None, 1, 1, 0.06, 0.02, 20, np.inf, 1) for _ in range(2)] + for _ in range(3) + ] dealer_ud = TrackerDealer(callback, trackers) # can't respond to a trial twice dealer_ud.next() dealer_ud.respond(True) - with pytest.raises(RuntimeError, - match="You must get a trial before you can respond."): + with pytest.raises( + RuntimeError, match="You must get a trial before you can respond." + ): dealer_ud.respond(True) dealer_ud = TrackerDealer(callback, np.array(trackers)) # can't respond before you pick a tracker and get a trial - with pytest.raises(RuntimeError, - match="You must get a trial before you can respond."): + with pytest.raises( + RuntimeError, match="You must get a trial before you can respond." + ): dealer_ud.respond(True) rand = np.random.RandomState(0) for sub, x_current in dealer_ud: dealer_ud.respond(rand.rand() < x_current) - assert np.abs(dealer_ud.trackers[0, 0].n_reversals - - dealer_ud.trackers[1, 0].n_reversals) <= 1 + assert ( + np.abs( + dealer_ud.trackers[0, 0].n_reversals + - dealer_ud.trackers[1, 0].n_reversals + ) + <= 1 + ) # test array-like indexing dealer_ud.trackers[0] @@ -225,39 +255,41 @@ def test_tracker_dealer(): dealer_ud.history(True) # bad rand type - trackers = [TrackerUD(None, 1, 1, 0.06, 0.02, 20, 50, 1) - for _ in range(2)] + trackers = [TrackerUD(None, 1, 1, 0.06, 0.02, 20, 50, 1) for _ in range(2)] with pytest.raises(TypeError, match="argument"): TrackerDealer(trackers, rand=1) # test TrackerDealer with TrackerBinom - trackers = [TrackerBinom(None, 0.05, 0.5, 50, stop_early=False) - for _ in range(2)] # start_value scalar type checking + trackers = [ + TrackerBinom(None, 0.05, 0.5, 50, stop_early=False) for _ in range(2) + ] # start_value scalar type checking with pytest.raises(TypeError, match="start_value must be a scalar"): - TrackerUD(None, 3, 1, [1, 0.5], [1, 0.5], 10, np.inf, [9, 5], - change_indices=[2]) - dealer_binom = TrackerDealer(callback, trackers, pace_rule='trials') + TrackerUD( + None, 3, 1, [1, 0.5], [1, 0.5], 10, np.inf, [9, 5], change_indices=[2] + ) + dealer_binom = TrackerDealer(callback, trackers, pace_rule="trials") for sub, x_current in dealer_binom: dealer_binom.respond(True) # if you're dealing from TrackerBinom, you can't use stop_early feature - trackers = [TrackerBinom(None, 0.05, 0.5, 50, stop_early=True, x_current=3) - for _ in range(2)] - with pytest.raises(ValueError, - match="be False to deal trials from a TrackerBinom"): - TrackerDealer(callback, trackers, 1, 'trials') + trackers = [ + TrackerBinom(None, 0.05, 0.5, 50, stop_early=True, x_current=3) + for _ in range(2) + ] + with pytest.raises(ValueError, match="be False to deal trials from a TrackerBinom"): + TrackerDealer(callback, trackers, 1, "trials") # if you're dealing from TrackerBinom, you can't use reversals to pace - with pytest.raises(ValueError, - match="be False to deal trials from a TrackerBinom"): + with pytest.raises(ValueError, match="be False to deal trials from a TrackerBinom"): TrackerDealer(callback, trackers, 1) def test_tracker_mhw(hide_window): """Test TrackerMHW""" import matplotlib.pyplot as plt + tr = TrackerMHW(callback, 0, 120) - with ExperimentController('test', **std_kwargs) as ec: + with ExperimentController("test", **std_kwargs) as ec: tr = TrackerMHW(ec, 0, 120) tr = TrackerMHW(None, 0, 120) rand = np.random.RandomState(0) @@ -273,11 +305,27 @@ def test_tracker_mhw(hide_window): with pytest.raises(RuntimeError, match="Tracker is stopped."): tr.respond(0) - for key in ('base_step', 'factor_down', 'factor_up_nr', 'start_value', - 'x_min', 'x_max', 'n_up_stop', 'repeat_limit', - 'n_correct_levels', 'threshold', 'stopped', 'x', 'x_current', - 'responses', 'n_trials', 'n_reversals', 'reversals', - 'reversal_inds', 'threshold_reached'): + for key in ( + "base_step", + "factor_down", + "factor_up_nr", + "start_value", + "x_min", + "x_max", + "n_up_stop", + "repeat_limit", + "n_correct_levels", + "threshold", + "stopped", + "x", + "x_current", + "responses", + "n_trials", + "n_reversals", + "reversals", + "reversal_inds", + "threshold_reached", + ): assert hasattr(tr, key) fig, ax, lines = tr.plot() @@ -289,45 +337,42 @@ def test_tracker_mhw(hide_window): plt.close(fig) # start_value scalar type checking - with pytest.raises(TypeError, match='start_value must be a scalar'): + with pytest.raises(TypeError, match="start_value must be a scalar"): TrackerMHW(None, 0, 120, 5, 2, 4, [5, 4], 2) # n_up_stop integer check - with pytest.raises(TypeError, match='n_up_stop must be an integer'): + with pytest.raises(TypeError, match="n_up_stop must be an integer"): TrackerMHW(None, 0, 120, 5, 2, 4, 40, 1.5) # x_min integer or float check - with pytest.raises(TypeError, match='x_min must be a float or integer'): - TrackerMHW(None, '5', 120, 5, 2, 4, 40, 2) + with pytest.raises(TypeError, match="x_min must be a float or integer"): + TrackerMHW(None, "5", 120, 5, 2, 4, 40, 2) # x_max integer or float check - with pytest.raises(TypeError, match='x_max must be a float or integer'): - TrackerMHW(None, 0, '90', 5, 2, 4, 40, 2) + with pytest.raises(TypeError, match="x_max must be a float or integer"): + TrackerMHW(None, 0, "90", 5, 2, 4, 40, 2) # start_value is a multiple of base_step - with pytest.raises(ValueError, - match='start_value must be a multiple of base_step'): + with pytest.raises(ValueError, match="start_value must be a multiple of base_step"): TrackerMHW(None, 0, 120, 5, 2, 4, 41, 2) # x_min factor check - with pytest.raises(ValueError, - match='x_min must be a multiple of base_step'): + with pytest.raises(ValueError, match="x_min must be a multiple of base_step"): TrackerMHW(None, 2, 120, 5, 2, 4, 40, 2) # x_max factor check - with pytest.raises(ValueError, - match='x_max must be a multiple of base_step'): + with pytest.raises(ValueError, match="x_max must be a multiple of base_step"): TrackerMHW(None, 0, 93, 5, 2, 4, 40, 2) tr = TrackerMHW(None, 0, 120, 5, 2, 4, 10, 2) responses = [True, True, True, True] - with pytest.warns(UserWarning, match='exceeded x_min or x_max bounds'): + with pytest.warns(UserWarning, match="exceeded x_min or x_max bounds"): for r in responses: tr.respond(r) tr = TrackerMHW(None, 0, 120, 5, 2, 4, 40, 2) responses = [False, False, False, False, False] - with pytest.warns(UserWarning, match='exceeded x_min or x_max bounds'): + with pytest.warns(UserWarning, match="exceeded x_min or x_max bounds"): for r in responses: tr.respond(r) assert not tr.check_valid(3) tr = TrackerMHW(None, 0, 120, 5, 2, 4, 40, 2) responses = [False, False, False, False, True, False, False, True] - with pytest.warns(UserWarning, match='exceeded x_min or x_max bounds'): + with pytest.warns(UserWarning, match="exceeded x_min or x_max bounds"): for r in responses: tr.respond(r) diff --git a/expyfun/tests/test_docstring_parameters.py b/expyfun/tests/test_docstring_parameters.py index db509a2e..0a8eeea7 100644 --- a/expyfun/tests/test_docstring_parameters.py +++ b/expyfun/tests/test_docstring_parameters.py @@ -1,11 +1,11 @@ import inspect -from inspect import getsource import os.path as op -from pkgutil import walk_packages import re import sys -from unittest import SkipTest import warnings +from inspect import getsource +from pkgutil import walk_packages +from unittest import SkipTest import pytest @@ -14,12 +14,12 @@ public_modules = [ # the list of modules users need to access for all functionality - 'expyfun', - 'expyfun.stimuli', - 'expyfun.io', - 'expyfun.visual', - 'expyfun.codeblocks', - 'expyfun.analyze', + "expyfun", + "expyfun.stimuli", + "expyfun.io", + "expyfun.visual", + "expyfun.codeblocks", + "expyfun.analyze", ] @@ -31,7 +31,7 @@ def requires_numpydoc(fun): have = False else: have = True - return pytest.mark.skipif(not have, reason='Requires numpydoc')(fun) + return pytest.mark.skipif(not have, reason="Requires numpydoc")(fun) def get_name(func, cls=None): @@ -43,70 +43,72 @@ def get_name(func, cls=None): if cls is not None: parts.append(cls.__name__) parts.append(func.__name__) - return '.'.join(parts) + return ".".join(parts) # functions to ignore args / docstring of -docstring_ignores = [ -] +docstring_ignores = [] char_limit = 800 # XX eventually we should probably get this lower -docstring_length_ignores = [ -] -tab_ignores = [ -] +docstring_length_ignores = [] +tab_ignores = [] _doc_special_members = [] def check_parameters_match(func, doc=None, cls=None): """Check docstring, return list of incorrect results.""" from numpydoc import docscrape + incorrect = [] name_ = get_name(func, cls=cls) - if not name_.startswith('expyfun.') or \ - name_.startswith('expyfun._externals'): + if not name_.startswith("expyfun.") or name_.startswith("expyfun._externals"): return incorrect if inspect.isdatadescriptor(func): return incorrect args = _get_args(func) # drop self - if len(args) > 0 and args[0] == 'self': + if len(args) > 0 and args[0] == "self": args = args[1:] if doc is None: with warnings.catch_warnings(record=True) as w: - warnings.simplefilter('always') + warnings.simplefilter("always") try: doc = docscrape.FunctionDoc(func) except Exception as exp: - incorrect += [name_ + ' parsing error: ' + str(exp)] + incorrect += [name_ + " parsing error: " + str(exp)] return incorrect if len(w): - raise RuntimeError('Error for %s:\n%s' % (name_, w[0])) + raise RuntimeError("Error for %s:\n%s" % (name_, w[0])) # check set - parameters = doc['Parameters'] + parameters = doc["Parameters"] # clean up some docscrape output: - parameters = [[p[0].split(':')[0].strip('` '), p[2]] - for p in parameters] - parameters = [p for p in parameters if '*' not in p[0]] + parameters = [[p[0].split(":")[0].strip("` "), p[2]] for p in parameters] + parameters = [p for p in parameters if "*" not in p[0]] param_names = [p[0] for p in parameters] if len(param_names) != len(args): - bad = str(sorted(list(set(param_names) - set(args)) + - list(set(args) - set(param_names)))) - if not any(re.match(d, name_) for d in docstring_ignores) and \ - 'deprecation_wrapped' not in func.__code__.co_name: - incorrect += [name_ + ' arg mismatch: ' + bad] + bad = str( + sorted( + list(set(param_names) - set(args)) + list(set(args) - set(param_names)) + ) + ) + if ( + not any(re.match(d, name_) for d in docstring_ignores) + and "deprecation_wrapped" not in func.__code__.co_name + ): + incorrect += [name_ + " arg mismatch: " + bad] else: for n1, n2 in zip(param_names, args): if n1 != n2: - incorrect += [name_ + ' ' + n1 + ' != ' + n2] + incorrect += [name_ + " " + n1 + " != " + n2] for param_name, desc in parameters: - desc = '\n'.join(desc) - full_name = name_ + '::' + param_name + desc = "\n".join(desc) + full_name = name_ + "::" + param_name if full_name in docstring_length_ignores: assert len(desc) > char_limit # assert it actually needs to be elif len(desc) > char_limit: - incorrect += ['%s too long (%d > %d chars)' - % (full_name, len(desc), char_limit)] + incorrect += [ + "%s too long (%d > %d chars)" % (full_name, len(desc), char_limit) + ] return incorrect @@ -114,37 +116,39 @@ def check_parameters_match(func, doc=None, cls=None): def test_docstring_parameters(): """Test module docstring formatting.""" from numpydoc import docscrape + incorrect = [] for name in public_modules: with warnings.catch_warnings(record=True): - warnings.simplefilter('ignore') + warnings.simplefilter("ignore") module = __import__(name, globals()) - for submod in name.split('.')[1:]: + for submod in name.split(".")[1:]: module = getattr(module, submod) classes = inspect.getmembers(module, inspect.isclass) for cname, cls in classes: - if cname.startswith('_') and cname not in _doc_special_members: + if cname.startswith("_") and cname not in _doc_special_members: continue with warnings.catch_warnings(record=True) as w: - warnings.simplefilter('always') + warnings.simplefilter("always") cdoc = docscrape.ClassDoc(cls) for ww in w: - if 'Using or importing the ABCs' not in str(ww.message): - raise RuntimeError('Error for __init__ of %s in %s:\n%s' - % (cls, name, ww)) - if hasattr(cls, '__init__'): + if "Using or importing the ABCs" not in str(ww.message): + raise RuntimeError( + "Error for __init__ of %s in %s:\n%s" % (cls, name, ww) + ) + if hasattr(cls, "__init__"): incorrect += check_parameters_match(cls.__init__, cdoc, cls) for method_name in cdoc.methods: method = getattr(cls, method_name) incorrect += check_parameters_match(method, cls=cls) - if hasattr(cls, '__call__'): + if hasattr(cls, "__call__"): incorrect += check_parameters_match(cls.__call__, cls=cls) functions = inspect.getmembers(module, inspect.isfunction) for fname, func in functions: - if fname.startswith('_'): + if fname.startswith("_"): continue incorrect += check_parameters_match(func) - msg = '\n' + '\n'.join(sorted(list(set(incorrect)))) + msg = "\n" + "\n".join(sorted(list(set(incorrect)))) if len(incorrect) > 0: raise AssertionError(msg) @@ -153,28 +157,27 @@ def test_tabs(): """Test that there are no tabs in our source files.""" # avoid importing modules that require mayavi if mayavi is not installed ignore = tab_ignores[:] - for importer, modname, ispkg in walk_packages(expyfun.__path__, - prefix='expyfun.'): + for importer, modname, ispkg in walk_packages(expyfun.__path__, prefix="expyfun."): if not ispkg and modname not in ignore: # mod = importlib.import_module(modname) # not py26 compatible! try: with warnings.catch_warnings(record=True): - warnings.simplefilter('ignore') + warnings.simplefilter("ignore") __import__(modname) except Exception: # can't import properly continue mod = sys.modules[modname] try: source = getsource(mod) - except IOError: # user probably should have run "make clean" + except OSError: # user probably should have run "make clean" continue - assert '\t' not in source, ('"%s" has tabs, please remove them ' - 'or add it to the ignore list' - % modname) + assert "\t" not in source, ( + '"%s" has tabs, please remove them ' + "or add it to the ignore list" % modname + ) -documented_ignored_mods = ( -) +documented_ignored_mods = () documented_ignored_names = """ add_pad fetch_data_file @@ -187,48 +190,51 @@ def test_tabs(): run_subprocess set_log_file verbose -""".split('\n') +""".split("\n") def test_documented(): """Test that public functions and classes are documented.""" # skip modules that require mayavi if mayavi is not installed public_modules_ = public_modules[:] - doc_file = op.abspath(op.join(op.dirname(__file__), '..', '..', 'doc', - 'python_reference.rst')) + doc_file = op.abspath( + op.join(op.dirname(__file__), "..", "..", "doc", "python_reference.rst") + ) if not op.isfile(doc_file): - raise SkipTest('Documentation file not found: %s' % doc_file) + raise SkipTest("Documentation file not found: %s" % doc_file) known_names = list() - with open(doc_file, 'rb') as fid: + with open(doc_file, "rb") as fid: for line in fid: - line = line.decode('utf-8') - if not line.startswith(' '): # at least two spaces + line = line.decode("utf-8") + if not line.startswith(" "): # at least two spaces continue line = line.split() - if len(line) == 1 and line[0] != ':': - known_names.append(line[0].split('.')[-1]) + if len(line) == 1 and line[0] != ":": + known_names.append(line[0].split(".")[-1]) known_names = set(known_names) missing = [] for name in public_modules_: with warnings.catch_warnings(record=True): # traits warnings - warnings.simplefilter('ignore') + warnings.simplefilter("ignore") module = __import__(name, globals()) - for submod in name.split('.')[1:]: + for submod in name.split(".")[1:]: module = getattr(module, submod) classes = inspect.getmembers(module, inspect.isclass) functions = inspect.getmembers(module, inspect.isfunction) checks = list(classes) + list(functions) for name, cf in checks: - if not name.startswith('_') and name not in known_names: + if not name.startswith("_") and name not in known_names: from_mod = inspect.getmodule(cf).__name__ - if (from_mod.startswith('expyfun') and - not from_mod.startswith('expyfun._externals') and - not any(from_mod.startswith(x) - for x in documented_ignored_mods) and - name not in documented_ignored_names): - missing.append('%s (%s.%s)' % (name, from_mod, name)) + if ( + from_mod.startswith("expyfun") + and not from_mod.startswith("expyfun._externals") + and not any(from_mod.startswith(x) for x in documented_ignored_mods) + and name not in documented_ignored_names + ): + missing.append("%s (%s.%s)" % (name, from_mod, name)) if len(missing) > 0: - raise AssertionError('\n\nFound new public members missing from ' - 'doc/python_reference.rst:\n\n* ' + - '\n* '.join(sorted(set(missing)))) + raise AssertionError( + "\n\nFound new public members missing from " + "doc/python_reference.rst:\n\n* " + "\n* ".join(sorted(set(missing))) + ) diff --git a/expyfun/tests/test_experiment_controller.py b/expyfun/tests/test_experiment_controller.py index 556500de..a9332133 100644 --- a/expyfun/tests/test_experiment_controller.py +++ b/expyfun/tests/test_experiment_controller.py @@ -1,28 +1,43 @@ +import sys +import warnings from contextlib import contextmanager from copy import deepcopy from functools import partial -import sys -import warnings import numpy as np -from numpy.testing import assert_equal import pytest -from numpy.testing import assert_allclose +from numpy.testing import assert_allclose, assert_equal -from expyfun import ExperimentController, visual, _experiment_controller +from expyfun import ExperimentController, _experiment_controller, visual from expyfun._experiment_controller import _get_dev_db -from expyfun._utils import (_TempDir, fake_button_press, _check_skip_backend, - fake_mouse_click, requires_opengl21, - _wait_secs as wait_secs, known_config_types, - _new_pyglet) from expyfun._sound_controllers._sound_controller import _SOUND_CARD_KEYS +from expyfun._utils import ( + _check_skip_backend, + _new_pyglet, + _TempDir, + fake_button_press, + fake_mouse_click, + known_config_types, + requires_opengl21, +) +from expyfun._utils import ( + _wait_secs as wait_secs, +) from expyfun.stimuli import get_tdt_rates -std_args = ['test'] # experiment name -std_kwargs = dict(output_dir=None, full_screen=False, window_size=(8, 8), - participant='foo', session='01', stim_db=0.0, noise_db=0.0, - verbose=True, version='dev') -SAFE_DELAY = 0.5 if sys.platform.startswith('win') else 0.2 +std_args = ["test"] # experiment name +std_kwargs = dict( + output_dir=None, + full_screen=False, + window_size=(8, 8), + participant="foo", + session="01", + stim_db=0.0, + noise_db=0.0, + verbose=True, + version="dev", +) +SAFE_DELAY = 0.5 if sys.platform.startswith("win") else 0.2 def dummy_print(string): @@ -30,44 +45,43 @@ def dummy_print(string): print(string) -@pytest.mark.parametrize('ws', [(2, 1), (1, 1)]) +@pytest.mark.parametrize("ws", [(2, 1), (1, 1)]) def test_unit_conversions(hide_window, ws): """Test unit conversions.""" kwargs = deepcopy(std_kwargs) - kwargs['stim_fs'] = 44100 - kwargs['window_size'] = ws + kwargs["stim_fs"] = 44100 + kwargs["window_size"] = ws with ExperimentController(*std_args, **kwargs) as ec: verts = np.random.rand(2, 4) - for to in ['norm', 'pix', 'deg', 'cm']: - for fro in ['norm', 'pix', 'deg', 'cm']: + for to in ["norm", "pix", "deg", "cm"]: + for fro in ["norm", "pix", "deg", "cm"]: v2 = ec._convert_units(verts, fro, to) v2 = ec._convert_units(v2, to, fro) assert_allclose(verts, v2) # test that degrees yield equiv. pixels in both directions verts = np.ones((2, 1)) - v0 = ec._convert_units(verts, 'deg', 'pix') + v0 = ec._convert_units(verts, "deg", "pix") verts = np.zeros((2, 1)) - v1 = ec._convert_units(verts, 'deg', 'pix') + v1 = ec._convert_units(verts, "deg", "pix") v2 = v0 - v1 # must check deviation from zero position assert_allclose(v2[0], v2[1]) - pytest.raises(ValueError, ec._convert_units, verts, 'deg', 'nothing') - pytest.raises(RuntimeError, ec._convert_units, verts[0], 'deg', 'pix') + pytest.raises(ValueError, ec._convert_units, verts, "deg", "nothing") + pytest.raises(RuntimeError, ec._convert_units, verts[0], "deg", "pix") def test_validate_audio(hide_window): """Test that validate_audio can pass through samples.""" - with ExperimentController(*std_args, suppress_resamp=True, - **std_kwargs) as ec: + with ExperimentController(*std_args, suppress_resamp=True, **std_kwargs) as ec: ec.set_stim_db(_get_dev_db(ec.audio_type) - 40) # 0.01 RMS - assert ec._stim_scaler == 1. + assert ec._stim_scaler == 1.0 for shape in ((1000,), (1, 1000), (2, 1000)): samples_in = np.zeros(shape) samples_out = ec._validate_audio(samples_in) assert samples_out.shape == (1000, 2) assert samples_out.dtype == np.float32 assert samples_out is not samples_in - for order in 'CF': + for order in "CF": samples_in = np.zeros((2, 1000), dtype=np.float32, order=order) samples_out = ec._validate_audio(samples_in) assert samples_out.shape == samples_in.shape[::-1] @@ -78,17 +92,13 @@ def test_validate_audio(hide_window): def test_data_line(hide_window): """Test writing of data lines.""" - entries = [['foo'], - ['bar', 'bar\tbar'], - ['bar2', r'bar\tbar'], - ['fb', None, -0.5]] + entries = [["foo"], ["bar", "bar\tbar"], ["bar2", r"bar\tbar"], ["fb", None, -0.5]] # this is what should be written to the file for each one - goal_vals = ['None', 'bar\\tbar', 'bar\\\\tbar', 'None'] + goal_vals = ["None", "bar\\tbar", "bar\\\\tbar", "None"] assert_equal(len(entries), len(goal_vals)) temp_dir = _TempDir() with std_kwargs_changed(output_dir=temp_dir): - with ExperimentController(*std_args, stim_fs=44100, - **std_kwargs) as ec: + with ExperimentController(*std_args, stim_fs=44100, **std_kwargs) as ec: for ent in entries: ec.write_data_line(*ent) fname = ec._data_file.name @@ -96,18 +106,17 @@ def test_data_line(hide_window): lines = fid.readlines() # check the header assert_equal(len(lines), len(entries) + 4) # header, colnames, flip, stop - assert_equal(lines[0][0], '#') # first line is a comment - for x in ['timestamp', 'event', 'value']: # second line is col header - assert (x in lines[1]) - assert ('flip' in lines[2]) # ec.__init__ ends with a flip - assert ('stop' in lines[-1]) # last line is stop (from __exit__) - outs = lines[1].strip().split('\t') - assert (all(l1 == l2 for l1, l2 in zip(outs, ['timestamp', - 'event', 'value']))) + assert_equal(lines[0][0], "#") # first line is a comment + for x in ["timestamp", "event", "value"]: # second line is col header + assert x in lines[1] + assert "flip" in lines[2] # ec.__init__ ends with a flip + assert "stop" in lines[-1] # last line is stop (from __exit__) + outs = lines[1].strip().split("\t") + assert all(l1 == l2 for l1, l2 in zip(outs, ["timestamp", "event", "value"])) # check the entries ts = [] for line, ent, gv in zip(lines[3:], entries, goal_vals): - outs = line.strip().split('\t') + outs = line.strip().split("\t") assert_equal(len(outs), 3) # check timestamping if len(ent) == 3 and ent[2] is not None: @@ -120,7 +129,7 @@ def test_data_line(hide_window): assert_equal(outs[2], gv) # make sure we got monotonically increasing timestamps ts = np.array(ts) - assert (np.all(ts[1:] >= ts[:-1])) + assert np.all(ts[1:] >= ts[:-1]) @contextmanager @@ -139,100 +148,161 @@ def std_kwargs_changed(**kwargs): def test_degenerate(): """Test degenerate EC conditions.""" - pytest.raises(TypeError, ExperimentController, *std_args, - audio_controller=1, stim_fs=44100, **std_kwargs) - pytest.raises(ValueError, ExperimentController, *std_args, - audio_controller='foo', stim_fs=44100, **std_kwargs) - pytest.raises(ValueError, ExperimentController, *std_args, - audio_controller=dict(TYPE='foo'), stim_fs=44100, - **std_kwargs) + pytest.raises( + TypeError, + ExperimentController, + *std_args, + audio_controller=1, + stim_fs=44100, + **std_kwargs, + ) + pytest.raises( + ValueError, + ExperimentController, + *std_args, + audio_controller="foo", + stim_fs=44100, + **std_kwargs, + ) + pytest.raises( + ValueError, + ExperimentController, + *std_args, + audio_controller=dict(TYPE="foo"), + stim_fs=44100, + **std_kwargs, + ) # monitor, etc. - pytest.raises(TypeError, ExperimentController, *std_args, - monitor='foo', **std_kwargs) - pytest.raises(KeyError, ExperimentController, *std_args, - monitor=dict(), **std_kwargs) - pytest.raises(ValueError, ExperimentController, *std_args, - response_device='foo', **std_kwargs) - with std_kwargs_changed(window_size=10.): - pytest.raises(ValueError, ExperimentController, *std_args, - **std_kwargs) - pytest.raises(ValueError, ExperimentController, *std_args, - audio_controller='sound_card', response_device='tdt', - **std_kwargs) - pytest.raises(ValueError, ExperimentController, *std_args, - audio_controller='pyglet', response_device='keyboard', - trigger_controller='sound_card', **std_kwargs) + pytest.raises( + TypeError, ExperimentController, *std_args, monitor="foo", **std_kwargs + ) + pytest.raises( + KeyError, ExperimentController, *std_args, monitor=dict(), **std_kwargs + ) + pytest.raises( + ValueError, ExperimentController, *std_args, response_device="foo", **std_kwargs + ) + with std_kwargs_changed(window_size=10.0): + pytest.raises(ValueError, ExperimentController, *std_args, **std_kwargs) + pytest.raises( + ValueError, + ExperimentController, + *std_args, + audio_controller="sound_card", + response_device="tdt", + **std_kwargs, + ) + pytest.raises( + ValueError, + ExperimentController, + *std_args, + audio_controller="pyglet", + response_device="keyboard", + trigger_controller="sound_card", + **std_kwargs, + ) # test type checking for 'session' with std_kwargs_changed(session=1): - pytest.raises(TypeError, ExperimentController, *std_args, - audio_controller='sound_card', stim_fs=44100, - **std_kwargs) + pytest.raises( + TypeError, + ExperimentController, + *std_args, + audio_controller="sound_card", + stim_fs=44100, + **std_kwargs, + ) # test value checking for trigger controller - pytest.raises(ValueError, ExperimentController, *std_args, - audio_controller='sound_card', trigger_controller='foo', - stim_fs=44100, **std_kwargs) + pytest.raises( + ValueError, + ExperimentController, + *std_args, + audio_controller="sound_card", + trigger_controller="foo", + stim_fs=44100, + **std_kwargs, + ) # test value checking for RMS checker - pytest.raises(ValueError, ExperimentController, *std_args, - audio_controller='sound_card', check_rms=True, stim_fs=44100, - **std_kwargs) + pytest.raises( + ValueError, + ExperimentController, + *std_args, + audio_controller="sound_card", + check_rms=True, + stim_fs=44100, + **std_kwargs, + ) @pytest.mark.timeout(120) def test_ec(ac, hide_window, monkeypatch): """Test EC methods.""" - if ac == 'tdt': - rd, tc, fs = 'tdt', 'tdt', get_tdt_rates()['25k'] - pytest.raises(ValueError, ExperimentController, *std_args, - audio_controller=dict(TYPE=ac, TDT_MODEL='foo'), - **std_kwargs) + if ac == "tdt": + rd, tc, fs = "tdt", "tdt", get_tdt_rates()["25k"] + pytest.raises( + ValueError, + ExperimentController, + *std_args, + audio_controller=dict(TYPE=ac, TDT_MODEL="foo"), + **std_kwargs, + ) else: _check_skip_backend(ac) - rd, tc, fs = 'keyboard', 'dummy', 44100 + rd, tc, fs = "keyboard", "dummy", 44100 for suppress in (True, False): with warnings.catch_warnings(record=True) as w: - warnings.simplefilter('always') + warnings.simplefilter("always") with ExperimentController( - *std_args, audio_controller=ac, response_device=rd, - trigger_controller=tc, stim_fs=100., - suppress_resamp=suppress, **std_kwargs) as ec: + *std_args, + audio_controller=ac, + response_device=rd, + trigger_controller=tc, + stim_fs=100.0, + suppress_resamp=suppress, + **std_kwargs, + ) as ec: pass - w = [ww for ww in w if 'TDT is in dummy mode' in str(ww.message)] - assert len(w) == (1 if ac == 'tdt' else 0) + w = [ww for ww in w if "TDT is in dummy mode" in str(ww.message)] + assert len(w) == (1 if ac == "tdt" else 0) with ExperimentController( - *std_args, audio_controller=ac, response_device=rd, - trigger_controller=tc, stim_fs=fs, **std_kwargs) as ec: - assert (ec.participant == std_kwargs['participant']) - assert (ec.session == std_kwargs['session']) - assert (ec.exp_name == std_args[0]) + *std_args, + audio_controller=ac, + response_device=rd, + trigger_controller=tc, + stim_fs=fs, + **std_kwargs, + ) as ec: + assert ec.participant == std_kwargs["participant"] + assert ec.session == std_kwargs["session"] + assert ec.exp_name == std_args[0] stamp = ec.current_time - ec.write_data_line('hello') + ec.write_data_line("hello") ec.wait_until(stamp + 0.02) - ec.screen_prompt('test', 0.01, 0, None) - ec.screen_prompt('test', 0.01, 0, ['1']) - ec.screen_prompt(['test', 'ing'], 0.01, 0, ['1']) - ec.screen_prompt('test', 1e-3, click=True) - pytest.raises(ValueError, ec.screen_prompt, 'foo', np.inf, 0, []) + ec.screen_prompt("test", 0.01, 0, None) + ec.screen_prompt("test", 0.01, 0, ["1"]) + ec.screen_prompt(["test", "ing"], 0.01, 0, ["1"]) + ec.screen_prompt("test", 1e-3, click=True) + pytest.raises(ValueError, ec.screen_prompt, "foo", np.inf, 0, []) pytest.raises(TypeError, ec.screen_prompt, 3, 0.01, 0, None) assert_equal(ec.wait_one_press(0.01), (None, None)) - assert (ec.wait_one_press(0.01, timestamp=False) is None) + assert ec.wait_one_press(0.01, timestamp=False) is None assert_equal(ec.wait_for_presses(0.01), []) assert_equal(ec.wait_for_presses(0.01, timestamp=False), []) pytest.raises(ValueError, ec.get_presses) ec.listen_presses() assert_equal(ec.get_presses(), []) - assert_equal(ec.get_presses(kind='presses'), []) - pytest.raises(ValueError, ec.get_presses, kind='foo') - if rd == 'tdt': + assert_equal(ec.get_presses(kind="presses"), []) + pytest.raises(ValueError, ec.get_presses, kind="foo") + if rd == "tdt": # TDT does not have key release events, so should raise an # exception if asked for them: - pytest.raises(RuntimeError, ec.get_presses, kind='releases') - pytest.raises(RuntimeError, ec.get_presses, kind='both') + pytest.raises(RuntimeError, ec.get_presses, kind="releases") + pytest.raises(RuntimeError, ec.get_presses, kind="both") else: - assert_equal(ec.get_presses(kind='both'), []) - assert_equal(ec.get_presses(kind='releases'), []) + assert_equal(ec.get_presses(kind="both"), []) + assert_equal(ec.get_presses(kind="releases"), []) ec.set_noise_db(0) ec.set_stim_db(20) # test buffer data handling @@ -243,31 +313,31 @@ def test_ec(ac, hide_window, monkeypatch): ec.wait_secs(SAFE_DELAY) pytest.raises(ValueError, ec.load_buffer, [0, 2, 0, 0, 0, 0]) ec.load_buffer(np.zeros((100,))) - with pytest.raises(ValueError, match='100 did not match .* count 2'): + with pytest.raises(ValueError, match="100 did not match .* count 2"): ec.load_buffer(np.zeros((100, 1))) - with pytest.raises(ValueError, match='100 did not match .* count 2'): + with pytest.raises(ValueError, match="100 did not match .* count 2"): ec.load_buffer(np.zeros((100, 2))) ec.wait_secs(SAFE_DELAY) ec.load_buffer(np.zeros((1, 100))) ec.load_buffer(np.zeros((2, 100))) data = np.zeros(int(5e6), np.float32) # too long for TDT - if fs == get_tdt_rates()['25k']: + if fs == get_tdt_rates()["25k"]: pytest.raises(RuntimeError, ec.load_buffer, data) else: ec.load_buffer(data) ec.load_buffer(np.zeros(2)) del data - pytest.raises(ValueError, ec.stamp_triggers, 'foo') + pytest.raises(ValueError, ec.stamp_triggers, "foo") pytest.raises(ValueError, ec.stamp_triggers, 0) pytest.raises(ValueError, ec.stamp_triggers, 3) - pytest.raises(ValueError, ec.stamp_triggers, 1, check='foo') + pytest.raises(ValueError, ec.stamp_triggers, 1, check="foo") print(ec._tc) # test __repr__ - if tc == 'dummy': + if tc == "dummy": assert_equal(ec._tc._trigger_list, []) - ec.stamp_triggers(3, check='int4') + ec.stamp_triggers(3, check="int4") ec.stamp_triggers(2) ec.stamp_triggers([2, 4, 8]) - if tc == 'dummy': + if tc == "dummy": assert_equal(ec._tc._trigger_list, [3, 2, 2, 4, 8]) ec._tc._trigger_list = list() pytest.raises(ValueError, ec.load_buffer, np.zeros((100, 3))) @@ -275,37 +345,37 @@ def test_ec(ac, hide_window, monkeypatch): pytest.raises(ValueError, ec.load_buffer, np.zeros((1, 1, 1))) # test RMS checking - pytest.raises(ValueError, ec.set_rms_checking, 'foo') + pytest.raises(ValueError, ec.set_rms_checking, "foo") # click: RMS 0.0135, should pass 'fullfile' and fail 'windowed' click = np.zeros((int(ec.fs / 4),)) # 250 ms - click[len(click) // 2] = 1. - click[len(click) // 2 + 1] = -1. + click[len(click) // 2] = 1.0 + click[len(click) // 2 + 1] = -1.0 # noise: RMS 0.03, should fail both 'fullfile' and 'windowed' noise = np.random.normal(scale=0.03, size=(int(ec.fs / 4),)) ec.set_rms_checking(None) ec.load_buffer(click) # should go unchecked ec.load_buffer(noise) # should go unchecked - ec.set_rms_checking('wholefile') + ec.set_rms_checking("wholefile") ec.load_buffer(click) # should pass - with pytest.warns(UserWarning, match='exceeds stated'): + with pytest.warns(UserWarning, match="exceeds stated"): ec.load_buffer(noise) ec.wait_secs(SAFE_DELAY) - ec.set_rms_checking('windowed') - with pytest.warns(UserWarning, match='exceeds stated'): + ec.set_rms_checking("windowed") + with pytest.warns(UserWarning, match="exceeds stated"): ec.load_buffer(click) ec.wait_secs(SAFE_DELAY) - with pytest.warns(UserWarning, match='exceeds stated'): + with pytest.warns(UserWarning, match="exceeds stated"): ec.load_buffer(noise) - if ac != 'tdt': # too many samples there - monkeypatch.setattr(_experiment_controller, '_SLOW_LIMIT', 1) - with pytest.warns(UserWarning, match='samples is slow'): + if ac != "tdt": # too many samples there + monkeypatch.setattr(_experiment_controller, "_SLOW_LIMIT", 1) + with pytest.warns(UserWarning, match="samples is slow"): ec.load_buffer(np.zeros(2, dtype=np.float32)) - monkeypatch.setattr(_experiment_controller, '_SLOW_LIMIT', 1e7) + monkeypatch.setattr(_experiment_controller, "_SLOW_LIMIT", 1e7) ec.stop() ec.set_visible() ec.set_visible(False) - ec.call_on_every_flip(partial(dummy_print, 'called start stimuli')) + ec.call_on_every_flip(partial(dummy_print, "called start stimuli")) ec.wait_secs(SAFE_DELAY) ec._ac_flush() @@ -320,43 +390,43 @@ def test_ec(ac, hide_window, monkeypatch): noise = np.random.normal(scale=0.01, size=(int(ec.fs),)) ec.load_buffer(noise) pytest.raises(RuntimeError, ec.start_stimulus) # order violation - assert (ec._playing is False) - if tc == 'dummy': + assert ec._playing is False + if tc == "dummy": assert_equal(ec._tc._trigger_list, []) - ec.start_stimulus(start_of_trial=False) # should work - if tc == 'dummy': + ec.start_stimulus(start_of_trial=False) # should work + if tc == "dummy": assert_equal(ec._tc._trigger_list, [1]) ec.wait_secs(SAFE_DELAY) - assert (ec._playing is True) - pytest.raises(RuntimeError, ec.trial_ok) # order violation + assert ec._playing is True + pytest.raises(RuntimeError, ec.trial_ok) # order violation ec.stop() - assert (ec._playing is False) + assert ec._playing is False # only binary for TTL - pytest.raises(KeyError, ec.identify_trial, ec_id='foo') # need ttl_id - pytest.raises(TypeError, ec.identify_trial, ec_id='foo', ttl_id='bar') - pytest.raises(ValueError, ec.identify_trial, ec_id='foo', ttl_id=[2]) - assert (ec._playing is False) - if tc == 'dummy': + pytest.raises(KeyError, ec.identify_trial, ec_id="foo") # need ttl_id + pytest.raises(TypeError, ec.identify_trial, ec_id="foo", ttl_id="bar") + pytest.raises(ValueError, ec.identify_trial, ec_id="foo", ttl_id=[2]) + assert ec._playing is False + if tc == "dummy": ec._tc._trigger_list = list() - ec.identify_trial(ec_id='foo', ttl_id=[0, 1]) - assert (ec._playing is False) + ec.identify_trial(ec_id="foo", ttl_id=[0, 1]) + assert ec._playing is False # # Second: start_stimuli # - pytest.raises(RuntimeError, ec.identify_trial, ec_id='foo', ttl_id=[0]) - assert (ec._playing is False) - pytest.raises(RuntimeError, ec.trial_ok) # order violation - assert (ec._playing is False) + pytest.raises(RuntimeError, ec.identify_trial, ec_id="foo", ttl_id=[0]) + assert ec._playing is False + pytest.raises(RuntimeError, ec.trial_ok) # order violation + assert ec._playing is False ec.start_stimulus(flip=False, when=-1) - if tc == 'dummy': + if tc == "dummy": assert_equal(ec._tc._trigger_list, [4, 8, 1]) - if ac != 'tdt': + if ac != "tdt": # dummy TDT version won't do this check properly, as # ec._ac._playing -> GetTagVal('playing') always gives False pytest.raises(RuntimeError, ec.play) # already played, must stop ec.wait_secs(SAFE_DELAY) ec.stop() - assert (ec._playing is False) + assert ec._playing is False # # Third: trial_ok # @@ -365,28 +435,28 @@ def test_ec(ac, hide_window, monkeypatch): ec.trial_ok() # double-check pytest.raises(RuntimeError, ec.start_stimulus) # order violation - ec.start_stimulus(start_of_trial=False) # should work - pytest.raises(RuntimeError, ec.trial_ok) # order violation + ec.start_stimulus(start_of_trial=False) # should work + pytest.raises(RuntimeError, ec.trial_ok) # order violation ec.wait_secs(SAFE_DELAY) ec.stop() - assert (ec._playing is False) + assert ec._playing is False ec.flip(-np.inf) - assert (ec._playing is False) + assert ec._playing is False ec.estimate_screen_fs() - assert (ec._playing is False) + assert ec._playing is False ec.play() ec.wait_secs(SAFE_DELAY) - assert (ec._playing is True) + assert ec._playing is True ec.call_on_every_flip(None) # something funny with the ring buffer in testing on OSX - if sys.platform != 'darwin': + if sys.platform != "darwin": ec.call_on_next_flip(ec.start_noise()) ec.flip() ec.wait_secs(SAFE_DELAY) ec.stop_noise() ec.stop() - assert (ec._playing is False) + assert ec._playing is False ec.stop_noise() ec.wait_secs(SAFE_DELAY) ec.start_stimulus(start_of_trial=False) @@ -408,150 +478,169 @@ def test_ec(ac, hide_window, monkeypatch): # we need to monkey-patch for old Pyglet try: from PIL import Image + Image.fromstring except AttributeError: Image.fromstring = None data = ec.screenshot() # HiDPI - sizes = [tuple(std_kwargs['window_size']), - tuple(np.array(std_kwargs['window_size']) * 2)] + sizes = [ + tuple(std_kwargs["window_size"]), + tuple(np.array(std_kwargs["window_size"]) * 2), + ] assert data.shape[:2] in sizes print(ec.fs) # test fs support wait_secs(0.01) test_pix = (11.3, 0.5, 110003) print(test_pix) # test __repr__ - assert all([x in repr(ec) for x in ['foo', '"test"', '01']]) + assert all([x in repr(ec) for x in ["foo", '"test"', "01"]]) ec.refocus() # smoke test for refocusing del ec -@pytest.mark.parametrize('screen_num', (None, 0)) -@pytest.mark.parametrize('monitor', ( - None, - dict(SCREEN_WIDTH=10, SCREEN_DISTANCE=10, SCREEN_SIZE_PIX=(1000, 1000)), -)) +@pytest.mark.parametrize("screen_num", (None, 0)) +@pytest.mark.parametrize( + "monitor", + ( + None, + dict(SCREEN_WIDTH=10, SCREEN_DISTANCE=10, SCREEN_SIZE_PIX=(1000, 1000)), + ), +) def test_screen_monitor(screen_num, monitor, hide_window): """Test screen and monitor option support.""" with ExperimentController( - *std_args, screen_num=screen_num, monitor=monitor, - **std_kwargs): + *std_args, screen_num=screen_num, monitor=monitor, **std_kwargs + ): pass full_kwargs = deepcopy(std_kwargs) - full_kwargs['full_screen'] = True - with pytest.raises(RuntimeError, match='resolution set incorrectly'): + full_kwargs["full_screen"] = True + with pytest.raises(RuntimeError, match="resolution set incorrectly"): ExperimentController(*std_args, **full_kwargs) - with pytest.raises(TypeError, match='must be a dict'): + with pytest.raises(TypeError, match="must be a dict"): ExperimentController(*std_args, monitor=1, **std_kwargs) - with pytest.raises(KeyError, match='is missing required keys'): + with pytest.raises(KeyError, match="is missing required keys"): ExperimentController(*std_args, monitor={}, **std_kwargs) def test_tdtpy_failure(hide_window): """Test that failed TDTpy import raises ImportError.""" try: - from tdt.util import connect_rpcox # noqa, analysis:ignore + from tdt.util import connect_rpcox # noqa: F401 except ImportError: pass else: - pytest.skip('Cannot test TDT import failure') - ac = dict(TYPE='tdt', TDT_MODEL='RP2') - with pytest.raises(ImportError, match='No module named'): + pytest.skip("Cannot test TDT import failure") + ac = dict(TYPE="tdt", TDT_MODEL="RP2") + with pytest.raises(ImportError, match="No module named"): ExperimentController( - *std_args, audio_controller=ac, response_device='keyboard', - trigger_controller='tdt', stim_fs=100., - suppress_resamp=True, **std_kwargs) + *std_args, + audio_controller=ac, + response_device="keyboard", + trigger_controller="tdt", + stim_fs=100.0, + suppress_resamp=True, + **std_kwargs, + ) @pytest.mark.timeout(10) def test_button_presses_and_window_size(hide_window): """Test EC window_size=None and button press capture.""" - with ExperimentController(*std_args, audio_controller='sound_card', - response_device='keyboard', window_size=None, - output_dir=None, full_screen=False, session='01', - participant='foo', trigger_controller='dummy', - force_quit='escape', version='dev') as ec: + with ExperimentController( + *std_args, + audio_controller="sound_card", + response_device="keyboard", + window_size=None, + output_dir=None, + full_screen=False, + session="01", + participant="foo", + trigger_controller="dummy", + force_quit="escape", + version="dev", + ) as ec: ec.listen_presses() ec.get_presses() assert_equal(ec.get_presses(), []) - fake_button_press(ec, '1', 0.5) - assert_equal(ec.screen_prompt('press 1', live_keys=['1'], - max_wait=1.5), '1') + fake_button_press(ec, "1", 0.5) + assert_equal(ec.screen_prompt("press 1", live_keys=["1"], max_wait=1.5), "1") ec.listen_presses() assert_equal(ec.get_presses(), []) - fake_button_press(ec, '1') - assert_equal(ec.get_presses(timestamp=False), [('1',)]) + fake_button_press(ec, "1") + assert_equal(ec.get_presses(timestamp=False), [("1",)]) ec.listen_presses() - fake_button_press(ec, '1') + fake_button_press(ec, "1") presses = ec.get_presses(timestamp=True, relative_to=0.2) assert_equal(len(presses), 1) assert_equal(len(presses[0]), 2) - assert_equal(presses[0][0], '1') - assert (isinstance(presses[0][1], float)) + assert_equal(presses[0][0], "1") + assert isinstance(presses[0][1], float) ec.listen_presses() - fake_button_press(ec, '1') - presses = ec.get_presses(timestamp=True, relative_to=0.1, - return_kinds=True) + fake_button_press(ec, "1") + presses = ec.get_presses(timestamp=True, relative_to=0.1, return_kinds=True) assert_equal(len(presses), 1) assert_equal(len(presses[0]), 3) - assert_equal(presses[0][::2], ('1', 'press')) - assert (isinstance(presses[0][1], float)) + assert_equal(presses[0][::2], ("1", "press")) + assert isinstance(presses[0][1], float) ec.listen_presses() - fake_button_press(ec, '1') + fake_button_press(ec, "1") presses = ec.get_presses(timestamp=False, return_kinds=True) - assert_equal(presses, [('1', 'press')]) + assert_equal(presses, [("1", "press")]) ec.listen_presses() - ec.screen_text('press 1 again') + ec.screen_text("press 1 again") ec.flip() - fake_button_press(ec, '1', 0.3) - assert_equal(ec.wait_one_press(1.5, live_keys=[1])[0], '1') - ec.screen_text('press 1 one last time') + fake_button_press(ec, "1", 0.3) + assert_equal(ec.wait_one_press(1.5, live_keys=[1])[0], "1") + ec.screen_text("press 1 one last time") ec.flip() - fake_button_press(ec, '1', 0.3) - out = ec.wait_for_presses(1.5, live_keys=['1'], timestamp=False) - assert_equal(out[0], '1') - fake_button_press(ec, 'a', 0.3) - fake_button_press(ec, 'return', 0.5) - assert ec.text_input() == 'A' - fake_button_press(ec, 'a', 0.3) - fake_button_press(ec, 'space', 0.35) - fake_button_press(ec, 'backspace', 0.4) - fake_button_press(ec, 'comma', 0.45) - fake_button_press(ec, 'return', 0.5) + fake_button_press(ec, "1", 0.3) + out = ec.wait_for_presses(1.5, live_keys=["1"], timestamp=False) + assert_equal(out[0], "1") + fake_button_press(ec, "a", 0.3) + fake_button_press(ec, "return", 0.5) + assert ec.text_input() == "A" + fake_button_press(ec, "a", 0.3) + fake_button_press(ec, "space", 0.35) + fake_button_press(ec, "backspace", 0.4) + fake_button_press(ec, "comma", 0.45) + fake_button_press(ec, "return", 0.5) # XXX this fails on OSX travis for some reason new_pyglet = _new_pyglet() - bad = sys.platform == 'darwin' - bad |= sys.platform == 'win32' and new_pyglet + bad = sys.platform == "darwin" + bad |= sys.platform == "win32" and new_pyglet if not bad: - assert ec.text_input(all_caps=False).strip() == 'a' + assert ec.text_input(all_caps=False).strip() == "a" @pytest.mark.timeout(10) @requires_opengl21 def test_mouse_clicks(hide_window): """Test EC mouse click support.""" - with ExperimentController(*std_args, participant='foo', session='01', - output_dir=None, version='dev') as ec: + with ExperimentController( + *std_args, participant="foo", session="01", output_dir=None, version="dev" + ) as ec: rect = visual.Rectangle(ec, [0, 0, 2, 2]) fake_mouse_click(ec, [1, 2], delay=0.3) - assert_equal(ec.wait_for_click_on(rect, 1.5, timestamp=False)[0], - ('left', 1, 2)) + assert_equal( + ec.wait_for_click_on(rect, 1.5, timestamp=False)[0], ("left", 1, 2) + ) pytest.raises(TypeError, ec.wait_for_click_on, (rect, rect), 1.5) - fake_mouse_click(ec, [2, 1], 'middle', delay=0.3) - out = ec.wait_one_click(1.5, 0., ['middle'], timestamp=True) - assert (out[3] < 1.5) - assert_equal(out[:3], ('middle', 2, 1)) - fake_mouse_click(ec, [3, 2], 'left', delay=0.3) - fake_mouse_click(ec, [4, 5], 'right', delay=0.3) + fake_mouse_click(ec, [2, 1], "middle", delay=0.3) + out = ec.wait_one_click(1.5, 0.0, ["middle"], timestamp=True) + assert out[3] < 1.5 + assert_equal(out[:3], ("middle", 2, 1)) + fake_mouse_click(ec, [3, 2], "left", delay=0.3) + fake_mouse_click(ec, [4, 5], "right", delay=0.3) out = ec.wait_for_clicks(1.5, timestamp=False) assert_equal(len(out), 2) - assert (any(o == ('left', 3, 2) for o in out)) - assert (any(o == ('right', 4, 5) for o in out)) + assert any(o == ("left", 3, 2) for o in out) + assert any(o == ("right", 4, 5) for o in out) out = ec.wait_for_clicks(0.1) assert_equal(len(out), 0) @@ -560,114 +649,133 @@ def test_mouse_clicks(hide_window): @pytest.mark.timeout(30) def test_background_color(hide_window): """Test setting background color""" - with ExperimentController(*std_args, participant='foo', session='01', - output_dir=None, version='dev') as ec: + with ExperimentController( + *std_args, participant="foo", session="01", output_dir=None, version="dev" + ) as ec: print((ec.window.width, ec.window.height)) - ec.set_background_color('red') + ec.set_background_color("red") ss = ec.screenshot()[:, :, :3] red_mask = (ss == [255, 0, 0]).all(axis=-1) - assert (red_mask.all()) - ec.set_background_color('white') + assert red_mask.all() + ec.set_background_color("white") ss = ec.screenshot()[:, :, :3] white_mask = (ss == [255] * 3).all(axis=-1) - assert (white_mask.all()) + assert white_mask.all() ec.flip() - ec.set_background_color('0.5') - visual.Rectangle(ec, [0, 0, 1, 1], fill_color='black').draw() + ec.set_background_color("0.5") + visual.Rectangle(ec, [0, 0, 1, 1], fill_color="black").draw() ss = ec.screenshot()[:, :, :3] - gray_mask = ((ss == [127] * 3).all(axis=-1) | - (ss == [128] * 3).all(axis=-1)) - assert (gray_mask.any()) + gray_mask = (ss == [127] * 3).all(axis=-1) | (ss == [128] * 3).all(axis=-1) + assert gray_mask.any() black_mask = (ss == [0] * 3).all(axis=-1) - assert (black_mask.any()) - assert (np.logical_or(gray_mask, black_mask).all()) + assert black_mask.any() + assert np.logical_or(gray_mask, black_mask).all() def test_tdt_delay(hide_window): """Test the tdt_delay parameter.""" - with ExperimentController(*std_args, - audio_controller=dict(TYPE='tdt', TDT_DELAY=0), - **std_kwargs) as ec: - assert_equal(ec._ac._used_params['TDT_DELAY'], 0) - with ExperimentController(*std_args, - audio_controller=dict(TYPE='tdt', TDT_DELAY=1), - **std_kwargs) as ec: - assert_equal(ec._ac._used_params['TDT_DELAY'], 1) - pytest.raises(ValueError, ExperimentController, *std_args, - audio_controller=dict(TYPE='tdt', TDT_DELAY='foo'), - **std_kwargs) - pytest.raises(OverflowError, ExperimentController, *std_args, - audio_controller=dict(TYPE='tdt', TDT_DELAY=np.inf), - **std_kwargs) - pytest.raises(TypeError, ExperimentController, *std_args, - audio_controller=dict(TYPE='tdt', TDT_DELAY=np.ones(2)), - **std_kwargs) - pytest.raises(ValueError, ExperimentController, *std_args, - audio_controller=dict(TYPE='tdt', TDT_DELAY=-1), - **std_kwargs) + with ExperimentController( + *std_args, audio_controller=dict(TYPE="tdt", TDT_DELAY=0), **std_kwargs + ) as ec: + assert_equal(ec._ac._used_params["TDT_DELAY"], 0) + with ExperimentController( + *std_args, audio_controller=dict(TYPE="tdt", TDT_DELAY=1), **std_kwargs + ) as ec: + assert_equal(ec._ac._used_params["TDT_DELAY"], 1) + pytest.raises( + ValueError, + ExperimentController, + *std_args, + audio_controller=dict(TYPE="tdt", TDT_DELAY="foo"), + **std_kwargs, + ) + pytest.raises( + OverflowError, + ExperimentController, + *std_args, + audio_controller=dict(TYPE="tdt", TDT_DELAY=np.inf), + **std_kwargs, + ) + pytest.raises( + TypeError, + ExperimentController, + *std_args, + audio_controller=dict(TYPE="tdt", TDT_DELAY=np.ones(2)), + **std_kwargs, + ) + pytest.raises( + ValueError, + ExperimentController, + *std_args, + audio_controller=dict(TYPE="tdt", TDT_DELAY=-1), + **std_kwargs, + ) def test_sound_card_triggering(hide_window): """Test using the sound card as a trigger controller.""" - audio_controller = dict(TYPE='sound_card', SOUND_CARD_TRIGGER_CHANNELS='0') + audio_controller = dict(TYPE="sound_card", SOUND_CARD_TRIGGER_CHANNELS="0") kwargs = std_kwargs.copy() kwargs.update( stim_fs=44100, suppress_resamp=True, audio_controller=audio_controller, - trigger_controller='sound_card', + trigger_controller="sound_card", ) - with pytest.raises(ValueError, match='SOUND_CARD_TRIGGER_CHANNELS is zer'): + with pytest.raises(ValueError, match="SOUND_CARD_TRIGGER_CHANNELS is zer"): ExperimentController(*std_args, **kwargs) - audio_controller.update(SOUND_CARD_TRIGGER_CHANNELS='1') + audio_controller.update(SOUND_CARD_TRIGGER_CHANNELS="1") # Use 1 trigger ch and 1 output ch because this should work on all systems with ExperimentController(*std_args, n_channels=1, **kwargs) as ec: - ec.identify_trial(ttl_id=[1, 0], ec_id='') + ec.identify_trial(ttl_id=[1, 0], ec_id="") ec.load_buffer([1e-2]) ec.start_stimulus() ec.stop() # Test the drift triggers audio_controller.update(SOUND_CARD_DRIFT_TRIGGER=0.001) with ExperimentController(*std_args, n_channels=1, **kwargs) as ec: - ec.identify_trial(ttl_id=[1, 0], ec_id='') - with pytest.warns(UserWarning, match='Drift triggers overlap with ' - 'onset triggers.'): + ec.identify_trial(ttl_id=[1, 0], ec_id="") + with pytest.warns( + UserWarning, match="Drift triggers overlap with " "onset triggers." + ): ec.load_buffer(np.zeros(ec.stim_fs)) ec.start_stimulus() ec.stop() - audio_controller.update(SOUND_CARD_DRIFT_TRIGGER=[1.1, 0.3, -0.3, - 'end']) + audio_controller.update(SOUND_CARD_DRIFT_TRIGGER=[1.1, 0.3, -0.3, "end"]) with ExperimentController(*std_args, n_channels=1, **kwargs) as ec: - ec.identify_trial(ttl_id=[1, 0], ec_id='') - with pytest.warns(UserWarning, match='Drift trigger at 1.1 seconds ' - 'occurs outside stimulus window, not stamping ' - 'trigger.'): + ec.identify_trial(ttl_id=[1, 0], ec_id="") + with pytest.warns( + UserWarning, + match="Drift trigger at 1.1 seconds " + "occurs outside stimulus window, not stamping " + "trigger.", + ): ec.load_buffer(np.zeros(ec.stim_fs)) ec.start_stimulus() ec.stop() audio_controller.update(SOUND_CARD_DRIFT_TRIGGER=[0.5, 0.505]) with ExperimentController(*std_args, n_channels=1, **kwargs) as ec: - ec.identify_trial(ttl_id=[1, 0], ec_id='') - with pytest.warns(UserWarning, match='Some 2-triggers overlap.*'): + ec.identify_trial(ttl_id=[1, 0], ec_id="") + with pytest.warns(UserWarning, match="Some 2-triggers overlap.*"): ec.load_buffer(np.zeros(ec.stim_fs)) ec.start_stimulus() ec.stop() audio_controller.update(SOUND_CARD_DRIFT_TRIGGER=[]) with ExperimentController(*std_args, n_channels=1, **kwargs) as ec: - ec.identify_trial(ttl_id=[1, 0], ec_id='') + ec.identify_trial(ttl_id=[1, 0], ec_id="") ec.load_buffer(np.zeros(ec.stim_fs)) ec.start_stimulus() ec.stop() audio_controller.update(SOUND_CARD_DRIFT_TRIGGER=[0.2, 0.5, -0.3]) with ExperimentController(*std_args, n_channels=1, **kwargs) as ec: - ec.identify_trial(ttl_id=[1, 0], ec_id='') + ec.identify_trial(ttl_id=[1, 0], ec_id="") ec.load_buffer(np.zeros(ec.stim_fs * 2)) ec.start_stimulus() ec.stop() -class _FakeJoystick(object): - device = 'FakeJoystick' +class _FakeJoystick: + device = "FakeJoystick" on_joybutton_press = lambda self, joystick, button: None # noqa open = lambda self, window, exclusive: None # noqa x = 0.125 @@ -676,19 +784,20 @@ class _FakeJoystick(object): def test_joystick(hide_window, monkeypatch): """Test joystick support.""" import pyglet + fake = _FakeJoystick() - monkeypatch.setattr(pyglet.input, 'get_joysticks', lambda: [fake]) + monkeypatch.setattr(pyglet.input, "get_joysticks", lambda: [fake]) with ExperimentController(*std_args, joystick=True, **std_kwargs) as ec: ec.listen_joystick_button_presses() fake.on_joybutton_press(fake, 1) presses = ec.get_joystick_button_presses() assert len(presses) == 1 - assert presses[0][0] == '1' - assert ec.get_joystick_value('x') == 0.125 + assert presses[0][0] == "1" + assert ec.get_joystick_value("x") == 0.125 def test_sound_card_params(): """Test that sound card params are known keys.""" for key in _SOUND_CARD_KEYS: - if key != 'TYPE': + if key != "TYPE": assert key in known_config_types, key diff --git a/expyfun/tests/test_eyelink_controller.py b/expyfun/tests/test_eyelink_controller.py index ed333c9c..ae37a557 100644 --- a/expyfun/tests/test_eyelink_controller.py +++ b/expyfun/tests/test_eyelink_controller.py @@ -1,12 +1,19 @@ import pytest -from expyfun import EyelinkController, ExperimentController +from expyfun import ExperimentController, EyelinkController from expyfun._utils import _TempDir, requires_opengl21 -std_args = ['test'] +std_args = ["test"] temp_dir = _TempDir() -std_kwargs = dict(output_dir=temp_dir, full_screen=False, window_size=(1, 1), - participant='foo', session='01', noise_db=0, version='dev') +std_kwargs = dict( + output_dir=temp_dir, + full_screen=False, + window_size=(1, 1), + participant="foo", + session="01", + noise_db=0, + version="dev", +) @requires_opengl21 @@ -16,52 +23,58 @@ def test_eyelink_methods(hide_window): pytest.raises(ValueError, EyelinkController, ec, fs=999) el = EyelinkController(ec) pytest.raises(RuntimeError, EyelinkController, ec) # can't have 2 open - pytest.raises(ValueError, el.custom_calibration, ctype='hey') - el.custom_calibration('H3') - el.custom_calibration('HV9') - el.custom_calibration('HV13') - pytest.raises(ValueError, el.custom_calibration, ctype='custom', - coordinates='foo') - pytest.raises(ValueError, el.custom_calibration, ctype='custom', - coordinates=[[0, 1], 0]) - pytest.raises(ValueError, el.custom_calibration, ctype='custom', - coordinates=[[0, 1], [0]]) + pytest.raises(ValueError, el.custom_calibration, ctype="hey") + el.custom_calibration("H3") + el.custom_calibration("HV9") + el.custom_calibration("HV13") + pytest.raises( + ValueError, el.custom_calibration, ctype="custom", coordinates="foo" + ) + pytest.raises( + ValueError, el.custom_calibration, ctype="custom", coordinates=[[0, 1], 0] + ) + pytest.raises( + ValueError, el.custom_calibration, ctype="custom", coordinates=[[0, 1], [0]] + ) el._open_file() pytest.raises(RuntimeError, el._open_file) el._start_recording() el.get_eye_position() pytest.raises(ValueError, el.wait_for_fix, [1]) x = el.wait_for_fix([-10000, -10000], max_wait=0.1) - assert (x is False) + assert x is False assert el.eye_used print(el.file_list) - assert (len(el.file_list) > 0) + assert len(el.file_list) > 0 print(el.fs) x = el.maintain_fix([-10000, -10000], 0.1, period=0.01) - assert (x is False) + assert x is False # run much of the calibration code, but don't *actually* do it el._fake_calibration = True el.calibrate(beep=False, prompt=False) el._fake_calibration = False # missing el_id - pytest.raises(KeyError, ec.identify_trial, ec_id='foo', ttl_id=[0]) - ec.identify_trial(ec_id='foo', ttl_id=[0], el_id=[1]) + pytest.raises(KeyError, ec.identify_trial, ec_id="foo", ttl_id=[0]) + ec.identify_trial(ec_id="foo", ttl_id=[0], el_id=[1]) ec.start_stimulus() ec.stop() ec.trial_ok() - ec.identify_trial(ec_id='foo', ttl_id=[0], el_id=[1, 1]) + ec.identify_trial(ec_id="foo", ttl_id=[0], el_id=[1, 1]) ec.start_stimulus() ec.stop() ec.trial_ok() - pytest.raises(ValueError, ec.identify_trial, ec_id='foo', ttl_id=[0], - el_id=[1, dict()]) - pytest.raises(ValueError, ec.identify_trial, ec_id='foo', ttl_id=[0], - el_id=[0] * 13) - pytest.raises(TypeError, ec.identify_trial, ec_id='foo', ttl_id=[0], - el_id=dict()) + pytest.raises( + ValueError, ec.identify_trial, ec_id="foo", ttl_id=[0], el_id=[1, dict()] + ) + pytest.raises( + ValueError, ec.identify_trial, ec_id="foo", ttl_id=[0], el_id=[0] * 13 + ) + pytest.raises( + TypeError, ec.identify_trial, ec_id="foo", ttl_id=[0], el_id=dict() + ) pytest.raises(TypeError, el._message, 1) el.stop() el.transfer_remote_file(el.file_list[0]) - assert (not el._closed) + assert not el._closed # ec.close() auto-calls el.close() - assert (el._closed) + assert el._closed diff --git a/expyfun/tests/test_logging.py b/expyfun/tests/test_logging.py index 2fb4a973..9a698fa7 100644 --- a/expyfun/tests/test_logging.py +++ b/expyfun/tests/test_logging.py @@ -2,45 +2,59 @@ import os import pytest + from expyfun import ExperimentController from expyfun._utils import _check_skip_backend, requires_lib -std_args = ['test'] -std_kwargs = dict(participant='foo', session='01', full_screen=False, - window_size=(1, 1), verbose=True, noise_db=0, version='dev') +std_args = ["test"] +std_kwargs = dict( + participant="foo", + session="01", + full_screen=False, + window_size=(1, 1), + verbose=True, + noise_db=0, + version="dev", +) -@requires_lib('mne') +@requires_lib("mne") def test_logging(ac, tmpdir, hide_window): """Test logging to file (Pyglet).""" - if ac != 'tdt': + if ac != "tdt": _check_skip_backend(ac) orig_dir = os.getcwd() os.chdir(str(tmpdir)) try: - with ExperimentController(*std_args, audio_controller=ac, - response_device='keyboard', - trigger_controller='dummy', - **std_kwargs) as ec: + with ExperimentController( + *std_args, + audio_controller=ac, + response_device="keyboard", + trigger_controller="dummy", + **std_kwargs, + ) as ec: test_name = ec._log_file stamp = ec.current_time ec.wait_until(stamp) # wait_until called w/already passed timest. - with pytest.warns(UserWarning, match='RMS'): - ec.load_buffer([1., -1., 1., -1., 1., -1.]) # RMS warning + with pytest.warns(UserWarning, match="RMS"): + ec.load_buffer([1.0, -1.0, 1.0, -1.0, 1.0, -1.0]) # RMS warning with open(test_name) as fid: - data = '\n'.join(fid.readlines()) + data = "\n".join(fid.readlines()) # check for various expected log messages (TODO: add more) - should_have = ['Participant: foo', 'Session: 01', - 'wait_until was called', - 'Stimulus max RMS ('] - if ac == 'tdt': - should_have.append('TDT') + should_have = [ + "Participant: foo", + "Session: 01", + "wait_until was called", + "Stimulus max RMS (", + ] + if ac == "tdt": + should_have.append("TDT") else: - should_have.append('sound card') - if ac != 'auto' and ac['SOUND_CARD_BACKEND'] != 'auto': - should_have.append(ac['SOUND_CARD_BACKEND']) + should_have.append("sound card") + if ac != "auto" and ac["SOUND_CARD_BACKEND"] != "auto": + should_have.append(ac["SOUND_CARD_BACKEND"]) assert_have_all(data, should_have) finally: os.chdir(orig_dir) @@ -48,8 +62,7 @@ def test_logging(ac, tmpdir, hide_window): def assert_have_all(data, should_have): """Assert all substrings are in the logging output.""" - __tracebackhide__ = operator.methodcaller('errisinstance', AssertionError) + __tracebackhide__ = operator.methodcaller("errisinstance", AssertionError) for s in should_have: if s not in data: - raise AssertionError('Missing data: "{0}" in:\n{1}' - ''.format(s, data)) + raise AssertionError(f'Missing data: "{s}" in:\n{data}' "") diff --git a/expyfun/tests/test_parallel.py b/expyfun/tests/test_parallel.py index fbae0f83..9f4d6c6b 100644 --- a/expyfun/tests/test_parallel.py +++ b/expyfun/tests/test_parallel.py @@ -1,10 +1,8 @@ -# -*- coding: utf-8 -*- - import numpy as np import pytest from numpy.testing import assert_array_equal -from expyfun._parallel import parallel_func, _check_n_jobs +from expyfun._parallel import _check_n_jobs, parallel_func from expyfun._utils import requires_lib @@ -13,10 +11,10 @@ def _identity(x): @pytest.mark.timeout(15) -@requires_lib('joblib') +@requires_lib("joblib") def test_parallel(): """Test parallel support.""" - pytest.raises(TypeError, _check_n_jobs, 'foo') + pytest.raises(TypeError, _check_n_jobs, "foo") parallel, p_fun, _ = parallel_func(_identity, 1) a = np.array(parallel(p_fun(x) for x in range(10))) parallel, p_fun, _ = parallel_func(_identity, 2) diff --git a/expyfun/tests/test_trigger_conversion.py b/expyfun/tests/test_trigger_conversion.py index 99de3e1a..c4df2111 100644 --- a/expyfun/tests/test_trigger_conversion.py +++ b/expyfun/tests/test_trigger_conversion.py @@ -1,12 +1,11 @@ -from numpy.testing import assert_array_equal import pytest +from numpy.testing import assert_array_equal -from expyfun import decimals_to_binary, binary_to_decimals +from expyfun import binary_to_decimals, decimals_to_binary def test_conversion(): - """Test decimal<->binary conversion - """ + """Test decimal<->binary conversion""" pytest.raises(ValueError, decimals_to_binary, [1], [0]) pytest.raises(ValueError, decimals_to_binary, [-1], [1]) pytest.raises(ValueError, decimals_to_binary, [1, 1], [1]) @@ -18,21 +17,24 @@ def test_conversion(): pytest.raises(ValueError, binary_to_decimals, [1], [-1]) pytest.raises(ValueError, binary_to_decimals, [1], [2]) # test cases - decs = [[1], - [1, 0, 1, 4, 5], - [0, 3], - [3, 0], - ] - bits = [[1], - [1, 1, 2, 4, 4], - [2, 2], - [2, 2], - ] - bins = [[1], - [1, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1], - [0, 0, 1, 1], - [1, 1, 0, 0], - ] + decs = [ + [1], + [1, 0, 1, 4, 5], + [0, 3], + [3, 0], + ] + bits = [ + [1], + [1, 1, 2, 4, 4], + [2, 2], + [2, 2], + ] + bins = [ + [1], + [1, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1], + [0, 0, 1, 1], + [1, 1, 0, 0], + ] for d, n, b in zip(decs, bits, bins): assert_array_equal(decimals_to_binary(d, n), b) assert_array_equal(binary_to_decimals(b, n), d) diff --git a/expyfun/tests/test_utils.py b/expyfun/tests/test_utils.py index 20abf148..fc18e96f 100644 --- a/expyfun/tests/test_utils.py +++ b/expyfun/tests/test_utils.py @@ -1,28 +1,28 @@ -import pytest import os import warnings import numpy as np +import pytest -from expyfun._utils import get_config, set_config, deprecated, _fix_audio_dims +from expyfun._utils import _fix_audio_dims, deprecated, get_config, set_config -warnings.simplefilter('always') +warnings.simplefilter("always") def test_config(): """Test expyfun config file support.""" - key = '_EXPYFUN_CONFIG_TESTING' - value = '123456' + key = "_EXPYFUN_CONFIG_TESTING" + value = "123456" old_val = os.getenv(key, None) os.environ[key] = value - assert (get_config(key) == value) + assert get_config(key) == value del os.environ[key] # catch the warning about it being a non-standard config key with warnings.catch_warnings(record=True) as w: - warnings.simplefilter('always') + warnings.simplefilter("always") # warnings raised only when setting key set_config(key, None) - assert (get_config(key) is None) + assert get_config(key) is None pytest.raises(KeyError, get_config, key, raise_error=True) set_config(key, value) assert get_config(key) == value @@ -32,17 +32,17 @@ def test_config(): os.environ[key] = old_val pytest.raises(ValueError, get_config, 1) get_config(None) - set_config(None, '0') + set_config(None, "0") -@deprecated('message') +@deprecated("message") def deprecated_func(): """Deprecated function.""" pass -@deprecated('message') -class deprecated_class(object): +@deprecated("message") +class deprecated_class: """Deprecated class.""" def __init__(self): @@ -52,13 +52,13 @@ def __init__(self): def test_deprecated(): """Test deprecated function.""" with warnings.catch_warnings(record=True) as w: - warnings.simplefilter('always') + warnings.simplefilter("always") deprecated_func() - assert (len(w) == 1) + assert len(w) == 1 with warnings.catch_warnings(record=True) as w: - warnings.simplefilter('always') + warnings.simplefilter("always") deprecated_class() - assert (len(w) == 1) + assert len(w) == 1 def test_audio_dims(): @@ -73,13 +73,12 @@ def test_audio_dims(): y = _fix_audio_dims(y, 2) assert y.shape == (2, n_samples) # no tiling for >2 channel output - with pytest.raises(ValueError, match='channel count 1 did not .* 3'): + with pytest.raises(ValueError, match="channel count 1 did not .* 3"): _fix_audio_dims(x, 3) for dim in (1, 3): - want = ('signal channel count 2 did not match required channel ' - 'count %s' % dim) + want = "signal channel count 2 did not match required channel " "count %s" % dim with pytest.raises(ValueError, match=want): _fix_audio_dims(y, dim) for n_channels in (1, 2, 3): - with pytest.raises(ValueError, match='must have one or two dimension'): + with pytest.raises(ValueError, match="must have one or two dimension"): _fix_audio_dims(np.zeros((2, 2, 2)), n_channels) diff --git a/expyfun/tests/test_version.py b/expyfun/tests/test_version.py index 98346128..cf7d5af5 100644 --- a/expyfun/tests/test_version.py +++ b/expyfun/tests/test_version.py @@ -1,58 +1,56 @@ -# -*- coding: utf-8 -*- import os -from os import path as op import warnings +from os import path as op import pytest -from expyfun import (ExperimentController, assert_version, download_version, - __version__) -from expyfun._utils import _TempDir +from expyfun import ExperimentController, __version__, assert_version, download_version from expyfun._git import _has_git +from expyfun._utils import _TempDir +@pytest.mark.filterwarnings("ignore:Package 'expyfun.data' is absent.*") @pytest.mark.timeout(60) # can be slow to download -def test_version_assertions(): +# old, broken, new +@pytest.mark.parametrize("want_version", ["090948e", "cae6bc3", "b6e8a81"]) +def test_version_assertions(want_version): """Test version assertions.""" pytest.raises(TypeError, assert_version, 1) - pytest.raises(TypeError, assert_version, '1' * 8) - pytest.raises(AssertionError, assert_version, 'x' * 7) + pytest.raises(TypeError, assert_version, "1" * 8) + pytest.raises(AssertionError, assert_version, "x" * 7) assert_version(__version__[-7:]) - # old, broken, new - for wi, want_version in enumerate(('090948e', 'cae6bc3', 'b6e8a81')): - print('Running %s' % want_version) - tempdir = _TempDir() - if not _has_git: - pytest.raises(ImportError, download_version, want_version, tempdir) - else: - pytest.raises(IOError, download_version, want_version, - op.join(tempdir, 'foo')) - pytest.raises(RuntimeError, download_version, 'x' * 7, tempdir) - ex_dir = op.join(tempdir, 'expyfun') - assert not op.isdir(ex_dir) - with warnings.catch_warnings(record=True): # Sometimes warns - warnings.simplefilter('ignore') - download_version(want_version, tempdir) - assert op.isdir(ex_dir) - assert op.isfile(op.join(ex_dir, '__init__.py')) - got_fname = op.join(ex_dir, '_version.py') - with open(got_fname) as fid: - line1 = fid.readline().strip() - got_version = line1.split(' = ')[1][-8:-1] - ex = want_version - if want_version == 'cae6bc3': - ex = (ex, '.dev0+c') - assert got_version in ex, got_fname + print("Running %s" % want_version) + tempdir = _TempDir() + if not _has_git: + pytest.raises(ImportError, download_version, want_version, tempdir) + else: + pytest.raises(IOError, download_version, want_version, op.join(tempdir, "foo")) + pytest.raises(RuntimeError, download_version, "x" * 7, tempdir) + ex_dir = op.join(tempdir, "expyfun") + assert not op.isdir(ex_dir) + with warnings.catch_warnings(record=True): # Sometimes warns + warnings.simplefilter("ignore") + download_version(want_version, tempdir) + assert op.isdir(ex_dir) + assert op.isfile(op.join(ex_dir, "__init__.py")) + got_fname = op.join(ex_dir, "_version.py") + with open(got_fname) as fid: + line1 = fid.readline().strip() + got_version = line1.split(" = ")[1][-8:-1] + ex = want_version + if want_version == "cae6bc3": + ex = (ex, ".dev0+c") + assert got_version in ex, got_fname - # auto dir determination - orig_dir = os.getcwd() - os.chdir(tempdir) - try: - assert op.isdir('expyfun') - pytest.raises(IOError, download_version, want_version) - finally: - os.chdir(orig_dir) + # auto dir determination + orig_dir = os.getcwd() + os.chdir(tempdir) + try: + assert op.isdir("expyfun") + pytest.raises(IOError, download_version, want_version) + finally: + os.chdir(orig_dir) # make sure we can get latest version tempdir_2 = _TempDir() if _has_git: @@ -63,11 +61,18 @@ def test_version_assertions(): def test_integrated_version_checking(): """Test EC version checking during init.""" tempdir = _TempDir() - args = ['test'] # experiment name - kwargs = dict(output_dir=tempdir, full_screen=False, window_size=(1, 1), - participant='foo', session='01', stim_db=0.0, noise_db=0.0, - verbose=True) - pytest.raises(RuntimeError, ExperimentController, *args, version=None, - **kwargs) - pytest.raises(AssertionError, ExperimentController, *args, - version='59f3f5b', **kwargs) # the very first commit + args = ["test"] # experiment name + kwargs = dict( + output_dir=tempdir, + full_screen=False, + window_size=(1, 1), + participant="foo", + session="01", + stim_db=0.0, + noise_db=0.0, + verbose=True, + ) + pytest.raises(RuntimeError, ExperimentController, *args, version=None, **kwargs) + pytest.raises( + AssertionError, ExperimentController, *args, version="59f3f5b", **kwargs + ) # the very first commit diff --git a/expyfun/visual/__init__.py b/expyfun/visual/__init__.py index 9d98389e..9f784a01 100644 --- a/expyfun/visual/__init__.py +++ b/expyfun/visual/__init__.py @@ -1,3 +1,15 @@ -from ._visual import (Text, Line, Triangle, Rectangle, Circle, RawImage, - Diamond, ConcentricCircles, FixationDot, ProgressBar, - _convert_color, _Triangular, Video) +from ._visual import ( + Text, + Line, + Triangle, + Rectangle, + Circle, + RawImage, + Diamond, + ConcentricCircles, + FixationDot, + ProgressBar, + _convert_color, + _Triangular, + Video, +) diff --git a/expyfun/visual/_visual.py b/expyfun/visual/_visual.py index a317421e..496fde5e 100644 --- a/expyfun/visual/_visual.py +++ b/expyfun/visual/_visual.py @@ -1,6 +1,4 @@ -""" -Visual stimulus design -====================== +"""Visual stimulus design. Tools for drawing shapes and text on the screen. """ @@ -11,41 +9,45 @@ # # License: BSD (3-clause) -from ctypes import (cast, pointer, POINTER, create_string_buffer, c_char, - c_int, c_float) -from functools import partial import re - import warnings +from ctypes import POINTER, c_char, c_float, c_int, cast, create_string_buffer, pointer +from functools import partial + import numpy as np + try: from PyOpenGL import gl except ImportError: from pyglet import gl -from .._utils import check_units, string_types, logger, _new_pyglet +from .._utils import _new_pyglet, check_units, logger def _convert_color(color, byte=True): - """Convert 3- or 4-element color into OpenGL usable color""" + """Convert 3- or 4-element color into OpenGL usable color.""" from matplotlib.colors import colorConverter - color = (0., 0., 0., 0.) if color is None else color + + color = (0.0, 0.0, 0.0, 0.0) if color is None else color color = 255 * np.array(colorConverter.to_rgba(color)) color = color.astype(np.uint8) if not byte: - color = (color / 255.).astype(np.float32) - return tuple(color) + color = tuple((color / 255.0).astype(np.float32)) + else: + color = tuple(int(c) for c in color) + return color def _replicate_color(color, pts): - """Convert single color to color array for OpenGL trianglulations""" + """Convert single color to color array for OpenGL triangulations.""" return np.tile(color, len(pts) // 2) ############################################################################## # Text -class Text(object): + +class Text: """A text object. Parameters @@ -93,31 +95,47 @@ class Text(object): The text object. """ - def __init__(self, ec, text, pos=(0, 0), color='white', - font_name='Arial', font_size=24, height=None, - width='auto', anchor_x='center', anchor_y='center', - units='norm', wrap=False, attr=True): + def __init__( + self, + ec, + text, + pos=(0, 0), + color="white", + font_name="Arial", + font_size=24, + height=None, + width="auto", + anchor_x="center", + anchor_y="center", + units="norm", + wrap=False, + attr=True, + ): import pyglet + pos = np.array(pos)[:, np.newaxis] - pos = ec._convert_units(pos, units, 'pix')[:, 0] - if width == 'auto': + pos = ec._convert_units(pos, units, "pix")[:, 0] + if width == "auto": width = float(ec.window_size_pix[0]) * 0.8 - elif isinstance(width, string_types): + elif isinstance(width, str): raise ValueError('"width", if str, must be "auto"') self._attr = attr if wrap: - text = text + '\n ' # weird Pyglet bug + text = text + "\n " # weird Pyglet bug if self._attr: - preamble = ('{{font_name \'{}\'}}{{font_size {}}}{{color {}}}' - '').format(font_name, font_size, _convert_color(color)) + preamble = ( + f"{{font_name '{font_name}'}}" + f"{{font_size {font_size}}}" + f"{{color {_convert_color(color)}}}" + ) doc = pyglet.text.decode_attributed(preamble + text) self._text = pyglet.text.layout.TextLayout( - doc, width=width, height=height, multiline=wrap, - dpi=int(ec.dpi)) + doc, width=width, height=height, multiline=wrap, dpi=int(ec.dpi) + ) else: self._text = pyglet.text.Label( - text, width=width, height=height, multiline=wrap, - dpi=int(ec.dpi)) + text, width=width, height=height, multiline=wrap, dpi=int(ec.dpi) + ) self._text.color = _convert_color(color) self._text.font_name = font_name self._text.font_size = font_size @@ -135,8 +153,9 @@ def set_color(self, color): The color. Use None for no color. """ if self._attr: - self._text.document.set_style(0, len(self._text.document.text), - {'color': _convert_color(color)}) + self._text.document.set_style( + 0, len(self._text.document.text), {"color": _convert_color(color)} + ) else: self._text.color = _convert_color(color) @@ -178,9 +197,11 @@ def _check_log(obj, func): func(obj, 4096, pointer(c_int()), ptr) message = log.value message = message.decode() - if message.startswith('No errors') or \ - re.match('.*shader was successfully compiled.*', message) or \ - message == 'Vertex shader(s) linked, fragment shader(s) linked.\n': + if ( + message.startswith("No errors") + or re.match(".*shader was successfully compiled.*", message) + or message == "Vertex shader(s) linked, fragment shader(s) linked.\n" + ): pass elif message: raise RuntimeError(message) @@ -190,14 +211,14 @@ def _create_program(ec, vert, frag): program = gl.glCreateProgram() vertex = gl.glCreateShader(gl.GL_VERTEX_SHADER) - buf = create_string_buffer(vert.encode('ASCII')) + buf = create_string_buffer(vert.encode("ASCII")) ptr = cast(pointer(pointer(buf)), POINTER(POINTER(c_char))) gl.glShaderSource(vertex, 1, ptr, None) gl.glCompileShader(vertex) _check_log(vertex, gl.glGetShaderInfoLog) fragment = gl.glCreateShader(gl.GL_FRAGMENT_SHADER) - buf = create_string_buffer(frag.encode('ASCII')) + buf = create_string_buffer(frag.encode("ASCII")) ptr = cast(pointer(pointer(buf)), POINTER(POINTER(c_char))) gl.glShaderSource(fragment, 1, ptr, None) gl.glCompileShader(fragment) @@ -213,9 +234,9 @@ def _create_program(ec, vert, frag): # Set the view matrix gl.glUseProgram(program) - loc = gl.glGetUniformLocation(program, b'u_view') + loc = gl.glGetUniformLocation(program, b"u_view") view = ec.window_size_pix - view = np.diag([2. / view[0], 2. / view[1], 1., 1.]) + view = np.diag([2.0 / view[0], 2.0 / view[1], 1.0, 1.0]) view[-1, :2] = -1 view = view.astype(np.float32).ravel() gl.glUniformMatrix4fv(loc, 1, False, (c_float * 16)(*view)) @@ -223,7 +244,7 @@ def _create_program(ec, vert, frag): return program -class _Triangular(object): +class _Triangular: """Super class for objects that use triangulations and/or lines""" def __init__(self, ec, fill_color, line_color, line_width, line_loop): @@ -240,13 +261,13 @@ def __init__(self, ec, fill_color, line_color, line_width, line_loop): self._buffers = dict() self._points = dict() self._tris = dict() - for kind in ('line', 'fill'): + for kind in ("line", "fill"): self._counts[kind] = 0 - self._colors[kind] = (0., 0., 0., 0.) + self._colors[kind] = (0.0, 0.0, 0.0, 0.0) self._buffers[kind] = dict(array=gl.GLuint()) - gl.glGenBuffers(1, pointer(self._buffers[kind]['array'])) - self._buffers['fill']['index'] = gl.GLuint() - gl.glGenBuffers(1, pointer(self._buffers['fill']['index'])) + gl.glGenBuffers(1, pointer(self._buffers[kind]["array"])) + self._buffers["fill"]["index"] = gl.GLuint() + gl.glGenBuffers(1, pointer(self._buffers["fill"]["index"])) gl.glUseProgram(0) self.set_fill_color(fill_color) @@ -256,12 +277,12 @@ def _set_points(self, points, kind, tris): """Set fill and line points.""" if points is None: self._counts[kind] = 0 - points = np.asarray(points, dtype=np.float32, order='C') + points = np.asarray(points, dtype=np.float32, order="C") assert points.ndim == 2 and points.shape[1] == 2 - array_count = points.size // 2 if kind == 'line' else points.size - if kind == 'fill': + array_count = points.size // 2 if kind == "line" else points.size + if kind == "fill": assert tris is not None - tris = np.asarray(tris, dtype=np.uint32, order='C') + tris = np.asarray(tris, dtype=np.uint32, order="C") assert tris.ndim == 1 and tris.size % 3 == 0 tris.shape = (-1, 3) assert (tris < len(points)).all() @@ -271,29 +292,33 @@ def _set_points(self, points, kind, tris): del points gl.glUseProgram(self._program) - gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._buffers[kind]['array']) - gl.glBufferData(gl.GL_ARRAY_BUFFER, self._points[kind].size * 4, - self._points[kind].tobytes(), - gl.GL_STATIC_DRAW) - if kind == 'line': + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._buffers[kind]["array"]) + gl.glBufferData( + gl.GL_ARRAY_BUFFER, + self._points[kind].size * 4, + self._points[kind].tobytes(), + gl.GL_STATIC_DRAW, + ) + if kind == "line": self._counts[kind] = array_count - if kind == 'fill': + if kind == "fill": self._counts[kind] = self._tris[kind].size - gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, - self._buffers[kind]['index']) - gl.glBufferData(gl.GL_ELEMENT_ARRAY_BUFFER, - self._tris[kind].size * 4, - self._tris[kind].tobytes(), - gl.GL_STATIC_DRAW) + gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, self._buffers[kind]["index"]) + gl.glBufferData( + gl.GL_ELEMENT_ARRAY_BUFFER, + self._tris[kind].size * 4, + self._tris[kind].tobytes(), + gl.GL_STATIC_DRAW, + ) gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, 0) gl.glBindBuffer(gl.GL_ARRAY_BUFFER, 0) gl.glUseProgram(0) def _set_fill_points(self, points, tris): - self._set_points(points, 'fill', tris) + self._set_points(points, "fill", tris) def _set_line_points(self, points): - self._set_points(points, 'line', None) + self._set_points(points, "line", None) def set_fill_color(self, fill_color): """Set the object color @@ -303,7 +328,7 @@ def set_fill_color(self, fill_color): fill_color : matplotlib Color | None The fill color. Use None for no fill. """ - self._colors['fill'] = _convert_color(fill_color, byte=False) + self._colors["fill"] = _convert_color(fill_color, byte=False) def set_line_color(self, line_color): """Set the object color @@ -313,7 +338,7 @@ def set_line_color(self, line_color): line_color : matplotlib Color | None The fill color. Use None for no fill. """ - self._colors['line'] = _convert_color(line_color, byte=False) + self._colors["line"] = _convert_color(line_color, byte=False) def set_line_width(self, line_width): """Set the line width in pixels @@ -326,15 +351,15 @@ def set_line_width(self, line_width): """ line_width = float(line_width) if not (0.0 <= line_width <= 10.0): - raise ValueError('line_width must be between 0 and 10') + raise ValueError("line_width must be between 0 and 10") self._line_width = line_width def draw(self): """Draw the object to the display buffer.""" gl.glUseProgram(self._program) - for kind in ('fill', 'line'): + for kind in ("fill", "line"): if self._counts[kind] > 0: - if kind == 'line': + if kind == "line": if self._line_width <= 0.0: continue gl.glLineWidth(self._line_width) @@ -344,22 +369,26 @@ def draw(self): mode = gl.GL_LINE_STRIP cmd = partial(gl.glDrawArrays, mode, 0, self._counts[kind]) else: - gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, - self._buffers[kind]['index']) - cmd = partial(gl.glDrawElements, gl.GL_TRIANGLES, - self._counts[kind], gl.GL_UNSIGNED_INT, 0) - gl.glBindBuffer(gl.GL_ARRAY_BUFFER, - self._buffers[kind]['array']) - loc_pos = gl.glGetAttribLocation(self._program, b'a_position') + gl.glBindBuffer( + gl.GL_ELEMENT_ARRAY_BUFFER, self._buffers[kind]["index"] + ) + cmd = partial( + gl.glDrawElements, + gl.GL_TRIANGLES, + self._counts[kind], + gl.GL_UNSIGNED_INT, + 0, + ) + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._buffers[kind]["array"]) + loc_pos = gl.glGetAttribLocation(self._program, b"a_position") gl.glEnableVertexAttribArray(loc_pos) - gl.glVertexAttribPointer(loc_pos, 2, gl.GL_FLOAT, gl.GL_FALSE, - 0, 0) - loc_col = gl.glGetUniformLocation(self._program, b'u_color') + gl.glVertexAttribPointer(loc_pos, 2, gl.GL_FLOAT, gl.GL_FALSE, 0, 0) + loc_col = gl.glGetUniformLocation(self._program, b"u_color") gl.glUniform4f(loc_col, *self._colors[kind]) cmd() # cleanup gl.glDisableVertexAttribArray(loc_pos) - if kind != 'line': + if kind != "line": gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, 0) gl.glBindBuffer(gl.GL_ARRAY_BUFFER, 0) gl.glUseProgram(0) @@ -390,14 +419,27 @@ class Line(_Triangular): The line object. """ - def __init__(self, ec, coords, units='norm', line_color='white', - line_width=1.0, line_loop=False): - _Triangular.__init__(self, ec, fill_color=None, line_color=line_color, - line_width=line_width, line_loop=line_loop) + def __init__( + self, + ec, + coords, + units="norm", + line_color="white", + line_width=1.0, + line_loop=False, + ): + _Triangular.__init__( + self, + ec, + fill_color=None, + line_color=line_color, + line_width=line_width, + line_loop=line_loop, + ) self.set_coords(coords, units) self.set_line_color(line_color) - def set_coords(self, coords, units='norm'): + def set_coords(self, coords, units="norm"): """Set line coordinates Parameters @@ -412,10 +454,12 @@ def set_coords(self, coords, units='norm'): if coords.ndim == 1: coords = coords[:, np.newaxis] if coords.ndim != 2 or coords.shape[0] != 2: - raise ValueError('coords must be a vector of length 2, or an ' - 'array with 2 dimensions (with first dimension ' - 'having length 2') - self._set_line_points(self._ec._convert_units(coords, units, 'pix').T) + raise ValueError( + "coords must be a vector of length 2, or an " + "array with 2 dimensions (with first dimension " + "having length 2" + ) + self._set_line_points(self._ec._convert_units(coords, units, "pix").T) class Triangle(_Triangular): @@ -443,15 +487,27 @@ class Triangle(_Triangular): The triangle object. """ - def __init__(self, ec, coords, units='norm', fill_color='white', - line_color=None, line_width=1.0): - _Triangular.__init__(self, ec, fill_color=fill_color, - line_color=line_color, line_width=line_width, - line_loop=True) + def __init__( + self, + ec, + coords, + units="norm", + fill_color="white", + line_color=None, + line_width=1.0, + ): + _Triangular.__init__( + self, + ec, + fill_color=fill_color, + line_color=line_color, + line_width=line_width, + line_loop=True, + ) self.set_coords(coords, units) self.set_fill_color(fill_color) - def set_coords(self, coords, units='norm'): + def set_coords(self, coords, units="norm"): """Set triangle coordinates Parameters @@ -464,9 +520,10 @@ def set_coords(self, coords, units='norm'): check_units(units) coords = np.array(coords, dtype=float) if coords.shape != (2, 3): - raise ValueError('coords must be an array of shape (2, 3), got %s' - % (coords.shape,)) - points = self._ec._convert_units(coords, units, 'pix') + raise ValueError( + "coords must be an array of shape (2, 3), got %s" % (coords.shape,) + ) + points = self._ec._convert_units(coords, units, "pix") points = points.T self._set_fill_points(points, [0, 1, 2]) self._set_line_points(points) @@ -498,14 +555,20 @@ class Rectangle(_Triangular): The rectangle object. """ - def __init__(self, ec, pos, units='norm', fill_color='white', - line_color=None, line_width=1.0): - _Triangular.__init__(self, ec, fill_color=fill_color, - line_color=line_color, line_width=line_width, - line_loop=True) + def __init__( + self, ec, pos, units="norm", fill_color="white", line_color=None, line_width=1.0 + ): + _Triangular.__init__( + self, + ec, + fill_color=fill_color, + line_color=line_color, + line_width=line_width, + line_loop=True, + ) self.set_pos(pos, units) - def set_pos(self, pos, units='norm'): + def set_pos(self, pos, units="norm"): """Set the position of the rectangle Parameters @@ -519,16 +582,20 @@ def set_pos(self, pos, units='norm'): # do this in normalized units, then convert pos = np.array(pos) if not (pos.ndim == 1 and pos.size == 4): - raise ValueError('pos must be a 4-element array-like vector') + raise ValueError("pos must be a 4-element array-like vector") self._pos = pos w = self._pos[2] h = self._pos[3] - points = np.array([[-w / 2., -h / 2.], - [-w / 2., h / 2.], - [w / 2., h / 2.], - [w / 2., -h / 2.]]).T + points = np.array( + [ + [-w / 2.0, -h / 2.0], + [-w / 2.0, h / 2.0], + [w / 2.0, h / 2.0], + [w / 2.0, -h / 2.0], + ] + ).T points += np.array(self._pos[:2])[:, np.newaxis] - points = self._ec._convert_units(points, units, 'pix') + points = self._ec._convert_units(points, units, "pix") points = points.T self._set_fill_points(points, [0, 1, 2, 0, 2, 3]) self._set_line_points(points) # all 4 points used for line drawing @@ -560,14 +627,20 @@ class Diamond(_Triangular): The rectangle object. """ - def __init__(self, ec, pos, units='norm', fill_color='white', - line_color=None, line_width=1.0): - _Triangular.__init__(self, ec, fill_color=fill_color, - line_color=line_color, line_width=line_width, - line_loop=True) + def __init__( + self, ec, pos, units="norm", fill_color="white", line_color=None, line_width=1.0 + ): + _Triangular.__init__( + self, + ec, + fill_color=fill_color, + line_color=line_color, + line_width=line_width, + line_loop=True, + ) self.set_pos(pos, units) - def set_pos(self, pos, units='norm'): + def set_pos(self, pos, units="norm"): """Set the position of the rectangle Parameters @@ -581,16 +654,15 @@ def set_pos(self, pos, units='norm'): # do this in normalized units, then convert pos = np.array(pos) if not (pos.ndim == 1 and pos.size == 4): - raise ValueError('pos must be a 4-element array-like vector') + raise ValueError("pos must be a 4-element array-like vector") self._pos = pos w = self._pos[2] h = self._pos[3] - points = np.array([[w / 2., 0.], - [0., h / 2.], - [-w / 2., 0.], - [0., -h / 2.]]).T + points = np.array( + [[w / 2.0, 0.0], [0.0, h / 2.0], [-w / 2.0, 0.0], [0.0, -h / 2.0]] + ).T points += np.array(self._pos[:2])[:, np.newaxis] - points = self._ec._convert_units(points, units, 'pix') + points = self._ec._convert_units(points, units, "pix") points = points.T self._set_fill_points(points, [0, 1, 2, 0, 2, 3]) self._set_line_points(points) @@ -626,16 +698,29 @@ class Circle(_Triangular): The circle object. """ - def __init__(self, ec, radius=1, pos=(0, 0), units='norm', - n_edges=200, fill_color='white', line_color=None, - line_width=1.0): - _Triangular.__init__(self, ec, fill_color=fill_color, - line_color=line_color, line_width=line_width, - line_loop=True) + def __init__( + self, + ec, + radius=1, + pos=(0, 0), + units="norm", + n_edges=200, + fill_color="white", + line_color=None, + line_width=1.0, + ): + _Triangular.__init__( + self, + ec, + fill_color=fill_color, + line_color=line_color, + line_width=line_width, + line_loop=True, + ) if not isinstance(n_edges, int): - raise TypeError('n_edges must be an int') + raise TypeError("n_edges must be an int") if n_edges < 4: - raise ValueError('n_edges must be >= 4 for a reasonable circle') + raise ValueError("n_edges must be >= 4 for a reasonable circle") self._n_edges = n_edges # construct triangulation (never changes so long as n_edges is fixed) @@ -645,11 +730,11 @@ def __init__(self, ec, radius=1, pos=(0, 0), units='norm', self._orig_tris = tris # need to set a dummy value here so recalculation doesn't fail - self._radius = np.array([1., 1.]) + self._radius = np.array([1.0, 1.0]) self.set_pos(pos, units) self.set_radius(radius, units) - def set_radius(self, radius, units='norm'): + def set_radius(self, radius, units="norm"): """Set the position and radius of the circle Parameters @@ -663,19 +748,19 @@ def set_radius(self, radius, units='norm'): check_units(units) radius = np.atleast_1d(radius).astype(float) if radius.ndim != 1 or radius.size > 2: - raise ValueError('radius must be a 1- or 2-element ' - 'array-like vector') + raise ValueError("radius must be a 1- or 2-element " "array-like vector") if radius.size == 1: radius = np.r_[radius, radius] # convert to pixel (OpenGL) units - self._radius = self._ec._convert_units(radius[:, np.newaxis], - units, 'pix')[:, 0] + self._radius = self._ec._convert_units(radius[:, np.newaxis], units, "pix")[ + :, 0 + ] # need to subtract center position - ctr = self._ec._convert_units(np.zeros((2, 1)), units, 'pix')[:, 0] + ctr = self._ec._convert_units(np.zeros((2, 1)), units, "pix")[:, 0] self._radius -= ctr self._recalculate() - def set_pos(self, pos, units='norm'): + def set_pos(self, pos, units="norm"): """Set the position and radius of the circle Parameters @@ -688,18 +773,18 @@ def set_pos(self, pos, units='norm'): check_units(units) pos = np.array(pos, dtype=float) if not (pos.ndim == 1 and pos.size == 2): - raise ValueError('pos must be a 2-element array-like vector') + raise ValueError("pos must be a 2-element array-like vector") # convert to pixel (OpenGL) units - self._pos = self._ec._convert_units(pos[:, np.newaxis], - units, 'pix')[:, 0] + self._pos = self._ec._convert_units(pos[:, np.newaxis], units, "pix")[:, 0] self._recalculate() def _recalculate(self): """Helper to recalculate point coordinates""" edges = self._n_edges arg = 2 * np.pi * (np.arange(edges) / float(edges)) - points = np.array([self._radius[0] * np.cos(arg), - self._radius[1] * np.sin(arg)]) + points = np.array( + [self._radius[0] * np.cos(arg), self._radius[1] * np.sin(arg)] + ) points = np.c_[np.zeros((2, 1)), points] # prepend the center points += np.array(self._pos[:2], dtype=float)[:, np.newaxis] points = points.T @@ -707,7 +792,7 @@ def _recalculate(self): self._set_line_points(points[1:]) # omit center point for lines -class ConcentricCircles(object): +class ConcentricCircles: """A set of filled concentric circles drawn without edges. Parameters @@ -732,23 +817,26 @@ class ConcentricCircles(object): The circle object. """ - def __init__(self, ec, radii=(0.2, 0.05), pos=(0, 0), units='norm', - colors=('w', 'k')): + def __init__( + self, ec, radii=(0.2, 0.05), pos=(0, 0), units="norm", colors=("w", "k") + ): radii = np.array(radii, float) if radii.ndim != 1: - raise ValueError('radii must be 1D') + raise ValueError("radii must be 1D") if not isinstance(colors, (tuple, list)): - raise TypeError('colors must be a tuple, list, or array') + raise TypeError("colors must be a tuple, list, or array") if len(colors) != len(radii): - raise ValueError('colors and radii must be the same length') + raise ValueError("colors and radii must be the same length") # need to set a dummy value here so recalculation doesn't fail - self._circles = [Circle(ec, r, pos, units, fill_color=c, line_width=0) - for r, c in zip(radii, colors)] + self._circles = [ + Circle(ec, r, pos, units, fill_color=c, line_width=0) + for r, c in zip(radii, colors) + ] def __len__(self): return len(self._circles) - def set_pos(self, pos, units='norm'): + def set_pos(self, pos, units="norm"): """Set the position of the circles Parameters @@ -761,7 +849,7 @@ def set_pos(self, pos, units='norm'): for circle in self._circles: circle.set_pos(pos, units) - def set_radius(self, radius, idx, units='norm'): + def set_radius(self, radius, idx, units="norm"): """Set the radius of one of the circles Parameters @@ -775,7 +863,7 @@ def set_radius(self, radius, idx, units='norm'): """ self._circles[idx].set_radius(radius, units) - def set_radii(self, radii, units='norm'): + def set_radii(self, radii, units="norm"): """Set the color of each circle Parameters @@ -788,8 +876,7 @@ def set_radii(self, radii, units='norm'): """ radii = np.array(radii, float) if radii.ndim != 1 or radii.size != len(self): - raise ValueError('radii must contain exactly {0} radii' - ''.format(len(self))) + raise ValueError(f"radii must contain exactly {len(self)} radii" "") for idx, radius in enumerate(radii): self.set_radius(radius, idx, units) @@ -815,8 +902,9 @@ def set_colors(self, colors): colors as the number of circles. """ if not isinstance(colors, (tuple, list)) or len(colors) != len(self): - raise ValueError('colors must be a list or tuple with {0} colors' - ''.format(len(self))) + raise ValueError( + f"colors must be a list or tuple with {len(self)} colors" "" + ) for idx, color in enumerate(colors): self.set_color(color, idx) @@ -846,16 +934,14 @@ class FixationDot(ConcentricCircles): The fixation dot. """ - def __init__(self, ec, colors=('w', 'k')): + def __init__(self, ec, colors=("w", "k")): if len(colors) != 2: - raise ValueError('colors must have length 2') - super(FixationDot, self).__init__(ec, radii=[0.2, 0.2], - pos=[0, 0], units='deg', - colors=colors) - self.set_radius(1, 1, units='pix') + raise ValueError("colors must have length 2") + super().__init__(ec, radii=[0.2, 0.2], pos=[0, 0], units="deg", colors=colors) + self.set_radius(1, 1, units="pix") -class ProgressBar(object): +class ProgressBar: """A progress bar that can be displayed between sections. This uses two rectangles, one outline, and one solid to show how much @@ -876,12 +962,12 @@ class ProgressBar(object): white. """ - def __init__(self, ec, pos, units='norm', colors=('g', 'w')): + def __init__(self, ec, pos, units="norm", colors=("g", "w")): self._ec = ec if len(colors) != 2: - raise ValueError('colors must have length 2') - if units not in ['norm', 'pix']: - raise ValueError('units must be either \'norm\' or \'pix\'') + raise ValueError("colors must have length 2") + if units not in ["norm", "pix"]: + raise ValueError("units must be either 'norm' or 'pix'") pos = np.array(pos, dtype=float) self._pos = pos @@ -894,9 +980,10 @@ def __init__(self, ec, pos, units='norm', colors=('g', 'w')): self._init_x = self._pos_bar[0] self._pos_bar[2] = 0 - self._rectangles = [Rectangle(ec, self._pos_bar, units, colors[0], - None), - Rectangle(ec, self._pos, units, None, colors[1])] + self._rectangles = [ + Rectangle(ec, self._pos_bar, units, colors[0], None), + Rectangle(ec, self._pos, units, None, colors[1]), + ] def update_bar(self, percent): """Update the progress of the bar. @@ -907,8 +994,8 @@ def update_bar(self, percent): The percentage of the bar to be filled. Must be between 0 and 1. """ if percent > 100 or percent < 0: - raise ValueError('percent must be a float between 0 and 100') - self._pos_bar[2] = percent * self._width / 100. + raise ValueError("percent must be a float between 0 and 100") + self._pos_bar[2] = percent * self._width / 100.0 self._pos_bar[0] = self._init_x + self._pos_bar[2] * 0.5 self._rectangles[0].set_pos(self._pos_bar, self._units) @@ -921,7 +1008,8 @@ def draw(self): ############################################################################## # Image display -class RawImage(object): + +class RawImage: """Create image from array for on-screen display. Parameters @@ -944,7 +1032,7 @@ class RawImage(object): The image object. """ - def __init__(self, ec, image_buffer, pos=(0, 0), scale=1., units='norm'): + def __init__(self, ec, image_buffer, pos=(0, 0), scale=1.0, units="norm"): self._ec = ec self._img = None self.set_image(image_buffer) @@ -962,26 +1050,28 @@ def set_image(self, image_buffer): ``np.uint8`` is slightly more efficient. """ from pyglet import image, sprite + image_buffer = np.ascontiguousarray(image_buffer) if image_buffer.dtype not in (np.float64, np.uint8): - raise TypeError('image_buffer must be np.float64 or np.uint8') + raise TypeError("image_buffer must be np.float64 or np.uint8") if image_buffer.dtype == np.float64: if image_buffer.max() > 1 or image_buffer.min() < 0: - raise ValueError('all float values must be between 0 and 1') - image_buffer = (image_buffer * 255).astype('uint8') + raise ValueError("all float values must be between 0 and 1") + image_buffer = (image_buffer * 255).astype("uint8") if image_buffer.ndim == 2: # grayscale image_buffer = np.tile(image_buffer[..., np.newaxis], (1, 1, 3)) if not image_buffer.ndim == 3 or image_buffer.shape[2] not in [3, 4]: - raise RuntimeError('image_buffer incorrect size: {}' - ''.format(image_buffer.shape)) + raise RuntimeError(f"image_buffer incorrect size: {image_buffer.shape}" "") # add alpha channel if necessary dims = image_buffer.shape - fmt = 'RGB' if dims[2] == 3 else 'RGBA' - self._sprite = sprite.Sprite(image.ImageData(dims[1], dims[0], fmt, - image_buffer.tobytes(), - -dims[1] * dims[2])) - - def set_pos(self, pos, units='norm'): + fmt = "RGB" if dims[2] == 3 else "RGBA" + self._sprite = sprite.Sprite( + image.ImageData( + dims[1], dims[0], fmt, image_buffer.tobytes(), -dims[1] * dims[2] + ) + ) + + def set_pos(self, pos, units="norm"): """Set image position. Parameters @@ -993,17 +1083,16 @@ def set_pos(self, pos, units='norm'): """ pos = np.array(pos, float) if pos.ndim != 1 or pos.size != 2: - raise ValueError('pos must be a 2-element array') + raise ValueError("pos must be a 2-element array") pos = np.reshape(pos, (2, 1)) - self._pos = self._ec._convert_units(pos, units, 'pix').ravel() + self._pos = self._ec._convert_units(pos, units, "pix").ravel() @property def bounds(self): """Left, Right, Bottom, Top (in pixels) of the image.""" pos = np.array(self._pos, float) - size = np.array([self._sprite.width, - self._sprite.height], float) - bounds = np.concatenate((pos - size / 2., pos + size / 2.)) + size = np.array([self._sprite.width, self._sprite.height], float) + bounds = np.concatenate((pos - size / 2.0, pos + size / 2.0)) return bounds[[0, 2, 1, 3]] @property @@ -1026,14 +1115,14 @@ def set_scale(self, scale): def draw(self): """Draw the image to the buffer""" self._sprite.scale = self._scale - pos = self._pos - [self._sprite.width / 2., self._sprite.height / 2.] + pos = self._pos - [self._sprite.width / 2.0, self._sprite.height / 2.0] try: self._sprite.position = (pos[0], pos[1]) except AttributeError: self._sprite.set_position(pos[0], pos[1]) self._sprite.draw() - def get_rect(self, units='norm'): + def get_rect(self, units="norm"): """X, Y center, Width, Height of image. Parameters @@ -1047,15 +1136,13 @@ def get_rect(self, units='norm'): The rect. """ # left,right,bottom,top - lrbt = self._ec._convert_units(self.bounds.reshape(2, -1), - fro='pix', to=units) - center = self._ec._convert_units(self._pos.reshape(2, -1), - fro='pix', to=units) + lrbt = self._ec._convert_units(self.bounds.reshape(2, -1), fro="pix", to=units) + center = self._ec._convert_units(self._pos.reshape(2, -1), fro="pix", to=units) width_height = np.diff(lrbt, axis=-1) return np.squeeze(np.concatenate([center, width_height])) -tex_vert = ''' +tex_vert = """ #version 120 attribute vec2 a_position; @@ -1068,9 +1155,9 @@ def get_rect(self, units='norm'): gl_Position = u_view * vec4(a_position, 0.0, 1.0); v_texcoord = a_texcoord; } -''' +""" -tex_frag = ''' +tex_frag = """ #version 120 #extension GL_ARB_texture_rectangle : enable @@ -1082,10 +1169,10 @@ def get_rect(self, units='norm'): gl_FragColor = texture2DRect(u_texture, v_texcoord); gl_FragColor.a = 1.0; } -''' +""" -class Video(object): +class Video: """Read video file and draw it to the screen. Parameters @@ -1123,20 +1210,31 @@ class Video(object): entertainment for the participant during a passive auditory task). """ - def __init__(self, ec, file_name, pos=(0, 0), units='norm', scale=1., - center=True, visible=True): - from pyglet.media import load, Player + def __init__( + self, + ec, + file_name, + pos=(0, 0), + units="norm", + scale=1.0, + center=True, + visible=True, + ): + from pyglet.media import Player, load + self._ec = ec # On Windows, the default is unaccelerated WMF, which is terribly slow. decoder = None if _new_pyglet(): try: from pyglet.media.codecs.ffmpeg import FFmpegDecoder + decoder = FFmpegDecoder() except Exception as exc: warnings.warn( - 'FFmpeg decoder could not be instantiated, decoding ' - f'performance could be compromised:\n{exc}') + "FFmpeg decoder could not be instantiated, decoding " + f"performance could be compromised:\n{exc}" + ) self._source = load(file_name, decoder=decoder) self._player = Player() with warnings.catch_warnings(record=True): # deprecated eos_action @@ -1144,9 +1242,9 @@ def __init__(self, ec, file_name, pos=(0, 0), units='norm', scale=1., self._player._audio_player = None frame_rate = self.frame_rate if frame_rate is None: - logger.warning('Frame rate could not be determined') - frame_rate = 60. - self._dt = 1. / frame_rate + logger.warning("Frame rate could not be determined") + frame_rate = 60.0 + self._dt = 1.0 / frame_rate self._playing = False self._finished = False self._pos = pos @@ -1159,14 +1257,15 @@ def __init__(self, ec, file_name, pos=(0, 0), units='norm', scale=1., self._program = _create_program(ec, tex_vert, tex_frag) gl.glUseProgram(self._program) self._buffers = dict() - for key in ('position', 'texcoord'): + for key in ("position", "texcoord"): self._buffers[key] = gl.GLuint(0) gl.glGenBuffers(1, pointer(self._buffers[key])) w, h = self.source_width, self.source_height tex = np.array([(0, h), (w, h), (w, 0), (0, 0)], np.float32) - gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._buffers['texcoord']) - gl.glBufferData(gl.GL_ARRAY_BUFFER, tex.nbytes, tex.tobytes(), - gl.GL_DYNAMIC_DRAW) + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._buffers["texcoord"]) + gl.glBufferData( + gl.GL_ARRAY_BUFFER, tex.nbytes, tex.tobytes(), gl.GL_DYNAMIC_DRAW + ) gl.glBindBuffer(gl.GL_ARRAY_BUFFER, 0) gl.glUseProgram(0) @@ -1190,8 +1289,9 @@ def play(self, auto_draw=True): self._player.play() self._playing = True else: - warnings.warn('ExperimentController.video.play() called when ' - 'already playing.') + warnings.warn( + "ExperimentController.video.play() called when " "already playing." + ) return self._ec.get_time() def pause(self): @@ -1213,8 +1313,9 @@ def pause(self): self._player.pause() self._playing = False else: - warnings.warn('ExperimentController.video.pause() called when ' - 'already paused.') + warnings.warn( + "ExperimentController.video.pause() called when " "already paused." + ) return self._ec.get_time() def _delete(self): @@ -1223,7 +1324,7 @@ def _delete(self): self.pause() self._player.delete() - def set_scale(self, scale=1.): + def set_scale(self, scale=1.0): """Set video scale. Parameters @@ -1237,20 +1338,20 @@ def set_scale(self, scale=1.): while ensuring none of the video is offscreen, which may result in letterboxing). """ - if isinstance(scale, string_types): - _scale = self._ec.window_size_pix / np.array((self.source_width, - self.source_height), - dtype=float) - if scale == 'fit': + if isinstance(scale, str): + _scale = self._ec.window_size_pix / np.array( + (self.source_width, self.source_height), dtype=float + ) + if scale == "fit": scale = _scale.min() - elif scale == 'fill': + elif scale == "fill": scale = _scale.max() self._scale = float(scale) # allows [1, 1., '1']; others: ValueError if self._scale <= 0: - raise ValueError('Video scale factor must be strictly positive.') + raise ValueError("Video scale factor must be strictly positive.") self.set_pos(self._pos, self._units, self._center) - def set_pos(self, pos, units='norm', center=True): + def set_pos(self, pos, units="norm", center=True): """Set video position. Parameters @@ -1266,9 +1367,9 @@ def set_pos(self, pos, units='norm', center=True): """ pos = np.array(pos, float) if pos.size != 2: - raise ValueError('pos must be a 2-element array') + raise ValueError("pos must be a 2-element array") pos = np.reshape(pos, (2, 1)) - pix = self._ec._convert_units(pos, units, 'pix').ravel() + pix = self._ec._convert_units(pos, units, "pix").ravel() offset = np.array((self.width, self.height)) // 2 if center else 0 self._pos = pos self._actual_pos = pix - offset @@ -1284,16 +1385,16 @@ def _draw(self): x, y = self._actual_pos w = self.source_width * self._scale h = self.source_height * self._scale - pos = np.array( - [(x, y), (x + w, y), (x + w, y + h), (x, y + h)], np.float32) - gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._buffers['position']) - gl.glBufferData(gl.GL_ARRAY_BUFFER, pos.nbytes, pos.tobytes(), - gl.GL_DYNAMIC_DRAW) - loc_pos = gl.glGetAttribLocation(self._program, b'a_position') + pos = np.array([(x, y), (x + w, y), (x + w, y + h), (x, y + h)], np.float32) + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._buffers["position"]) + gl.glBufferData( + gl.GL_ARRAY_BUFFER, pos.nbytes, pos.tobytes(), gl.GL_DYNAMIC_DRAW + ) + loc_pos = gl.glGetAttribLocation(self._program, b"a_position") gl.glEnableVertexAttribArray(loc_pos) gl.glVertexAttribPointer(loc_pos, 2, gl.GL_FLOAT, gl.GL_FALSE, 0, 0) - gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._buffers['texcoord']) - loc_tex = gl.glGetAttribLocation(self._program, b'a_texcoord') + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._buffers["texcoord"]) + loc_tex = gl.glGetAttribLocation(self._program, b"a_texcoord") gl.glEnableVertexAttribArray(loc_tex) gl.glVertexAttribPointer(loc_tex, 2, gl.GL_FLOAT, gl.GL_FALSE, 0, 0) gl.glBindBuffer(gl.GL_ARRAY_BUFFER, 0) @@ -1340,9 +1441,11 @@ def _eos(self): return self._eos_fun() def _eos_old(self): - return (self._player._last_video_timestamp is not None and - self._player._last_video_timestamp == - self._source.get_next_video_timestamp()) + return ( + self._player._last_video_timestamp is not None + and self._player._last_video_timestamp + == self._source.get_next_video_timestamp() + ) def _eos_new(self): ts = self._source.get_next_video_timestamp() diff --git a/expyfun/visual/tests/test_visuals.py b/expyfun/visual/tests/test_visuals.py index fbae8ca2..1619f2ad 100644 --- a/expyfun/visual/tests/test_visuals.py +++ b/expyfun/visual/tests/test_visuals.py @@ -2,18 +2,26 @@ import pytest from numpy.testing import assert_equal -from expyfun import ExperimentController, visual, fetch_data_file +from expyfun import ExperimentController, fetch_data_file, visual from expyfun._utils import requires_opengl21, requires_video -std_kwargs = dict(output_dir=None, full_screen=False, window_size=(1, 1), - participant='foo', session='01', stim_db=0.0, noise_db=0.0, - verbose=True, version='dev') +std_kwargs = dict( + output_dir=None, + full_screen=False, + window_size=(1, 1), + participant="foo", + session="01", + stim_db=0.0, + noise_db=0.0, + verbose=True, + version="dev", +) @requires_opengl21 def test_visuals(hide_window): """Test EC visual methods.""" - with ExperimentController('test', **std_kwargs) as ec: + with ExperimentController("test", **std_kwargs) as ec: pytest.raises(TypeError, visual.Circle, ec, n_edges=3.5) pytest.raises(ValueError, visual.Circle, ec, n_edges=3) circ = visual.Circle(ec) @@ -21,29 +29,28 @@ def test_visuals(hide_window): pytest.raises(ValueError, circ.set_radius, [1, 2, 3]) pytest.raises(ValueError, circ.set_pos, [1]) pytest.raises(ValueError, visual.Triangle, ec, [5, 6]) - tri = visual.Triangle(ec, [[-1, 0, 1], [-1, 1, -1]], units='deg', - line_width=1.0) + tri = visual.Triangle( + ec, [[-1, 0, 1], [-1, 1, -1]], units="deg", line_width=1.0 + ) tri.draw() rect = visual.Rectangle(ec, [0, 0, 1, 1], line_width=1.0) rect.draw() diamond = visual.Diamond(ec, [0, 0, 1, 1], line_width=1.0) diamond.draw() pytest.raises(TypeError, visual.ConcentricCircles, ec, colors=dict()) - pytest.raises(TypeError, visual.ConcentricCircles, ec, - colors=np.array([])) + pytest.raises(TypeError, visual.ConcentricCircles, ec, colors=np.array([])) pytest.raises(ValueError, visual.ConcentricCircles, ec, radii=[[1]]) pytest.raises(ValueError, visual.ConcentricCircles, ec, radii=[1]) - fix = visual.ConcentricCircles(ec, radii=[1, 2, 3], - colors=['w', 'k', 'y']) + fix = visual.ConcentricCircles(ec, radii=[1, 2, 3], colors=["w", "k", "y"]) fix.set_pos([0.5, 0.5]) fix.set_radius(0.1, 1) fix.set_radii([0.1, 0.2, 0.3]) - fix.set_color('w', 1) - fix.set_colors(['w', 'k', 'k']) - fix.set_colors(('w', 'k', 'k')) - pytest.raises(IndexError, fix.set_color, 'w', 3) - pytest.raises(ValueError, fix.set_colors, ['w', 'k']) - pytest.raises(ValueError, fix.set_colors, np.array(['w', 'k', 'k'])) + fix.set_color("w", 1) + fix.set_colors(["w", "k", "k"]) + fix.set_colors(("w", "k", "k")) + pytest.raises(IndexError, fix.set_color, "w", 3) + pytest.raises(ValueError, fix.set_colors, ["w", "k"]) + pytest.raises(ValueError, fix.set_colors, np.array(["w", "k", "k"])) pytest.raises(IndexError, fix.set_radius, 0.1, 3) pytest.raises(ValueError, fix.set_radii, [0.1, 0.2]) fix.draw() @@ -60,8 +67,9 @@ def test_visuals(hide_window): assert_equal(img.scale, 1) # test get_rect imgrect = visual.Rectangle(ec, img.get_rect()) - assert_equal(imgrect._points['fill'][(0, 2, 0, 1), (0, 0, 1, 1)], - img.bounds) + assert_equal( + imgrect._points["fill"][(0, 2, 0, 1), (0, 0, 1, 1)], img.bounds + ) img.draw() line = visual.Line(ec, [[0, 1], [1, 0]]) line.draw() @@ -70,25 +78,29 @@ def test_visuals(hide_window): line.draw() pytest.raises(ValueError, line.set_coords, [0]) line.set_coords([0, 1]) - ec.set_background_color('black') - text = visual.Text(ec, 'Hello {color (255, 0, 0, 255)}Everybody!', - pos=[0, 0], color=[1, 1, 1], wrap=False) + ec.set_background_color("black") + text = visual.Text( + ec, + "Hello {color (255, 0, 0, 255)}Everybody!", + pos=[0, 0], + color=[1, 1, 1], + wrap=False, + ) text.draw() text.set_color(None) text.draw() - text = visual.Text(ec, 'Thank you, come again.', pos=[0, 0], - color='white', attr=False) + text = visual.Text( + ec, "Thank you, come again.", pos=[0, 0], color="white", attr=False + ) text.draw() - text.set_color('red') + text.set_color("red") text.draw() - bar = visual.ProgressBar(ec, [0, 0, 1, .2]) - bar = visual.ProgressBar(ec, [0, 0, 1, 1], units='pix') - bar.update_bar(.5) + bar = visual.ProgressBar(ec, [0, 0, 1, 0.2]) + bar = visual.ProgressBar(ec, [0, 0, 1, 1], units="pix") + bar.update_bar(0.5) bar.draw() - pytest.raises(ValueError, visual.ProgressBar, ec, [0, 0, 1, .1], - units='deg') - pytest.raises(ValueError, visual.ProgressBar, ec, [0, 0, 1, .1], - colors=['w']) + pytest.raises(ValueError, visual.ProgressBar, ec, [0, 0, 1, 0.1], units="deg") + pytest.raises(ValueError, visual.ProgressBar, ec, [0, 0, 1, 0.1], colors=["w"]) pytest.raises(ValueError, bar.update_bar, 500) @@ -96,21 +108,21 @@ def test_visuals(hide_window): def test_video(hide_window): """Test EC video methods.""" std_kwargs.update(dict(window_size=(640, 480))) - video_path = fetch_data_file('video/example-video.mp4') - with ExperimentController('test', **std_kwargs) as ec: + video_path = fetch_data_file("video/example-video.mp4") + with ExperimentController("test", **std_kwargs) as ec: ec.load_video(video_path) ec.video.play() pytest.raises(ValueError, ec.video.set_pos, [1, 2, 3]) - pytest.raises(ValueError, ec.video.set_scale, 'foo') + pytest.raises(ValueError, ec.video.set_scale, "foo") pytest.raises(ValueError, ec.video.set_scale, -1) ec.wait_secs(0.1) ec.video.set_visible(False) ec.wait_secs(0.1) ec.video.set_visible(True) - ec.video.set_scale('fill') - ec.video.set_scale('fit') - ec.video.set_scale('0.5') - ec.video.set_pos(pos=(0.1, 0), units='norm') + ec.video.set_scale("fill") + ec.video.set_scale("fit") + ec.video.set_scale("0.5") + ec.video.set_pos(pos=(0.1, 0), units="norm") ec.video.pause() ec.video.draw() ec.delete_video() diff --git a/ignore_words.txt b/ignore_words.txt index 2f138a8c..abef3133 100644 --- a/ignore_words.txt +++ b/ignore_words.txt @@ -6,3 +6,5 @@ sinc fied hist bu +master +blacklist diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..b292de58 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,124 @@ +[tool.codespell] +ignore-words = "ignore_words.txt" +builtin = "clear,rare,informal,names,usage" +skip = "doc/references.bib" + +[tool.ruff] +exclude = ["__init__.py"] + +[tool.ruff.lint] +select = ["A", "B006", "D", "E", "F", "I", "W", "UP"] # , "UP031"] +ignore = [ + "D100", # Missing docstring in public module + "D104", # Missing docstring in public package + "D400", # First line should end with a period + "D401", # First line should be in imperative mood + "D413", # Missing blank line after last section + "UP031", # Use format specifiers instead of percent format + "UP030", # Use implicit references for positional format fields +] + +[tool.ruff.lint.pydocstyle] +convention = "numpy" +ignore-decorators = [ + "property", + "setter", + "mne.utils.copy_function_doc_to_method_doc", + "mne.utils.copy_doc", + "mne.utils.deprecated", +] + +[tool.ruff.lint.per-file-ignores] +"examples/**.py" = [ + "D205", # 1 blank line required between summary line and description +] + +[tool.pytest.ini_options] +# -r f (failed), E (error), s (skipped), x (xfail), X (xpassed), w (warnings) +# don't put in xfail for pytest 8.0+ because then it prints the tracebacks, +# which look like real errors +addopts = """--durations=20 --doctest-modules -rfEXs --cov-report= --tb=short \ + --cov-branch --doctest-ignore-import-errors --junit-xml=junit-results.xml \ + --ignore=doc --ignore=examples --ignore=tools \ + --color=yes --capture=sys""" +junit_family = "xunit2" +# Set this pretty low to ensure we do not by default add really long tests, +# or make changes that make things a lot slower +timeout = 5 +usefixtures = "matplotlib_config" +# Once SciPy updates not to have non-integer and non-tuple errors (1.2.0) we +# should remove them from here. +# This list should also be considered alongside reset_warnings in doc/conf.py +filterwarnings = ''' + error:: + ignore::ImportWarning + ignore:TDT is in dummy mode:UserWarning + ignore:generator 'ZipRunIterator.ranges' raised StopIteration:DeprecationWarning + ignore:size changed:RuntimeWarning + ignore:Using or importing the ABCs:DeprecationWarning + ignore:joblib not installed:RuntimeWarning + ignore:Matplotlib is building the font cache using fc-list:UserWarning + ignore:.*clock has been deprecated.*:DeprecationWarning + ignore:the imp module is deprecated.*:DeprecationWarning + ignore:.*eos_action is deprecated.*:DeprecationWarning + ignore:.*Vertex attribute shorthand.*: + ignore:.*ufunc size changed.*:RuntimeWarning + ignore:.*doc-files.*: + ignore:.*include is ignored because.*: + always:.*unclosed file.*:ResourceWarning + always:.*may indicate binary incompatibility.*: + ignore:.*Cannot change thread mode after it is set.*:UserWarning + ignore:.*distutils Version classes are deprecated.*:DeprecationWarning + ignore:.*distutils\.sysconfig module is deprecated.*:DeprecationWarning + ignore:.*isSet\(\) is deprecated.*:DeprecationWarning + ignore:`product` is deprecated as of NumPy.*:DeprecationWarning + ignore:Invalid dash-separated options.*: + always:.*Exception ignored in.*__del__.*: +''' + +[tool.rstcheck] +report_level = "WARNING" +ignore_roles = [ + "attr", + "class", + "doc", + "eq", + "exc", + "file", + "footcite", + "footcite:t", + "func", + "gh", + "kbd", + "meth", + "mod", + "newcontrib", + "py:mod", + "py:obj", + "obj", + "ref", + "samp", + "term", +] +ignore_directives = [ + "autoclass", + "autofunction", + "automodule", + "autosummary", + "bibliography", + "cssclass", + "currentmodule", + "dropdown", + "footbibliography", + "glossary", + "graphviz", + "grid", + "highlight", + "minigallery", + "tabularcolumns", + "toctree", + "rst-class", + "tab-set", + "towncrier-draft-entries", +] +ignore_messages = "^.*(Unknown target name|Undefined substitution referenced)[^`]*$" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 97ef076c..00000000 --- a/setup.cfg +++ /dev/null @@ -1,67 +0,0 @@ -[aliases] -release = egg_info -RDb '' -# Make sure the sphinx docs are built each time we do a dist. -# bdist = build_sphinx bdist -# sdist = build_sphinx sdist -# Make sure a zip file is created each time we build the sphinx docs -# build_sphinx = generate_help build_sphinx zip_help -# Make sure the docs are uploaded when we do an upload -# upload = upload upload_help - -[egg_info] -# tag_build = .dev - -[bdist_rpm] -doc-files = doc - -[tool:pytest] -addopts = - --durations=20 --doctest-modules -ra --cov-report= --tb=short - --doctest-ignore-import-errors --junit-xml=junit-results.xml - --ignore=examples --ignore=tutorials --ignore=doc --ignore=make - --capture=sys -usefixtures = matplotlib_config -junit_family = xunit2 -# Set this pretty low to ensure we do not by default add really long tests, -# or make changes that make things a lot slower -timeout = 5 -# Once SciPy updates not to have non-integer and non-tuple errors (1.2.0) we -# should remove them from here. -# This list should also be considered alongside reset_warnings in doc/conf.py -filterwarnings = - error:: - ignore::ImportWarning - ignore:TDT is in dummy mode:UserWarning - ignore:generator 'ZipRunIterator.ranges' raised StopIteration:DeprecationWarning - ignore:size changed:RuntimeWarning - ignore:Using or importing the ABCs:DeprecationWarning - ignore:joblib not installed:RuntimeWarning - ignore:Matplotlib is building the font cache using fc-list:UserWarning - ignore:.*clock has been deprecated.*:DeprecationWarning - ignore:the imp module is deprecated.*:DeprecationWarning - ignore:.*eos_action is deprecated.*:DeprecationWarning - ignore:.*Vertex attribute shorthand.*: - ignore:.*ufunc size changed.*:RuntimeWarning - ignore:.*doc-files.*: - ignore:.*include is ignored because.*: - always:.*unclosed file.*:ResourceWarning - always:.*may indicate binary incompatibility.*: - ignore:.*Cannot change thread mode after it is set.*:UserWarning - ignore:.*distutils Version classes are deprecated.*:DeprecationWarning - ignore:.*distutils\.sysconfig module is deprecated.*:DeprecationWarning - ignore:.*isSet\(\) is deprecated.*:DeprecationWarning - ignore:`product` is deprecated as of NumPy.*:DeprecationWarning - ignore:Invalid dash-separated options.*: - always:.*Exception ignored in.*__del__.*: - -[flake8] -exclude = __init__.py,decorator.py,ndarraysource.py -ignore = E226,E241,E242,E265,W504 - -[pydocstyle] -convention = pep257 -match_dir = ^(?!\.|_externals|doc|examples).*$ -match = (?!tests/__init__\.py|fixes).*\.py -add-ignore = D100,D104,D107,D413,D105,D200,D205,D400,D401 # eventually D105,D200,D205,D400,D401 should be used -add-select = D214,D215,D404,D405,D406,D407,D408,D409,D410,D411 -ignore-decorators = ^(property|.*setter).* diff --git a/setup.py b/setup.py index a60b8d45..40d8f183 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ # we are using a setuptools namespace import setuptools # noqa, analysis:ignore -from numpy.distutils.core import setup +from setuptools import setup descr = """Experiment controller functions.""" @@ -80,7 +80,15 @@ def setup_package(script_args=None): download_url=DOWNLOAD_URL, long_description=long_description, python_requires=">=3.8", - install_requires=["packaging", "numpy", "scipy", "matplotlib", "pillow"], # noqa + install_requires=[ + "packaging", + "numpy", + "scipy", + "matplotlib", + "pillow", + "h5io", + "decorator", + ], extras_require={ "test": ["pytest", "pytest-cov", "pytest-timeout"], }, diff --git a/make/get_video.ps1 b/tools/get_video.ps1 similarity index 100% rename from make/get_video.ps1 rename to tools/get_video.ps1 From 3f9937b62a1c567f6f485d8182d4ed7cc327c8be Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Jun 2024 19:54:19 +0000 Subject: [PATCH 10/11] Build(deps): Bump the actions group with 2 updates (#452) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Eric Larson --- .git-blame-ignore-revs | 1 + .github/workflows/tests.yml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..32db017b --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1 @@ +c7c1b18440968e2def388dff25118e13fe3c3b9a # ruff format diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0a07f692..a684dc71 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -40,7 +40,7 @@ jobs: kind: 'old' python: '3.8' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: LABSN/sound-ci-helpers@v1 - uses: pyvista/setup-headless-display-action@main with: @@ -68,7 +68,7 @@ jobs: echo "Setting env vars for macOS" fi name: Set env vars - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} if: matrix.kind != 'conda' From 8c67580695fba731fbee7f290925752b91487cf1 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 24 Jun 2024 16:01:04 -0400 Subject: [PATCH 11/11] FIX: Fix bug with video playback (#445) --- expyfun/visual/_visual.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/expyfun/visual/_visual.py b/expyfun/visual/_visual.py index 496fde5e..669decb3 100644 --- a/expyfun/visual/_visual.py +++ b/expyfun/visual/_visual.py @@ -1406,12 +1406,17 @@ def _draw(self): def draw(self): """Draw the video texture to the screen buffer.""" - self._player.update_texture() - # detect end-of-stream to prevent pyglet from hanging: - if not self._eos: - if self._visible: + done = False + if self._player.source is None: + done = True + if not done: + self._player.update_texture() + # detect end-of-stream to prevent pyglet from hanging: + if self._eos: + done = True + elif self._visible: self._draw() - else: + if done: self._finished = True self.pause() self._ec.check_force_quit() @@ -1448,9 +1453,10 @@ def _eos_old(self): ) def _eos_new(self): + done = self._player.source is None ts = self._source.get_next_video_timestamp() dur = self._source._duration - return ts is None or ts >= dur + return done or ts is None or ts >= dur @property def playing(self):