Repository: psychopy/psychopy

Issue:
QUEST intensity scaling should be removed
Currently, `data.QuestHandler.__init__()` takes the optional parameter `stepType`, which is later used to transform intensity values, as in:

``` python
        startVal = self._intensity2scale(startVal)
        startValSd = self._intensity2scale(startValSd)
```

where `_intensity2scale()` is defined as:

``` python
    def _intensity2scale(self, intensity):
        """returns the scaled intensity level based on value of self.stepType"""
        if self.stepType=='db':
            scaled_intensity = numpy.log10(intensity) * 20.0
        elif self.stepType=='log':
            scaled_intensity = numpy.log10(intensity)
        else:
            scaled_intensity = intensity
        return scaled_intensity
```

However, this interferes with the QUEST algorithm, which is implemented in `contrib.quest`, and already fully takes care of the stimulus selection. Jon also [mentioned](https://groups.google.com/d/msg/psychopy-users/5AsGnvLsPpU/wr1EPMnYJIYJ) something similar on the mailing list already.

The `stepType` argument should be removed. If a user wants to specify `startVal` and `startValSd` e.g. in terms of log intensities, they could easily do the calculation manually, i.e.,

``` python
QuestHandler(np.log10(startVal), np.log10(startValSd), ...)
```

QUEST will take care of the rest.

Also, currently `stopInterval`, `minVal` and `maxVal` are not transformed, so I assume the current implementation is incorrect for all but linear `stepType`s anyway.

Staircases in `MultiStairHandler` execute, but never 'finish'
It appears that, although the staircases called by `MultiStairCase` are recording all responses, they never actually transition into a `finished` state.

``` python
from __future__ import print_function
from psychopy.data import MultiStairHandler
import numpy as np

np.random.seed(1000)

if __name__ == '__main__':
    nTrials = 5  # This is going to yield 5 trials per staircase.

    conditions = [
        {'label': 'stim_01', 'startVal': 0.3, 'startValSd': 0.8,
         'minVal': 0, 'maxVal': 1, 'pThreshold': 0.5, 'gamma': 0.01,
         'delta': 0.01, 'grain': 0.01, 'nTrials': nTrials},
        {'label': 'stim_02', 'startVal': 0.3, 'startValSd': 0.8,
         'minVal': 0, 'maxVal': 1, 'pThreshold': 0.5, 'gamma': 0.01,
         'delta': 0.01, 'grain': 0.01, 'nTrials': nTrials},
        {'label': 'stim_03', 'startVal': 0.3, 'startValSd': 0.8,
         'minVal': 0, 'maxVal': 1, 'pThreshold': 0.5, 'gamma': 0.01,
         'delta': 0.01, 'grain': 0.01, 'nTrials': nTrials}
    ]

    stairs = MultiStairHandler(
            stairType='quest', method='random',
            conditions=conditions, nTrials=nTrials
    )

    responses = np.tile([1, 1, 1, 0, 0, 0], 3)[:15]

    for trial, intensity in enumerate(stairs):
        stairs.addResponse(responses[trialN])

    print(stairs.finished)
    print([s.finished for s in stairs.staircases])
    print([s.thisTrialN for s in stairs.staircases])
    print([len(s.data) for s in stairs.staircases])
```

Output:

``` python
False
[False, False, True]
[4, 4, 4]
[5, 5, 5]
```

Expected Output:

``` python
True
[True, True, True]
[4, 4, 4]
[5, 5, 5]
```



Discussion:

Resolving patch:
diff --git a/psychopy/data.py b/psychopy/data.py
--- a/psychopy/data.py
+++ b/psychopy/data.py
@@ -2480,7 +2480,8 @@ def __init__(self,
                  maxVal=None,
                  originPath=None,
                  name='',
-                 autoLog=True):
+                 autoLog=True,
+                 **kwargs):
         """
         :Parameters:
 
@@ -2531,12 +2532,16 @@ def __init__(self,
                 The largest legal value for the staircase, which can be used to prevent it
                 reaching impossible contrast values, for instance.
 
-        """
+            Additional keyword arguments will be ignored.
 
-        """
-        trialList: a simple list (or flat array) of trials.
+        :Notes:
+
+        The additional keyword arguments `**kwargs` might for example be
+        passed by the `MultiStairHandler`, which expects a `label` keyword
+        for each staircase. These parameters are to be ignored by the
+        StairHandler.
 
-            """
+        """
         self.name=name
         self.startVal=startVal
         self.nReversals=nReversals
@@ -3041,7 +3046,6 @@ def __init__(self,
                  nTrials=None,
                  stopInterval=None,
                  method='quantile',
-                 stepType='log',
                  beta=3.5,
                  delta=0.01,
                  gamma=0.5,
@@ -3053,7 +3057,8 @@ def __init__(self,
                  staircase=None,
                  originPath=None,
                  name='',
-                 autoLog=True):
+                 autoLog=True,
+                 **kwargs):
         """
         Typical values for pThreshold are:
             * 0.82 which is equivalent to a 3 up 1 down standard staircase
@@ -3090,10 +3095,6 @@ def __init__(self,
                 The method used to determine the next threshold to test. If you want to get a specific threshold
                 level at the end of your staircasing, please use the quantile, mean, and mode methods directly.
 
-            stepType: *'log'*, 'db', 'lin'
-                The type of steps that should be taken each time. 'db' and 'log' will transform your intensity levels
-                into decibels or log units and will move along the psychometric function with these values.
-
             beta: *3.5* or a number
                 Controls the steepness of the psychometric function.
 
@@ -3130,22 +3131,32 @@ def __init__(self,
                 give the quest algorithm more information if you have it. You can also call the
                 importData function directly.
 
-        """
+            Additional keyword arguments will be ignored.
 
-        # Initialize using parent class first
-        StairHandler.__init__(self, startVal, nTrials=nTrials, extraInfo=extraInfo, method=method,
-                                stepType=stepType, minVal=minVal, maxVal=maxVal, name=name, autoLog=autoLog)
+        :Notes:
+
+        The additional keyword arguments `**kwargs` might for example be
+        passed by the `MultiStairHandler`, which expects a `label` keyword
+        for each staircase. These parameters are to be ignored by the
+        StairHandler.
+
+        """
+        StairHandler.__init__(
+                self, startVal, nTrials=nTrials, extraInfo=extraInfo,
+                method=method, stepType='lin', minVal=minVal,
+                maxVal=maxVal, name=name, autoLog=autoLog
+        )
 
-        # Setup additional values
         self.stopInterval = stopInterval
 
-        # Transform startVal and startValSd based on stepType
-        startVal = self._intensity2scale(startVal)
-        startValSd = self._intensity2scale(startValSd)
+        startVal = startVal
+        startValSd = startValSd
         self._questNextIntensity = startVal
 
         # Create Quest object
-        self._quest = QuestObject(startVal, startValSd, pThreshold, beta, delta, gamma, grain, range)
+        self._quest = QuestObject(
+                startVal, startValSd, pThreshold, beta, delta, gamma,
+                grain=grain, range=range)
 
         # Import any old staircase data
         if staircase is not None:
@@ -3166,7 +3177,7 @@ def addResponse(self, result, intensity=None):
         if intensity is None:
             intensity = self._questNextIntensity
         else:
-            intensity = self._intensity2scale(intensity)
+            intensity = intensity
             # Update the intensity.
             #
             # During the first trial, self.intensities will be of length 0,
@@ -3181,7 +3192,11 @@ def addResponse(self, result, intensity=None):
         #add the current data to experiment if poss
         if self.getExp() != None:#update the experiment handler too
             self.getExp().addData(self.name+".response", result)
-        self.calculateNextIntensity()
+
+        self._checkFinished()
+        if not self.finished:
+            self.calculateNextIntensity()
+
     def importData(self, intensities, results):
         """import some data which wasn't previously given to the quest algorithm"""
         # NOT SURE ABOUT CLASS TO USE FOR RAISING ERROR
@@ -3202,7 +3217,7 @@ def calculateNextIntensity(self):
             self._nextIntensity = self.maxVal
         elif (self._nextIntensity < self.minVal) and self.minVal is not None:
             self._nextIntensity = self.minVal
-        self._questNextIntensity = self._intensity2scale(self._nextIntensity)
+        self._questNextIntensity = self._nextIntensity
     def _intensity(self):
         """assigns the next intensity level"""
         if self.method == 'mean':
@@ -3212,43 +3227,23 @@ def _intensity(self):
         elif self.method == 'quantile':
             self._questNextIntensity = self._quest.quantile()
         # else: maybe raise an error
-        self._nextIntensity = self._scale2intensity(self._questNextIntensity)
-
-    def _intensity2scale(self, intensity):
-        """returns the scaled intensity level based on value of self.stepType"""
-        if self.stepType=='db':
-            scaled_intensity = numpy.log10(intensity) * 20.0
-        elif self.stepType=='log':
-            scaled_intensity = numpy.log10(intensity)
-        else:
-            scaled_intensity = intensity
-        return scaled_intensity
-
-    def _scale2intensity(self, scaled_intensity):
-        """returns the unscaled intensity level based on value of self.stepType"""
-        if self.stepType=='db':
-            intensity = 10.0**(scaled_intensity/20.0)
-        elif self.stepType=='log':
-            intensity = 10.0**scaled_intensity
-        else:
-            intensity = scaled_intensity
-        return intensity
+        self._nextIntensity = self._questNextIntensity
 
     def mean(self):
         """mean of Quest posterior pdf"""
-        return self._scale2intensity(self._quest.mean())
+        return self._quest.mean()
 
     def sd(self):
         """standard deviation of Quest posterior pdf"""
-        return self._scale2intensity(self._quest.sd())
+        return self._quest.sd()
 
     def mode(self):
         """mode of Quest posterior pdf"""
-        return self._scale2intensity(self._quest.mode()[0])
+        return self._quest.mode()[0]
 
     def quantile(self, p=None):
         """quantile of Quest posterior pdf"""
-        return self._scale2intensity(self._quest.quantile(p))
+        return self._quest.quantile(p)
 
     def confInterval(self, getDifference=False):
         """give the range of the 5-95% confidence interval"""
@@ -3298,8 +3293,6 @@ def next(self):
                     break #break out of the forever loop
                 #do stuff here for the trial
         """
-        self._checkFinished()
-
         if self.finished==False:
             #update pointer for next trial
             self.thisTrialN+=1
@@ -3576,7 +3569,7 @@ def __init__(self, stairType='simple', method='random',
 
         #fetch first staircase/value (without altering/advancing it)
         self._startNewPass()
-        self.currentStaircase = self.thisPassRemaining[0]#take the first and remove it
+        self.currentStaircase = self.thisPassRemaining[0]  # take the first
         self._nextIntensity = self.currentStaircase._nextIntensity#gets updated by self.addData()
         #store the origin file and its path
         self.originPath, self.origin = self.getOriginPathAndFile(originPath)
@@ -3602,44 +3595,30 @@ def _checkArguments(self):
         else:
             logging.error("MultiStairHandler `stairType` should be 'simple', 'QUEST' or 'quest', not '%s'" %self.type)
     def _createStairs(self):
-        if self.type=='simple':
-            defaults = {'nReversals':None, 'stepSizes':4, 'nTrials':self.nTrials,
-                'nUp':1, 'nDown':3, 'extraInfo':None,
-                'stepType':'db', 'minVal':None, 'maxVal':None}
-        elif self.type in ['QUEST','quest']:
-            # fp added alternatives since the builder creates 'QUEST' but
-            # the API spec wants 'quest'?
-            defaults = {'pThreshold':0.82, 'nTrials':self.nTrials, 'stopInterval':None,
-                'method':'quantile', 'stepType':'log', 'beta':3.5, 'delta':0.01,
-                'gamma':0.5, 'grain':0.01, 'range':None, 'extraInfo':None,
-                'minVal':None, 'maxVal':None, 'staircase':None}
-
         for condition in self.conditions:
-            startVal=condition['startVal']
-            #fetch each params from conditions if possible
-            for paramName in defaults:
-                #get value for the parameter
-                if paramName in condition.keys():
-                    val=condition[paramName]
-                else:
-                    val = defaults[paramName]
-                #assign value to variable name
-                exec('%s=%s' %(paramName, repr(val)))
-            #then create actual staircase
-            if self.type=='simple':
-                thisStair = StairHandler(startVal, nReversals=nReversals,
-                    stepSizes=stepSizes, nTrials=nTrials, nUp=nUp, nDown=nDown,
-                    extraInfo=extraInfo,
-                    stepType=stepType, minVal=minVal, maxVal=maxVal)
-            elif self.type in ['QUEST','quest']:
-                # see above
-                thisStair = QuestHandler(startVal, startValSd=condition['startValSd'],
-                    pThreshold=pThreshold, nTrials=nTrials, stopInterval=stopInterval,
-                    method=method, stepType=stepType, beta=beta, delta=delta,
-                    gamma=gamma, grain=grain, range=range, extraInfo=extraInfo,
-                    minVal=minVal, maxVal=maxVal, staircase=staircase)
-            thisStair.condition = condition#this isn't normally part of handler
-            #and finally, add it to the list
+            # We create a copy, because we are going to remove items from
+            # this dictionary in this loop, but don't want these
+            # changes to alter the originals in self.conditions.
+            args = dict(condition)
+
+            # If no individual `nTrials` parameter was supplied for this
+            # staircase, use the `nTrials` that were passed to
+            # the MultiStairHandler on instantiation.
+            if 'nTrials' not in args:
+                args['nTrials'] = self.nTrials
+
+            if self.type == 'simple':
+                startVal = args.pop('startVal')
+                thisStair = StairHandler(startVal, **args)
+            elif self.type in ['QUEST', 'quest']:
+                startVal = args.pop('startVal')
+                startValSd = args.pop('startValSd')
+                thisStair = QuestHandler(startVal, startValSd, **args)
+
+            # This isn't normally part of handler.
+            thisStair.condition = condition
+
+            # And finally, add it to the list.
             self.staircases.append(thisStair)
             self.runningStaircases.append(thisStair)
     def __iter__(self):
@@ -3665,17 +3644,20 @@ def next(self):
 
         """
         #create a new set for this pass if needed
-        if not hasattr(self, 'thisPassRemaining') or self.thisPassRemaining==[]:
-            if len(self.runningStaircases)>0:
+        if (not hasattr(self, 'thisPassRemaining') or
+                not self.thisPassRemaining):
+            if self.runningStaircases:
                 self._startNewPass()
             else:
                 self.finished=True
                 raise StopIteration
+
         #fetch next staircase/value
         self.currentStaircase = self.thisPassRemaining.pop(0)#take the first and remove it
         #if staircase.next() not called, staircaseHandler would not save the first intensity,
         #Error: miss align intensities and responses
-        self._nextIntensity =self.currentStaircase.next()#gets updated by self.addData()
+        self._nextIntensity = self.currentStaircase.next()  # gets updated by self.addResponse()
+
         #return value
         if not self.finished:
             #inform experiment of the condition (but not intensity, that might be overridden by user)


Test patch:
diff --git a/psychopy/tests/test_data/test_MultiStair.py b/psychopy/tests/test_data/test_MultiStair.py
deleted file mode 100644
--- a/psychopy/tests/test_data/test_MultiStair.py
+++ /dev/null
@@ -1,88 +0,0 @@
-from __future__ import print_function
-from psychopy import data, logging
-from psychopy.tests import utils
-import numpy as np
-import os, glob, shutil
-logging.console.setLevel(logging.DEBUG)
-
-from tempfile import mkdtemp
-
-DEBUG=False
-
-
-class TestMultiStair(object):
-    """test multistairhandlers but using the experiment handler attached as well
-    """
-    def setup(self):#setup is run for each test within the class
-        self.tmpFile = mkdtemp(prefix='psychopy-tests-testMultiStaircase')
-        self.exp = data.ExperimentHandler(name='testExp',
-                        savePickle=True,
-                        saveWideText=True,
-                        dataFileName=self.tmpFile+'x')
-        if DEBUG:
-            print(self.tmpFile)
-
-    def teardown(self):
-        #    remove the tmp files
-        shutil.rmtree(self.tmpFile)
-
-    def doTrials(self, stairs, responses, levels=None, reversals=None):
-        """run the trials and check that the levels were correct given the responses
-        """
-        self.exp.addLoop(stairs)
-        trialN=0
-        for thisLevel, thisCondition in enumerate(stairs):
-            stairs.addResponse(responses[trialN])
-            stairs.addOtherData('rt', thisLevel*0.1)
-            if DEBUG:
-                if levels:
-                    print(trialN, thisLevel, responses[trialN], stairs.stepSizeCurrent, levels[trialN])#the latter is the expected level
-                else:
-                    print(trialN, thisLevel, responses[trialN])# there was no expected level given
-            self.exp.nextEntry()
-            trialN += 1
-        exp = stairs.getExp()
-        #test how that went
-        recordedResps = [entry['.response'] for entry in exp.entries]
-        recordedLevels = [entry['.intensity'] for entry in exp.entries]
-        print('n', trialN)
-        for n in range(trialN):
-            print(n,recordedResps[n], responses[n],recordedLevels[n], levels[n])
-        print(recordedResps)
-        assert np.allclose(recordedResps, responses[:trialN]), "staircase levels not as expected"
-        if hasattr(stairs, 'minVal'):
-            assert min(recordedLevels)>=stairs.minVal
-            assert max(recordedLevels)>=stairs.maxVal#assume it also has a maxVal
-        if levels is not None:
-            assert np.allclose(recordedLevels, levels), "staircase levels not as expected"
-        if reversals is not None:
-            assert np.allclose(stairs.reversalIntensities, reversals)
-
-    def test_multiStairQuest(self):
-        nTrials = 5
-        conditions = [{'label': 'stim_01', 'startVal': 0.3, 'startValSd': 0.8, 'minVal': 0, 'maxVal': 1,
-                       'pThreshold': 0.5, 'gamma': 0.01, 'delta': 0.01, 'grain': 0.01},
-                      {'label': 'stim_02', 'startVal': 0.3, 'startValSd': 0.8, 'minVal': 0, 'maxVal': 1,
-                       'pThreshold': 0.5, 'gamma': 0.01, 'delta': 0.01, 'grain': 0.01},
-                      {'label': 'stim_03', 'startVal': 0.3, 'startValSd': 0.8, 'minVal': 0, 'maxVal': 1,
-                       'pThreshold': 0.5, 'gamma': 0.01, 'delta': 0.01, 'grain': 0.01}]
-        multiStairHandler = data.MultiStairHandler(stairType='quest',
-                                                   method='sequential',
-                                                   conditions=conditions,
-                                                   nTrials=nTrials)
-        responsesToMake=makeBasicResponseCycles()
-        expectedLevels = [0.3, 0.26922321258393306,0.24699865727320078,
-                  0.265989972738, 0.282954436975,]
-        expectedLevels = np.repeat(expectedLevels, len(conditions))
-        self.doTrials(multiStairHandler, responsesToMake, expectedLevels)
-
-def makeBasicResponseCycles():
-    """helper function to make basic set of responses
-    """
-    responsesToMake=[]
-    for cycle in range(10):
-        for nRight in range(6):
-            responsesToMake.append(1)
-        for nWrong in range(6):
-            responsesToMake.append(0)
-    return responsesToMake
diff --git a/psychopy/tests/test_data/test_StairHandlers.py b/psychopy/tests/test_data/test_StairHandlers.py
new file mode 100644
--- /dev/null
+++ b/psychopy/tests/test_data/test_StairHandlers.py
@@ -0,0 +1,372 @@
+"""Test StairHandler"""
+from __future__ import division, print_function
+from psychopy import data, logging
+import numpy as np
+import shutil
+from tempfile import mkdtemp
+
+logging.console.setLevel(logging.DEBUG)
+DEBUG=False
+
+np.random.seed(1000)
+
+
+class _BaseTestStairHandler(object):
+    def setup(self):
+        self.tmpFile = mkdtemp(prefix='psychopy-tests-%s' %
+                               type(self).__name__)
+        self.exp = data.ExperimentHandler(
+                name='testExp',
+                savePickle=True,
+                saveWideText=True,
+                dataFileName=('%sx' % self.tmpFile)
+        )
+
+        if DEBUG:
+            print(self.tmpFile)
+
+    def teardown(self):
+        shutil.rmtree(self.tmpFile)
+
+    def simulate(self):
+        """
+        Simulate a staircase run.
+        """
+        self.exp.addLoop(self.stairs)
+        for trialN, _ in enumerate(self.stairs):
+            self.stairs.addResponse(self.responses[trialN])
+            self.stairs.addOtherData(
+                    'RT', 0.1 + 2*np.random.random_sample(1)
+            )
+            self.exp.nextEntry()
+
+    def checkSimulationResults(self):
+        """
+        Verify the results of a simulated staircase run.
+        """
+        stairs = self.stairs
+        responses = self.responses
+        intensities = self.intensities
+        if hasattr(self, 'reversalPoints'):
+            reversalPoints = self.reversalPoints
+        else:
+            reversalPoints = None
+        if hasattr(self, 'reversalIntensities'):
+            reversalIntensities = self.reversalIntensities
+        else:
+            reversalIntensities = None
+
+        assert stairs.finished  # Staircase terminated normally.
+        assert stairs.data == responses  # Responses were stored correctly.
+        # Trial count starts at zero.
+        assert stairs.thisTrialN == len(stairs.data) - 1
+
+        # Intensity values are sane.
+        assert np.min(stairs.intensities) >= stairs.minVal
+        assert np.max(stairs.intensities) <= stairs.maxVal
+        assert np.allclose(stairs.intensities, intensities)
+
+        # Reversal values are sane.
+        if stairs.nReversals is not None:
+            # The first trial can never be a reversal.
+            assert stairs.nReversals <= len(stairs.data) - 1
+
+            assert stairs.nReversals == len(stairs.reversalPoints)
+            assert stairs.nReversals == len(stairs.reversalIntensities)
+
+        if stairs.reversalPoints:
+            assert (len(stairs.reversalPoints) ==
+                    len(stairs.reversalIntensities))
+
+        if reversalIntensities:
+            assert np.allclose(stairs.reversalIntensities,
+                               reversalIntensities)
+
+
+class _BaseTestMultiStairHandler(_BaseTestStairHandler):
+    def checkSimulationResults(self):
+        """
+        Verify the results of a simulated staircase run.
+        """
+        stairs = self.stairs
+        responses = self.responses
+        intensities = self.intensities
+
+        assert all([s.finished for s in stairs.staircases])
+        assert (sum([len(s.data) for s in stairs.staircases]) ==
+                len(responses))
+        assert (sum([len(s.intensities) for s in stairs.staircases]) ==
+                len(intensities))
+        assert (sum([len(s.data) for s in stairs.staircases]) ==
+                sum([len(s.intensities) for s in stairs.staircases]))
+
+
+class TestStairHandler(_BaseTestStairHandler):
+    """
+    Test StairHandler, but with the ExperimentHandler attached as well.
+    """
+    def test_StairHandlerLinear(self):
+        nTrials = 20
+        startVal, minVal, maxVal = 0.8, 0, 1
+        stepSizes = [0.1, 0.01, 0.001]
+        nUp, nDown = 1, 3
+        nReversals = 4
+        stepType = 'lin'
+
+        self.stairs = data.StairHandler(
+            startVal=startVal, nUp=nUp, nDown=nDown, minVal=minVal,
+            maxVal=maxVal, nReversals=nReversals, stepSizes=stepSizes,
+            nTrials=nTrials, stepType=stepType
+        )
+
+        self.responses = makeBasicResponseCycles(
+            cycles=3, nCorrect=4, nIncorrect=4, length=20
+        )
+
+        self.intensities = [
+            0.8, 0.7, 0.6, 0.5, 0.4, 0.41, 0.42, 0.43, 0.44, 0.44, 0.44,
+            0.439, 0.439, 0.44, 0.441, 0.442, 0.443, 0.443, 0.443, 0.442
+        ]
+
+        self.reversalIntensities = [0.4, 0.44, 0.439, 0.443]
+
+        self.simulate()
+        self.checkSimulationResults()
+
+    def test_StairHandlerLog(self):
+        nTrials = 20
+        startVal, minVal, maxVal = 0.8, 0, 1
+        # We try to reproduce the values from test_StairHandlerDb().
+        stepSizes = [0.4/20, 0.2/20, 0.2/20, 0.1/20]
+        nUp, nDown = 1, 3
+        nReversals = 4
+        stepType = 'log'
+
+        self.stairs = data.StairHandler(
+            startVal=startVal, nUp=nUp, nDown=nDown, minVal=minVal,
+            maxVal=maxVal, nReversals=nReversals, stepSizes=stepSizes,
+            nTrials=nTrials, stepType=stepType
+        )
+
+        self.responses = makeBasicResponseCycles(
+            cycles=3, nCorrect=4, nIncorrect=4, length=20
+        )
+
+        self.intensities = [
+            0.8, 0.763994069, 0.729608671, 0.696770872, 0.665411017,
+            0.680910431, 0.696770872, 0.713000751, 0.729608671,
+            0.729608671, 0.729608671, 0.713000751, 0.713000751,
+            0.72125691, 0.729608671, 0.738057142, 0.746603441,
+            0.746603441, 0.746603441, 0.738057142
+        ]
+
+        self.reversalIntensities = [
+            0.665411017, 0.729608671, 0.713000751, 0.746603441
+        ]
+
+        self.simulate()
+        self.checkSimulationResults()
+
+    def test_StairHandlerDb(self):
+        nTrials = 20
+        startVal, minVal, maxVal = 0.8, 0, 1
+        stepSizes = [0.4, 0.2, 0.2, 0.1]
+        nUp, nDown = 1, 3
+        nReversals = 4
+        stepType = 'db'
+
+        self.stairs = data.StairHandler(
+            startVal=startVal, nUp=nUp, nDown=nDown, minVal=minVal,
+            maxVal=maxVal, nReversals=nReversals, stepSizes=stepSizes,
+            nTrials=nTrials, stepType=stepType
+        )
+
+        self.responses = makeBasicResponseCycles(
+            cycles=3, nCorrect=4, nIncorrect=4, length=20
+        )
+
+        self.intensities = [
+            0.8, 0.763994069, 0.729608671, 0.696770872, 0.665411017,
+            0.680910431, 0.696770872, 0.713000751, 0.729608671,
+            0.729608671, 0.729608671, 0.713000751, 0.713000751,
+            0.72125691, 0.729608671, 0.738057142, 0.746603441,
+            0.746603441, 0.746603441, 0.738057142
+        ]
+
+        self.reversalIntensities = [
+            0.665411017, 0.729608671, 0.713000751, 0.746603441
+        ]
+
+        self.simulate()
+        self.checkSimulationResults()
+
+
+class TestQuestHandler(_BaseTestStairHandler):
+    """
+    Test QuestHandler, but with the ExperimentHandler attached as well.
+    """
+    def test_QuestHandler(self):
+        nTrials = 10
+        startVal, minVal, maxVal = 50, 0, 100
+        range = maxVal - minVal
+        startValSd = 50
+        grain = 0.01
+        pThreshold = 0.82
+        beta, gamma, delta = 3.5, 0.5, 0.01
+        stopInterval = None
+        method = 'quantile'
+
+        self.stairs = data.QuestHandler(
+            startVal, startValSd, pThreshold=pThreshold, nTrials=nTrials,
+            stopInterval=stopInterval, method=method, beta=beta,
+            gamma=gamma, delta=delta, grain=grain, range=range,
+            minVal=minVal, maxVal=maxVal
+        )
+
+        self.responses = makeBasicResponseCycles(
+            cycles=3, nCorrect=2, nIncorrect=2, length=10
+        )
+
+        self.intensities = [
+            50, 45.139710407074872, 37.291086503930742,
+            58.297413127139947, 80.182967131096547, 75.295251409003527,
+            71.57627192423783, 79.881680484036906, 90.712313302815517,
+            88.265808957695796
+        ]
+
+        mean = 86.0772169427
+        mode = 80.11
+        quantile = 86.3849031085
+
+        self.simulate()
+        self.checkSimulationResults()
+
+        assert np.allclose(self.stairs.mean(), mean)
+        assert np.allclose(self.stairs.mode(), mode)
+        assert np.allclose(self.stairs.quantile(), quantile)
+
+        # Check if the internal grid has the expected dimensions.
+        assert len(self.stairs._quest.x) == (maxVal - minVal) / grain + 1
+        assert np.allclose(
+                self.stairs._quest.x[1] - self.stairs._quest.x[0],
+                grain
+        )
+        assert self.stairs._quest.x[0] == -range/2
+        assert self.stairs._quest.x[-1] == range/2
+
+
+class TestMultiStairHandler(_BaseTestMultiStairHandler):
+    """
+    Test MultiStairHandler, but with the ExperimentHandler attached as well
+    """
+    def test_multiStairQuestSequentialIdenticalConditions(self):
+        """
+        Identical parameters passed to both QuestHandlers, and identical
+        simulated responses provided.
+
+        We use the exact same settings as in
+        TestQuestHandler.test_QuestHandler().
+
+        """
+        # These are the parameters for the QuestHandlers.
+        nTrials = 10  # This is going to yield 10 trials per staircase.
+        startVal, minVal, maxVal = 50, 0, 100
+        range = maxVal - minVal
+        startValSd = 50
+        grain = 0.01
+        pThreshold = 0.82
+        beta, gamma, delta = 3.5, 0.5, 0.01
+        stopInterval = None
+        method = 'quantile'
+
+        # These parameters are shared between the two staircases. The only
+        # thing we will need to add is individual labels, which will
+        # happen below.
+        conditions = {
+            'startVal': startVal, 'startValSd': startValSd,
+            'minVal': minVal, 'maxVal': maxVal, 'range': range,
+            'pThreshold': pThreshold, 'gamma': gamma, 'delta': delta,
+            'grain': grain, 'method': method, 'nTrials': nTrials,
+            'stopInterval': stopInterval
+        }
+
+        self.conditions = [
+            dict({'label': 'staircase_0'}.items() + conditions.items()),
+            dict({'label': 'staircase_1'}.items() + conditions.items()),
+        ]
+
+        self.stairs = data.MultiStairHandler(
+                stairType='quest', method='linear',
+                conditions=self.conditions
+        )
+
+        # Responses for only one staircase. We will duplicate this below.
+        responses = makeBasicResponseCycles(
+            cycles=3, nCorrect=2, nIncorrect=2, length=10
+        )
+        self.responses = np.repeat(responses, 2)
+
+        # Same procedure as with the responses: This lists the responses
+        # for one of the staircases, and will be duplicated below.
+        intensities = [
+            50, 45.139710407074872, 37.291086503930742,
+            58.297413127139947, 80.182967131096547, 75.295251409003527,
+            71.57627192423783, 79.881680484036906, 90.712313302815517,
+            88.265808957695796
+        ]
+        self.intensities = np.repeat(intensities, 2)
+
+        mean = 86.0772169427
+        mode = 80.11
+        quantile = 86.3849031085
+
+        self.simulate()
+        self.checkSimulationResults()
+
+        assert np.allclose(self.stairs.staircases[0].mean(), mean)
+        assert np.allclose(self.stairs.staircases[0].mode(), mode)
+        assert np.allclose(self.stairs.staircases[0].quantile(), quantile)
+
+        assert (self.stairs.staircases[0].intensities ==
+                self.stairs.staircases[1].intensities)
+
+        assert (self.stairs.staircases[0].data ==
+                self.stairs.staircases[1].data)
+
+
+
+def makeBasicResponseCycles(cycles=10, nCorrect=4, nIncorrect=4,
+                            length=None):
+    """
+    Helper function to create a basic set of responses.
+
+    :Parameters:
+
+    cycles : int, optional
+        The number of response cycles to generate. One cycle consists of a
+        number of correct and incorrect responses.
+        Defaults to 10.
+    nCorrect, nIncorrect : int, optional
+        The number of correct and incorrect responses per cycle.
+        Defaults to 4.
+    length : int or None, optional
+
+    :Returns:
+
+    responses : list
+        A list of simulated responses with length
+        `cycles * (nCorrect + nIncorrect)`.
+
+    """
+    responsesCorrectPerCycle = np.ones(nCorrect, dtype=np.int)
+    responsesIncorrectPerCycle = np.zeros(nIncorrect, dtype=np.int)
+
+    responses = np.tile(
+        np.r_[responsesCorrectPerCycle, responsesIncorrectPerCycle],
+        cycles
+    ).tolist()
+
+    if length is not None:
+        return responses[:length]
+    else:
+        return responses
diff --git a/psychopy/tests/test_data/test_stairs.py b/psychopy/tests/test_data/test_stairs.py
deleted file mode 100644
--- a/psychopy/tests/test_data/test_stairs.py
+++ /dev/null
@@ -1,86 +0,0 @@
-"""Test StairHandler"""
-from __future__ import print_function
-from psychopy import data, logging
-from psychopy.tests import utils
-import numpy as np
-import os, glob, shutil
-logging.console.setLevel(logging.DEBUG)
-
-from tempfile import mkdtemp
-
-DEBUG=False
-
-
-class TestStairHandlers(object):
-    """test staircase but using the experiment handler attached as well
-    """
-    def setup(self):#setup is run for each test within the class
-        self.tmpFile = mkdtemp(prefix='psychopy-tests-testStaircase')
-        self.exp = data.ExperimentHandler(name='testExp',
-                        savePickle=True,
-                        saveWideText=True,
-                        dataFileName=self.tmpFile+'x')
-        if DEBUG:
-            print(self.tmpFile)
-
-    def teardown(self):
-        #    remove the tmp files
-        shutil.rmtree(self.tmpFile)
-
-    def doTrials(self, stairs, responses, levels=None, reversals=None):
-        """run the trials and check that the levels were correct given the responses
-        """
-        self.exp.addLoop(stairs)
-        for trialN, thisLevel in enumerate(stairs):
-            stairs.addResponse(responses[trialN])
-            stairs.addOtherData('rt', thisLevel*0.1)
-            if DEBUG:
-                if levels:
-                    print(trialN, thisLevel, responses[trialN], stairs.stepSizeCurrent, levels[trialN])#the latter is the expected level)
-                else:
-                    print(trialN, thisLevel, responses[trialN])# there was no expected level given
-            self.exp.nextEntry()
-        assert stairs.data == responses
-        if hasattr(stairs, 'minVal'):
-            assert min(responses)>=stairs.minVal
-            assert max(responses)>=stairs.maxVal#assume it also has a maxVal
-        if levels:
-            assert np.allclose(stairs.intensities, levels), "staircase levels not as expected"
-        if reversals:
-            assert np.allclose(stairs.reversalIntensities, reversals)
-
-    def test_staircaseLinear(self):
-        nTrials = 20
-        stairs = data.StairHandler(startVal=0.8, nUp=1, nDown=3, minVal=0, maxVal=1,
-            nReversals=4, stepSizes=[0.1,0.01,0.001], nTrials=nTrials,
-            stepType='lin')
-        responsesToMake=makeBasicResponseCycles()
-        levels = [0.8, 0.7, 0.6, 0.5, 0.4, 0.41, 0.42, 0.43, 0.44, 0.44, 0.44,
-                  0.439, 0.439, 0.44, 0.441, 0.442, 0.443, 0.443, 0.443, 0.442]
-        reversals = [0.4, 0.44, 0.439, 0.443]
-        self.doTrials(stairs, responsesToMake[:nTrials], levels=levels, reversals=reversals)
-
-    def test_staircaseDB(self):
-        nTrials = 20
-        stairs = data.StairHandler(startVal=0.8, nUp=1, nDown=3, minVal=0, maxVal=1,
-            nReversals=4, stepSizes=[0.4,0.2,0.2,0.1], nTrials=nTrials,
-            stepType='db')
-        responsesToMake=makeBasicResponseCycles()
-        levels=[0.8, 0.763994069, 0.729608671, 0.696770872, 0.665411017, 0.680910431,
-                0.696770872, 0.713000751, 0.729608671, 0.729608671, 0.729608671, 0.713000751,
-                0.713000751, 0.72125691, 0.729608671, 0.738057142, 0.746603441, 0.746603441,
-                0.746603441, 0.738057142]
-        reversals = [0.665411017, 0.729608671, 0.713000751, 0.746603441]
-        self.doTrials(stairs, responsesToMake[:nTrials], levels=levels)
-
-
-def makeBasicResponseCycles():
-    """helper function to make basic set of responses
-    """
-    responsesToMake=[]
-    for cycle in range(10):
-        for nRight in range(4):
-            responsesToMake.append(1)
-        for nWrong in range(4):
-            responsesToMake.append(0)
-    return responsesToMake