diff --git a/docs/source/api/iohub/device/default_yaml_configs/default_pupil_core_eyetracker.yaml b/docs/source/api/iohub/device/default_yaml_configs/default_pupil_core_eyetracker.yaml index 000cd8afb8..70e617940b 100644 --- a/docs/source/api/iohub/device/default_yaml_configs/default_pupil_core_eyetracker.yaml +++ b/docs/source/api/iohub/device/default_yaml_configs/default_pupil_core_eyetracker.yaml @@ -56,7 +56,6 @@ eyetracker.hw.pupil_labs.pupil_core.EyeTracker: port: 50020 timeout_ms: 1000 pupil_capture_recording: - enabled: True location: Null # Use Pupil Capture default recording location # Subscribe to pupil data only, does not require calibration or surface setup pupillometry_only: False diff --git a/docs/source/api/iohub/device/default_yaml_configs/default_pupil_neon_eyetracker.yaml b/docs/source/api/iohub/device/default_yaml_configs/default_pupil_neon_eyetracker.yaml index 512a4acf40..12f26cdf77 100644 --- a/docs/source/api/iohub/device/default_yaml_configs/default_pupil_neon_eyetracker.yaml +++ b/docs/source/api/iohub/device/default_yaml_configs/default_pupil_neon_eyetracker.yaml @@ -53,5 +53,4 @@ eyetracker.hw.pupil_labs.neon.EyeTracker: runtime_settings: companion_address: neon.local companion_port: 8080 - recording_enabled: True camera_calibration: scene_camera.json diff --git a/psychopy/app/Resources/routine_templates/Basic.psyexp b/psychopy/app/Resources/routine_templates/Basic.psyexp index 2b97aa9011..490de8cffc 100644 --- a/psychopy/app/Resources/routine_templates/Basic.psyexp +++ b/psychopy/app/Resources/routine_templates/Basic.psyexp @@ -53,7 +53,6 @@ - diff --git a/psychopy/app/Resources/routine_templates/Misc.psyexp b/psychopy/app/Resources/routine_templates/Misc.psyexp index b15fbf2aad..306c4ae8a2 100644 --- a/psychopy/app/Resources/routine_templates/Misc.psyexp +++ b/psychopy/app/Resources/routine_templates/Misc.psyexp @@ -53,7 +53,6 @@ - diff --git a/psychopy/app/Resources/routine_templates/Online.psyexp b/psychopy/app/Resources/routine_templates/Online.psyexp index f00f24cbd9..c1b0c72163 100644 --- a/psychopy/app/Resources/routine_templates/Online.psyexp +++ b/psychopy/app/Resources/routine_templates/Online.psyexp @@ -59,9 +59,7 @@ - - diff --git a/psychopy/app/Resources/routine_templates/Trials.psyexp b/psychopy/app/Resources/routine_templates/Trials.psyexp index a13335d457..8fe50f57ed 100644 --- a/psychopy/app/Resources/routine_templates/Trials.psyexp +++ b/psychopy/app/Resources/routine_templates/Trials.psyexp @@ -53,7 +53,6 @@ - diff --git a/psychopy/experiment/components/__init__.py b/psychopy/experiment/components/__init__.py index 06ff89e661..6ae87cfe0c 100644 --- a/psychopy/experiment/components/__init__.py +++ b/psychopy/experiment/components/__init__.py @@ -64,11 +64,6 @@ class name. Usually, this function is called by the plugin system. The user # check type and attributes of the class if not issubclass(compClass, (BaseComponent, BaseVisualComponent)): - logging.warning( - "Component `{}` does not appear to be a subclass of " - "`psychopy.experiment.components._base.BaseComponent`. This will be skipped." - .format(compName) - ) return elif not hasattr(compClass, 'categories'): logging.warning( diff --git a/psychopy/experiment/components/settings/__init__.py b/psychopy/experiment/components/settings/__init__.py index 8c605c4a8a..d5f7294d3b 100644 --- a/psychopy/experiment/components/settings/__init__.py +++ b/psychopy/experiment/components/settings/__init__.py @@ -114,11 +114,9 @@ def __init__( plPupilRemoteAddress="127.0.0.1", plPupilRemotePort=50020, plPupilRemoteTimeoutMs=1000, - plPupilCaptureRecordingEnabled=True, plPupilCaptureRecordingLocation="", plCompanionAddress="neon.local", plCompanionPort=8080, - plCompanionRecordingEnabled=True, ecSampleRate='default', keyboardBackend="ioHub", filename=None, exportHTML='on Sync', @@ -541,8 +539,8 @@ def getVersions(): "Tobii Technology": ["tbModel", "tbLicenseFile", "tbSerialNo", "tbSampleRate"], "Pupil Labs": ["plPupillometryOnly", "plSurfaceName", "plConfidenceThreshold", "plPupilRemoteAddress", "plPupilRemotePort", "plPupilRemoteTimeoutMs", - "plPupilCaptureRecordingEnabled", "plPupilCaptureRecordingLocation"], - "Pupil Labs (Neon)": ["plCompanionAddress", "plCompanionPort", "plCompanionRecordingEnabled"], + "plPupilCaptureRecordingLocation"], + "Pupil Labs (Neon)": ["plCompanionAddress", "plCompanionPort"], "EyeLogic": ["ecSampleRate"], } for tracker in trackerParams: @@ -734,11 +732,6 @@ def getVersions(): hint=_translate("Pupil remote timeout (ms)"), label=_translate("Pupil remote timeout (ms)"), categ="Eyetracking" ) - self.params['plPupilCaptureRecordingEnabled'] = Param( - plPupilCaptureRecordingEnabled, valType='bool', inputType="bool", - hint=_translate("Pupil capture recording enabled"), - label=_translate("Pupil capture recording enabled"), categ="Eyetracking" - ) self.params['plPupilCaptureRecordingLocation'] = Param( plPupilCaptureRecordingLocation, valType='str', inputType="single", hint=_translate("Pupil capture recording location"), @@ -754,11 +747,6 @@ def getVersions(): hint=_translate("Companion port"), label=_translate("Companion port"), categ="Eyetracking" ) - self.params['plCompanionRecordingEnabled'] = Param( - plCompanionRecordingEnabled, valType='bool', inputType="bool", - hint=_translate("Recording enabled"), - label=_translate("Recording enabled"), categ="Eyetracking" - ) # EyeLogic self.params['ecSampleRate'] = Param( @@ -1615,7 +1603,6 @@ def writeDevicesCode(self, buff): # Define runtime_settings > pupil_capture_recording dict code = ( - "'enabled': %(plPupilCaptureRecordingEnabled)s,\n" "'location': %(plPupilCaptureRecordingLocation)s,\n" ) buff.writeIndentedLines(code % inits) @@ -1646,7 +1633,6 @@ def writeDevicesCode(self, buff): code = ( "'companion_address': %(plCompanionAddress)s,\n" "'companion_port': %(plCompanionPort)s,\n" - "'recording_enabled': %(plCompanionRecordingEnabled)s,\n" ) buff.writeIndentedLines(code % inits) diff --git a/psychopy/experiment/components/sound/__init__.py b/psychopy/experiment/components/sound/__init__.py index dd8aad63b2..21589f2966 100644 --- a/psychopy/experiment/components/sound/__init__.py +++ b/psychopy/experiment/components/sound/__init__.py @@ -112,7 +112,7 @@ def getSpeakerValues(): return vals self.params['speakerIndex'] = Param( - speakerIndex, valType="code", inputType="choice", categ="Device", + speakerIndex, valType="str", inputType="choice", categ="Device", allowedVals=getSpeakerValues, allowedLabels=getSpeakerLabels, hint=_translate( diff --git a/psychopy/hardware/crs/bits.py b/psychopy/hardware/crs/bits.py index 24366a96be..54a7686027 100644 --- a/psychopy/hardware/crs/bits.py +++ b/psychopy/hardware/crs/bits.py @@ -1,34 +1,33 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -# Part of the PsychoPy library -# Copyright (C) 2002-2018 Jonathan Peirce (C) 2019-2024 Open Science Tools Ltd. -# Distributed under the terms of the GNU General Public License (GPL). - -# Acknowledgements: -# This code was initially written by Jon Peirce. -# with substantial additions by Andrew Schofield -# CRS Ltd provided support as needed. -# Shader code for mono++ and color++ modes was based on code in Psychtoolbox -# (Kleiner) but does not actually use that code directly - -import psychopy.logging as logging - -try: - from psychopy_crs.bits import ( - BitsSharp, - BitsPlusPlus, - DisplayPlusPlus, - DisplayPlusPlusTouch) -except (ModuleNotFoundError, ImportError): - logging.error( - "Support for Cambridge Research Systems Bits#, Bits++, Display++ and " - "Display++ Touch hardware is not available this session. Please " - "install `psychopy-crs` and restart the session to enable support.") -except Exception as e: - logging.error( - "Error encountered while loading `psychopy-crs`. Check logs for more " - "information.") - -if __name__ == "__main__": +from psychopy.tools.pkgtools import PluginStub + + +class BitsSharp( + PluginStub, + plugin="psychopy-crs", + doclink="https://psychopy.github.io/psychopy-crs/coder/bits/#psychopy_crs.bits.BitsSharp" +): + pass + + +class BitsPlusPlus( + PluginStub, + plugin="psychopy-crs", + doclink="https://psychopy.github.io/psychopy-crs/coder/bits/#psychopy_crs.bits.BitsPlusPlus" +): pass + + +class DisplayPlusPlus( + PluginStub, + plugin="psychopy-crs", + doclink="https://psychopy.github.io/psychopy-crs/coder/bits/#psychopy_crs.bits.DisplayPlusPlus" +): + pass + + +class DisplayPlusPlusTouch( + PluginStub, + plugin="psychopy-crs", + doclink="https://psychopy.github.io/psychopy-crs/coder/bits/#psychopy_crs.bits.DisplayPlusPlusTouch" +): + pass \ No newline at end of file diff --git a/psychopy/hardware/crs/colorcal.py b/psychopy/hardware/crs/colorcal.py index 1c47fd1643..b818d335bd 100644 --- a/psychopy/hardware/crs/colorcal.py +++ b/psychopy/hardware/crs/colorcal.py @@ -9,13 +9,9 @@ from psychopy.tools.pkgtools import PluginStub -class ColorCAL(PluginStub, plugin="psychopy-crs", doclink="https://psychopy.github.io/psychopy-crs/coder/ColorCAL"): +class ColorCAL( + PluginStub, + plugin="psychopy-crs", + doclink="https://psychopy.github.io/psychopy-crs/coder/ColorCAL" +): pass - - -# Monkey-patch our metadata into CRS class if missing required attributes -if not hasattr(ColorCAL, "longName"): - setattr(ColorCAL, "longName", "CRS ColorCAL") - -if not hasattr(ColorCAL, "driverFor"): - setattr(ColorCAL, "driverFor", ["colorcal"]) diff --git a/psychopy/hardware/crs/optical.py b/psychopy/hardware/crs/optical.py index 47f68c6574..8d009eb064 100644 --- a/psychopy/hardware/crs/optical.py +++ b/psychopy/hardware/crs/optical.py @@ -24,13 +24,9 @@ from psychopy.tools.pkgtools import PluginStub -class OptiCAL(PluginStub, plugin="psychopy-crs", doclink="https://psychopy.github.io/psychopy-crs/coder/OptiCAL"): +class OptiCAL( + PluginStub, + plugin="psychopy-crs", + doclink="https://psychopy.github.io/psychopy-crs/coder/OptiCAL" +): pass - - -# Monkey-patch our metadata into CRS class if missing required attributes -if not hasattr(OptiCAL, "longName"): - setattr(OptiCAL, "longName", "CRS OptiCal") - -if not hasattr(OptiCAL, "driverFor"): - setattr(OptiCAL, "driverFor", ["optical"]) diff --git a/psychopy/hardware/crs/shaders.py b/psychopy/hardware/crs/shaders.py index a25e398285..453a5c8a65 100644 --- a/psychopy/hardware/crs/shaders.py +++ b/psychopy/hardware/crs/shaders.py @@ -12,10 +12,21 @@ # (Mario Kleiner) but does not use that code directly # It is, for example, Mario's idea to add the 0.01 to avoid rounding issues -try: - from psychopy_crs.shaders import bitsMonoModeFrag, bitsColorModeFrag -except Exception: + +from psychopy.tools.pkgtools import PluginStub + + +class bitsMonoModeFrag( + PluginStub, + plugin="psychopy-crs", + doclink="https://psychopy.github.io/psychopy-crs/coder/shaders" +): pass -if __name__ == "__main__": + +class bitsColorModeFrag( + PluginStub, + plugin="psychopy-crs", + doclink="https://psychopy.github.io/psychopy-crs/coder/shaders" +): pass diff --git a/psychopy/hardware/emulator.py b/psychopy/hardware/emulator.py index dbbca84ddb..846bf6fdc9 100644 --- a/psychopy/hardware/emulator.py +++ b/psychopy/hardware/emulator.py @@ -36,5 +36,12 @@ class ResponseEmulator( pass +class launchScan( + PluginStub, + plugin="psychopy-mri-emulator" +): + pass + + if __name__ == "__main__": pass diff --git a/psychopy/hardware/microphone.py b/psychopy/hardware/microphone.py index 372425bd6c..d494ea4e21 100644 --- a/psychopy/hardware/microphone.py +++ b/psychopy/hardware/microphone.py @@ -191,8 +191,11 @@ def __init__(self, if isinstance(device, MicrophoneDevice): self._device = device._device else: - raise KeyError( - f"Could not find MicrophoneDevice named {index}" + # if not found, find best match + self._device = self.findBestDevice( + index=index, + sampleRateHz=sampleRateHz, + channels=channels ) else: # get best match @@ -337,11 +340,11 @@ def findBestDevice(self, index, sampleRateHz, channels): # iterate through device profiles for profile in self.getDevices(): # if same index, keep as fallback - if profile.deviceIndex == index: + if index in (profile.deviceIndex, profile.deviceName): fallbackDevice = profile # if same everything, we got it! if all(( - profile.deviceIndex == index, + index in (profile.deviceIndex, profile.deviceName), profile.defaultSampleRate == sampleRateHz, profile.inputChannels == channels, )): @@ -353,6 +356,7 @@ def findBestDevice(self, index, sampleRateHz, channels): f"Could not find exact match for specified parameters (index={index}, sampleRateHz=" f"{sampleRateHz}, channels={channels}), falling back to best approximation (" f"index={fallbackDevice.deviceIndex}, " + f"name={fallbackDevice.deviceName}," f"sampleRateHz={fallbackDevice.defaultSampleRate}, " f"channels={fallbackDevice.inputChannels})" ) @@ -391,7 +395,7 @@ def isSameDevice(self, other): # if the other object is the wrong type or doesn't have an index, it's not this return False - return self.index == index + return index in (self.index, self._device.deviceName) @staticmethod def getDevices(): @@ -429,9 +433,13 @@ def getDevices(): def getAvailableDevices(): devices = [] for profile in st.getAudioCaptureDevices(): + # get index as a name if possible + index = profile.get('device_name', None) + if index is None: + index = profile.get('index', None) device = { 'deviceName': profile.get('device_name', "Unknown Microphone"), - 'index': profile.get('index', None), + 'index': index, 'sampleRateHz': profile.get('defaultSampleRate', None), 'channels': profile.get('inputChannels', None), } diff --git a/psychopy/hardware/photodiode.py b/psychopy/hardware/photodiode.py index 07e9fb62ff..df3ba2ec55 100644 --- a/psychopy/hardware/photodiode.py +++ b/psychopy/hardware/photodiode.py @@ -155,7 +155,7 @@ def findChannels(self, win): return channels - def findPhotodiode(self, win, channel=None): + def findPhotodiode(self, win, channel=None, retryLimit=5): """ Draws rectangles on the screen and records photodiode responses to recursively find the location of the diode. @@ -271,32 +271,46 @@ def scanQuadrants(responsive=False): # if none of these have returned, rect is too small to cover the whole photodiode, so return return responsive - # reset state - self.state = [None] * self.channels - self.dispatchMessages() - self.clearResponses() - # recursively shrink rect around the photodiode - responsive = scanQuadrants() - # if cancelled, warn and continue - if responsive is None: - logging.warn( - "`findPhotodiode` procedure cancelled by user." - ) - return ( - layout.Position(self.pos, units="norm", win=win), - layout.Position(self.size, units="norm", win=win), - ) - # if we didn't get any responses at all, prompt to try again - if not responsive: + def handleNonResponse(label, rect, timeout=5): + # skip if retry limit hit + if retryLimit <= 0: + return None + # start a countdown + timer = core.CountdownTimer(start=timeout) # set label text to alert user - label.text = ( + msg = ( "Received no responses from photodiode during `findPhotodiode`. Photodiode may not " "be connected or may be configured incorrectly.\n" "\n" - "To continue, use the arrow keys to move the photodiode patch and use the " - "plus/minus keys to resize it.\n" - "\n" - "Press ENTER when finished." + "To manually specify the photodiode's position, press ENTER. To quit, press " + "ESCAPE. Otherwise, will retry in {:.0f}s\n" + ) + label.foreColor = "red" + # start a frame loop until they press enter + keys = [] + while timer.getTime() > 0 and not keys: + # get keys + keys = kb.getKeys() + # skip if escape pressed + if "escape" in keys: + return None + # specify manually if return pressed + if "return" in keys: + return specifyManually(label=label, rect=rect) + # format label + label.text = msg.format(timer.getTime()) + # show label and square + label.draw() + # flip + win.flip() + # if we timed out, retry whole find procedure + return self.findPhotodiode(win, channel=channel, retryLimit=retryLimit-1) + + def specifyManually(label, rect): + # set label text to alert user + label.text = ( + "Use the arrow keys to move the photodiode patch and use the plus/minus keys to " + "resize it. Press ENTER when finished, or press ESCAPE to quit.\n" ) label.foreColor = "red" # revert to defaults @@ -306,12 +320,18 @@ def scanQuadrants(responsive=False): # start a frame loop until they press enter keys = [] res = 0.05 - while "return" not in keys: + while "return" not in keys and "escape" not in keys: # get keys keys = kb.getKeys() # skip if escape pressed if "escape" in keys: return None + # finish if return pressed + if "return" in keys: + return ( + layout.Position(self.pos, units="norm", win=win), + layout.Position(self.size, units="norm", win=win), + ) # move rect according to arrow keys pos = list(rect.pos) if "left" in keys: @@ -335,13 +355,25 @@ def scanQuadrants(responsive=False): rect.draw() # flip win.flip() - # wait for a keypress - kb.waitKeys() - # return defaults + + # reset state + self.state = [None] * self.channels + self.dispatchMessages() + self.clearResponses() + # recursively shrink rect around the photodiode + responsive = scanQuadrants() + # if cancelled, warn and continue + if responsive is None: + logging.warn( + "`findPhotodiode` procedure cancelled by user." + ) return ( layout.Position(self.pos, units="norm", win=win), layout.Position(self.size, units="norm", win=win), ) + # if we didn't get any responses at all, prompt to try again + if not responsive: + handleNonResponse(label=label, rect=rect) # clear all the events created by this process self.state = [None] * self.channels self.dispatchMessages() diff --git a/psychopy/hardware/speaker.py b/psychopy/hardware/speaker.py index 45a8c4c4f8..94449b29f1 100644 --- a/psychopy/hardware/speaker.py +++ b/psychopy/hardware/speaker.py @@ -1,37 +1,50 @@ -from psychopy.hardware import BaseDevice +from psychopy.hardware import BaseDevice, DeviceManager from psychopy.sound import setDevice, getDevices, backend +from psychopy.tools import systemtools as st from psychopy import logging class SpeakerDevice(BaseDevice): def __init__(self, index): - profiles = self.getAvailableDevices() + # placeholder values, in case none set later + self.deviceName = None + self.index = None - # if index is default (-1), setup a default device index - if not isinstance(index, (int, float)) or index < 0: - index = profiles[0]['index'] # initialize as the first device + # try simple integerisation of index + if isinstance(index, str): + try: + index = int(index) + except ValueError: + pass + + # get all playback devices + profiles = st.getAudioPlaybackDevices() - # check if a default device is already set and update index + # if index is default, get default + if index in (-1, None): if hasattr(backend, 'defaultOutput'): + # check if a default device is already set and update index defaultDevice = backend.defaultOutput if isinstance(defaultDevice, (int, float)): # if a default device index is set, use it index = defaultDevice elif isinstance(defaultDevice, str): # if a default device is set by name, find it - for profile in profiles: - if profile['deviceName'] == defaultDevice: + for profile in profiles.values(): + if profile['name'] == defaultDevice: index = profile['index'] + else: + index = profiles[0]['index'] + + # find profile which matches index + for profile in profiles.values(): + if index in (profile['index'], profile['name']): + self.index = int(profile['index']) + self.deviceName = profile['name'] - available_index = [profile['index'] for profile in profiles] - if index < 0 or index not in available_index: + if self.index is None: logging.error("No speaker device found with index %d" % index) - # store index - self.index = index - # set global device (best we can do for now) - setDevice(index) - def isSameDevice(self, other): """ Determine whether this object represents the same physical speaker as a given other object. @@ -57,7 +70,7 @@ def isSameDevice(self, other): # if the other object is the wrong type or doesn't have an index, it's not this return False - return self.index == index + return index in (self.index, self.deviceName) def testDevice(self): """ @@ -78,11 +91,14 @@ def testDevice(self): @staticmethod def getAvailableDevices(): devices = [] - for profile in getDevices(kind="output").values(): + # get index as a name if possible + index = profile.get('DeviceName', None) + if index is None: + index = profile.get('DeviceIndex', None) device = { 'deviceName': profile.get('DeviceName', "Unknown Microphone"), - 'index': profile.get('DeviceIndex', None), + 'index': index, } devices.append(device) diff --git a/psychopy/liaison.py b/psychopy/liaison.py index 7bd8a841a1..94bf30e519 100644 --- a/psychopy/liaison.py +++ b/psychopy/liaison.py @@ -329,6 +329,10 @@ async def _processMessage(self, websocket, message): message : string the message sent by the client to the server, as a JSON string """ + # log message + self._logger.debug( + f"Liaison received message: {message}" + ) # decode the message: try: decodedMessage = json.loads(message) diff --git a/psychopy/sound/backend_ptb.py b/psychopy/sound/backend_ptb.py index 56ca350bf8..e9295e9ad5 100644 --- a/psychopy/sound/backend_ptb.py +++ b/psychopy/sound/backend_ptb.py @@ -127,6 +127,9 @@ class _StreamsDict(dict): use the instance `streams` rather than creating a new instance of this """ + def __init__(self, index): + # store device index + self.index = index def getStream(self, sampleRate, channels, blockSize): """Gets a stream of exact match or returns a new one @@ -186,11 +189,11 @@ def _getStream(self, sampleRate, channels, blockSize): # create new stream self[label] = _MasterStream(sampleRate, channels, blockSize, - device=defaultOutput) + device=self.index) return label, self[label] -streams = _StreamsDict() +devices = {} class _MasterStream(audio.Stream): @@ -351,8 +354,8 @@ def isFinished(self): def _getDefaultSampleRate(self): """Check what streams are open and use one of these""" - if len(streams): - return list(streams.values())[0].sampleRate + if len(devices.get(self.speaker.index, [])): + return list(devices[self.speaker.index].values())[0].sampleRate else: return 48000 # seems most widely supported @@ -604,17 +607,26 @@ def stream(self): """Read-only property returns the stream on which the sound will be played """ + # if no stream yet, make one if not self.streamLabel: + # if no streams for current device yet, make a StreamsDict for it + if self.speaker.index not in devices: + devices[self.speaker.index] = _StreamsDict(index=self.speaker.index) + # make stream try: - label, s = streams.getStream(sampleRate=self.sampleRate, - channels=self.channels, - blockSize=self.blockSize) + label, s = devices[self.speaker.index].getStream( + sampleRate=self.sampleRate, + channels=self.channels, + blockSize=self.blockSize + ) except SoundFormatError as err: # try to use something similar (e.g. mono->stereo) # then check we have an appropriate stream open - altern = streams._getSimilar(sampleRate=self.sampleRate, - channels=-1, - blockSize=-1) + altern = devices[self.speaker.index]._getSimilar( + sampleRate=self.sampleRate, + channels=-1, + blockSize=-1 + ) if altern is None: raise SoundFormatError(err) else: # safe to extract data @@ -625,7 +637,7 @@ def stream(self): self.blockSize = s.blockSize self.streamLabel = label - return streams[self.streamLabel] + return devices[self.speaker.index][self.streamLabel] def __del__(self): if self.track: diff --git a/psychopy/tests/test_hardware/test_CRS_BitsSharp.py b/psychopy/tests/test_hardware/test_CRS_BitsSharp.py deleted file mode 100644 index d435a794ae..0000000000 --- a/psychopy/tests/test_hardware/test_CRS_BitsSharp.py +++ /dev/null @@ -1,67 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Created on Thu May 8 10:46:41 2014 - -@author: jon.peirce -""" -import pytest -from psychopy import visual, core - - -def test_bitsSharp(): - try: - from psychopy.hardware.crs import BitsSharp - except (ModuleNotFoundError, ImportError): - return - - win = visual.Window(screen=0, fullscr=True, useFBO=True, autoLog=True) - win.setGamma(1.0) #make sure gfx card LUT is identity - #initialise BitsSharp - try: - bits = BitsSharp(win=win, mode='color++') - except ImportError: - pytest.skip("crs.BitsSharp: could not initialize. possible:\nfrom serial.tools import list_ports\n" - "ImportError: No module named tools") - - if not bits.OK: - win.close() - pytest.skip("No BitsSharp connected") - - print(bits.info) - - #switch to status screen (while keeping in mono 'mode') - bits.getVideoLine(lineN=1, nPixels=1) - core.wait(5) #wait for status mode to take effect - - #create a stimulus to check luminance values - screenSqr = visual.GratingStim(win,tex=None, mask=None, - size=2) - - print('\n up from zero:') - bit16 = (2.0 ** 16) - 1 - for frameN in range(5): - intensity = frameN / bit16 - screenSqr.color = intensity * 2 - 1 # psychopy is -1:1 - screenSqr.draw() - win.flip() - pixels = bits.getVideoLine(lineN=1, nPixels=2) - print(pixels[0], pixels[1], intensity) - - print('\n down from 1:') - for frameN in range(5): - intensity = 1 - (frameN / bit16) - screenSqr.color = intensity * 2 - 1 # psychopy is -1:1 - screenSqr.draw() - win.flip() - pixels = bits.getVideoLine(lineN=1, nPixels=2) - print(pixels[0], pixels[1], intensity) - - print('\n check the middle::') - for intensity in [0.5, 0.5 + (1 / bit16)]: - screenSqr.color = intensity * 2 - 1 # psychopy is -1:1 - screenSqr.draw() - win.flip() - pixels = bits.getVideoLine(lineN=1, nPixels=2) - print(pixels[0], pixels[1], intensity) - - bits.mode = "color++" #get out of status screen diff --git a/psychopy/tests/test_hardware/test_CRS_bitsShaders.py b/psychopy/tests/test_hardware/test_CRS_bitsShaders.py deleted file mode 100644 index ae299ddddb..0000000000 --- a/psychopy/tests/test_hardware/test_CRS_bitsShaders.py +++ /dev/null @@ -1,110 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Created on Mon Dec 15 15:22:48 2014 - -@author: lpzjwp -""" -from psychopy import visual -from psychopy.tools import systemtools -from psychopy.tests import skip_under_vm -import numpy as np - -try: - from PIL import Image -except ImportError: - import Image - - -array=np.array -#expectedVals = {'bits++':{}, 'mono++':{}, 'color++':{}} -expectedVals = { - 'color++': {1024: {'lowG': array([ 0, 64, 0, 192, 1, 64, 1, 192, 2, 64]), - 'highR': array([ 62, 192, 63, 64, 63, 192]), - 'lowR': array([ 0, 64, 0, 192, 1, 64, 1, 192, 2, 64]), - 'highG': array([ 62, 192, 63, 64, 63, 192])}, - 65535: {'lowG': array([0, 1, 0, 3, 0, 5, 0, 7, 0, 9]), - 'highR': array([ 0, 251, 0, 253, 0, 255]), - 'lowR': array([0, 1, 0, 3, 0, 5, 0, 7, 0, 9]), - 'highG': array([ 0, 251, 0, 253, 0, 255])}, - 255.0: {'lowG': array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), - 'highR': array([250, 251, 252, 253, 254, 255]), - 'lowR': array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), - 'highG': array([250, 251, 252, 253, 254, 255])}}, - 'mono++':{1024: {'lowG': array([ 0, 64, 128, 192, 0, 64, 128, 192, 0, 64]), - 'highR': array([62, 62, 62, 63, 63, 63]), - 'lowR': array([0, 0, 0, 0, 1, 1, 1, 1, 2, 2]), - 'highG': array([128, 192, 255, 64, 128, 192])}, - 65535: {'lowG': array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), - 'highR': array([0, 0, 0, 0, 0, 0]), - 'lowR': array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), - 'highG': array([250, 251, 252, 253, 254, 255])}, - 255.0: {'lowG': array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), - 'highR': array([250, 251, 252, 253, 254, 255]), - 'lowR': array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), - 'highG': array([250, 251, 252, 253, 254, 255])}}, - 'bits++': { - 1024: {'lowG': array([106, 136, 19, 25, 115, 68, 41, 159, 0, 0]), - 'highR': array([119, 118, 120, 119, 121, 120]), - 'lowR': array([ 36, 63, 8, 211, 3, 112, 56, 34, 0, 0]), - 'highG': array([119, 118, 120, 119, 121, 120])}, - 65535: {'lowG': array([106, 136, 19, 25, 115, 68, 41, 159, 0, 0]), - 'highR': array([119, 118, 120, 119, 121, 120]), - 'lowR': array([ 36, 63, 8, 211, 3, 112, 56, 34, 0, 0]), - 'highG': array([119, 118, 120, 119, 121, 120])}, - 255.0: {'lowG': array([106, 136, 19, 25, 115, 68, 41, 159, 0, 0]), - 'highR': array([119, 118, 120, 119, 121, 120]), - 'lowR': array([ 36, 63, 8, 211, 3, 112, 56, 34, 0, 0]), - 'highG': array([119, 118, 120, 119, 121, 120])}}} - -@skip_under_vm -def test_bitsShaders(): - try: - from psychopy.hardware.crs.bits import BitsSharp - except (ModuleNotFoundError, ImportError): - return - - win = visual.Window([1024, 768], fullscr=0, screen=1, useFBO=True, - autoLog=True) - - bits = BitsSharp(win, mode='bits++', noComms=True) - - # draw a ramp across the screenexpectedVals = range(256) - w, h = win.size - intended = list(range(256)) - testArrLums = np.resize(intended, - [256, 256]) / 127.5 - 1 # NB psychopy uses -1:1 - stim = visual.ImageStim(win, image=testArrLums, - size=[256, h], pos=[128 - w / 2, 0], units='pix', - ) - expected = np.repeat(intended, 3).reshape([-1, 3]) - - # stick something in the middle for fun! - gabor = visual.GratingStim(win, mask='gauss', sf=3, ori=45, contrast=0.5) - gabor.autoDraw = True - - #a dict of dicts for expected vals - for mode in ['bits++', 'mono++', 'color++']: - bits.mode=mode - for finalVal in [255.0, 1024, 65535]: - thisExpected = expectedVals[mode][finalVal] - - intended = np.linspace(0.0,1,256)*255.0/finalVal - stim.image = np.resize(intended,[256,256])*2-1 #NB psychopy uses -1:1 - - stim.draw() - #fr = np.array(win._getFrame(buffer='back').transpose(Image.ROTATE_270)) - win.flip() - fr = np.array(win._getFrame(buffer='front').transpose(Image.ROTATE_270)) - if not systemtools.isVM_CI(): - assert np.all(thisExpected['lowR'] == fr[0:10, -1, 0]) - assert np.all(thisExpected['lowG'] == fr[0:10, -1, 1]) - assert np.all(thisExpected['highR'] == fr[250:256, -1, 0]) - assert np.all(thisExpected['highG'] == fr[250:256, -1, 1]) - - print('R', repr(fr[0:10,-1,0]), repr(fr[250:256,-1,0])) - print('G', repr(fr[0:10,-1,1]), repr(fr[250:256,-1,0])) - #event.waitKeys() - - -if __name__=='__main__': - test_bitsShaders() diff --git a/psychopy/tests/test_hardware/test_photodiode.py b/psychopy/tests/test_hardware/test_photodiode.py new file mode 100644 index 0000000000..0e1421a2b5 --- /dev/null +++ b/psychopy/tests/test_hardware/test_photodiode.py @@ -0,0 +1,66 @@ +import pytest +from psychopy import core, visual +from psychopy.tests.utils import RUNNING_IN_VM +from psychopy.hardware.photodiode import BasePhotodiodeGroup, PhotodiodeResponse + + +class DummyPhotodiode(BasePhotodiodeGroup): + + def __init__(self, channels=1, threshold=None, pos=None, size=None, units=None): + # make a basic timer + self.timer = core.Clock() + # queue of messages that can be manually added + self.queue = [] + # initialise base + BasePhotodiodeGroup.__init__( + self, channels=channels, threshold=threshold, pos=pos, size=size, units=units + ) + + def dispatchMessages(self): + for msg in self.queue: + self.responses = self.parseMessage(msg) + + def parseMessage(self, message): + """ + + Parameters + ---------- + message : tuple[float, bool, int, float] + Raw message, in the format: + - float: Timestamp + - bool: True/False photodiode state + - int: Channel in question + + Returns + ------- + PhotodiodeResponse + Photodiode response + """ + # split message + t, value, channel = message + # make obj + return PhotodiodeResponse( + t, value, channel, threshold=self.threshold[channel] + ) + + def _setThreshold(self, threshold, channel): + self.threshold[channel] = threshold + + def resetTimer(self, clock=None): + self.timer.reset() + +class TestPhotodiode: + + def setup_class(self): + self.photodiode = DummyPhotodiode() + self.win = visual.Window() + + def test_handle_no_response(self): + """ + If no response (as will be the case here), should try n times and then give up. + """ + # this one takes a while and isn't all that helpful if you can't watch it, so skip under vm + if RUNNING_IN_VM: + pytest.skip() + # try to find the photodiode, knowing full well it'll get nothing as this is a dummy + self.photodiode.findPhotodiode(win=self.win, retryLimit=2) \ No newline at end of file diff --git a/psychopy/tools/pkgtools.py b/psychopy/tools/pkgtools.py index 92b6793684..1e33b21821 100644 --- a/psychopy/tools/pkgtools.py +++ b/psychopy/tools/pkgtools.py @@ -35,7 +35,7 @@ import site # On import we want to configure the user site-packages dir and add it to the -# import path. +# import path. # set user site-packages dir if os.environ.get('PSYCHOPYNOPACKAGES', '0') == '1': site.ENABLE_USER_SITE = True @@ -43,14 +43,14 @@ site.USER_BASE = None logging.debug( 'User site-packages dir set to: %s' % site.getusersitepackages()) - + # add paths from main plugins/packages (installed by plugins manager) site.addsitedir(prefs.paths['userPackages']) # user site-packages site.addsitedir(prefs.paths['userInclude']) # user include site.addsitedir(prefs.paths['packages']) # base package dir -if not site.USER_SITE in sys.path: - site.addsitedir(site.getusersitepackages()) +if site.USER_SITE not in sys.path: + site.addsitedir(site.getusersitepackages()) # cache list of packages to speed up checks _installedPackageCache = [] @@ -60,6 +60,10 @@ USER_PACKAGES_PATH = str(prefs.paths['userPackages']) +class PluginRequiredError(Exception): + pass + + class PluginStub: """ Class to handle classes which have moved out to plugins. @@ -82,22 +86,23 @@ def __init_subclass__(cls, plugin, doclink="https://plugins.psychopy.org/directo cls.__doc__ = ( "`{mro}` is now located within the `{plugin}` plugin. You can find the documentation for it `here <{doclink}>`_." ).format( - mro=cls.__mro__, + mro=cls.__module__, plugin=plugin, doclink=doclink ) - - def __call__(self, *args, **kwargs): + + + def __init__(self, *args, **kwargs): """ When initialised, rather than creating an object, will log an error. """ - raise NameError( + raise PluginRequiredError(( "Support for `{mro}` is not available this session. Please install " "`{plugin}` and restart the session to enable support." ).format( - mro=type(self).__mro__, + mro=type(self).__module__, plugin=self.plugin, - ) + )) def refreshPackages(): @@ -112,7 +117,7 @@ def refreshPackages(): _installedPackageCache.clear() _installedPackageNamesCache.clear() - + # iterate through installed packages in the user folder for dist in importlib.metadata.distributions(path=sys.path + [USER_PACKAGES_PATH]): # get name if in 3.8 @@ -186,11 +191,11 @@ def addDistribution(distPath): def installPackage( - package, - target=None, - upgrade=False, + package, + target=None, + upgrade=False, forceReinstall=False, - noDeps=False, + noDeps=False, awaited=True, outputCallback=None, terminateCallback=None, @@ -219,15 +224,15 @@ def installPackage( noDeps : bool Don't install dependencies if `True`. awaited : bool - If False, then use an asynchronous install process - this function will return right away + If False, then use an asynchronous install process - this function will return right away and the plugin install will happen in a different thread. outputCallback : function - Function to be called when any output text is received from the process performing the + Function to be called when any output text is received from the process performing the install. Not used if awaited=True. terminateCallback : function Function to be called when installation is finished. Not used if awaited=True. extra : dict - Extra information to be supplied to the install thread when installing asynchronously. + Extra information to be supplied to the install thread when installing asynchronously. Not used if awaited=True. Returns @@ -243,7 +248,7 @@ def installPackage( """ if target is None: target = prefs.paths['userPackages'] - + # convert extra to dict if extra is None: extra = {} @@ -285,6 +290,7 @@ def installPackage( cmd.append('--no-input') # do not prompt, we cannot accept input cmd.append('--no-color') # no color for console, not supported cmd.append('--no-warn-conflicts') # silence non-fatal errors + cmd.append('--disable-pip-version-check') # do not check for pip updates # get the environment for the subprocess env = os.environ.copy() @@ -350,7 +356,7 @@ def _getUserPackageTopLevels(): foundTopLevelDirs = dict() for foundDir in userPackageDirs: - if not foundDir.endswith('.dist-info'): + if not foundDir.endswith('.dist-info'): continue topLevelPath = os.path.join(userPackageDir, foundDir, 'top_level.txt') @@ -397,7 +403,7 @@ def _isUserPackage(package): distName = dist.name # get name userPackages.append(distName) - + return package in userPackages @@ -425,7 +431,7 @@ def _uninstallUserPackage(package): # string to use as stdout stdout = "" # take note of this function being run as if it was a command - cmd = f"python psychopy.tools.pkgtools._uninstallUserPackage(package)" + cmd = "python psychopy.tools.pkgtools._uninstallUserPackage(package)" userPackagePath = getUserPackagesPath() @@ -463,12 +469,12 @@ def _uninstallUserPackage(package): stdout += _translate( "Could not remove {absPath}, reason: {err}".format(absPath=absPath, err=err) ) - + # log success msg = 'Uninstalled package `{}`.'.format(package) logging.info(msg) stdout += msg + "\n" - + # return the return code and a dict of information from the console return True, { "cmd": cmd, @@ -476,6 +482,7 @@ def _uninstallUserPackage(package): "stderr": "" } + def uninstallPackage(package): """Uninstall a package from the current distribution. @@ -640,7 +647,7 @@ def getPypiInfo(packageName, silence=False): "Could not get info for package {}. Reason:\n" "\n" "{}" - ).format(packageName,err), style=wx.ICON_ERROR) + ).format(packageName, err), style=wx.ICON_ERROR) if not silence: dlg.ShowModal() return diff --git a/psychopy/visual/slider.py b/psychopy/visual/slider.py index 82aeed7c05..1dcee2ecd8 100644 --- a/psychopy/visual/slider.py +++ b/psychopy/visual/slider.py @@ -28,18 +28,17 @@ # Set to True to make borders visible for debugging debug = False - class Slider(MinimalStim, WindowMixin, ColorMixin): """A class for obtaining ratings, e.g., on a 1-to-7 or categorical scale. A simpler alternative to RatingScale, to be customised with code rather than with arguments. - A RatingScale instance is a re-usable visual object having a ``draw()`` + A Slider instance is a re-usable visual object having a ``draw()`` method, with customizable appearance and response options. ``draw()`` displays the rating scale, handles the subject's mouse or key responses, - and updates the display. When the subject accepts a selection, - ``.noResponse`` goes ``False`` (i.e., there is a response). + and updates the display. As soon as a rating is supplied, ``.rating`` + will go from ``None`` to selected item You can call the ``getRating()`` method anytime to get a rating, ``getRT()`` to get the decision time, or ``getHistory()`` to obtain @@ -87,56 +86,110 @@ def __init__(self, win : psychopy.visual.Window Into which the scale will be rendered - ticks : list or tuple + ticks : list or tuple, optional A set of values for tick locations. If given a list of numbers then these determine the locations of the ticks (the first and last determine the endpoints and the rest are spaced according to their values between these endpoints. - labels : a list or tuple + labels : a list or tuple, optional The text to go with each tick (or spaced evenly across the ticks). If you give 3 labels but 5 tick locations then the end and middle ticks will be given labels. If the labels can't be distributed across the ticks then an error will be raised. If you want an uneven distribution you should include a list matching the length of ticks but with some values set to None - - pos : XY pair (tuple, array or list) + + startValue : int or float, optional + The initial position of the marker on the slider. If not specified, + the marker will start at the mid-point of the scale. + + pos : tuple, list, or array, optional + The (x, y) position of the slider on the screen. size : w,h pair (tuple, array or list) The size for the scale defines the area taken up by the line and the ticks. This also controls whether the scale is horizontal or vertical. - units : the units to interpret the pos and size - - flip : bool - By default the labels will be below or left of the line. This - puts them above (or right) - + units : str, optional + The units to interpret the `pos` and `size` parameters. Can be any + of the standard PsychoPy units (e.g., 'pix', 'cm', 'norm'). + + flip : bool, optional + If `True`, the labels will be placed above (for horizontal sliders) + or to the right (for vertical sliders) of the slider line. Default + is `False`. + + ori : int or float, optional + The orientation of the slider in degrees. A value of 0 means no + rotation, positive values rotate the slider clockwise. + + style : str or list of str, optional + The style of the slider, e.g., 'rating', 'slider', 'radio'. Multiple + styles can be combined in a list. + + styleTweaks : list of str, optional + Additional styling tweaks, e.g., 'triangleMarker', 'labels45'. + granularity : int or float The smallest valid increments for the scale. 0 gives a continuous (e.g. "VAS") scale. 1 gives a traditional likert scale. Something like 0.1 gives a limited fine-grained scale. - labelColor / color : - Color of the labels according to the color space + readOnly : bool, optional + If `True`, the slider is displayed but does not accept input. + + labelColor : color, optional + The color of the labels in the specified color space. + + markerColor : color, optional + The color of the marker in the specified color space. + + lineColor : color, optional + The color of the slider line and ticks in the specified color space. + + colorSpace : str, optional + The color space for defining `labelColor`, `markerColor`, and + `lineColor` (e.g., 'rgb', 'rgb255', 'hex'). + + opacity : float, optional + The opacity of the slider, ranging from 0 (completely transparent) + to 1 (completely opaque). + + font : str, optional + The font used for the labels. + + depth : int, optional + The depth layer for rendering. Layers with lower numbers are rendered + first (behind). + + name : str, optional + An optional name for the slider, useful for logging. - markerColor / fillColor : - Color of the marker according to the color space + labelHeight : float, optional + The height of the label text. If `None`, a default value based on + the slider size is used. - lineColor / borderColor : - Color of the line and ticks according to the color space + labelWrapWidth : float, optional + The maximum width for text labels before wrapping. If `None`, labels + are not wrapped. - font : font name + autoDraw : bool, optional + If `True`, the slider will be automatically drawn every frame. - autodraw : + autoLog : bool, optional + If `True`, a log message is automatically generated each time the + slider is updated. This can be useful for debugging or analysis. - depth : + color : color, optional + Synonym for `labelColor`. - name : + fillColor : color, optional + Synonym for `markerColor`. - autoLog : + borderColor : color, optional + Synonym for `lineColor`. """ # what local vars are defined (these are the init params) for use by # __repr__