# data/QuestHandler.js

    /**
     * Quest Trial Handler
     *
     * @author Alain Pitiot & Thomas Pronk
     * @version 2022.2.3
     * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org)
     * @license Distributed under the terms of the MIT License
     */

    import {TrialHandler} from "./TrialHandler.js";

    /**
     * <p>A Trial Handler that implements the Quest algorithm for quick measurement of
        psychophysical thresholds. QuestHandler relies on the [jsQuest]{@link https://github.com/kurokida/jsQUEST} library, a port of Prof Dennis Pelli's QUEST algorithm by [Daiichiro Kuroki]{@link https://github.com/kurokida}.</p>
     *
     * @extends TrialHandler
     */
    export class QuestHandler extends TrialHandler
    {
    	/**
    	 * @memberof module:data
    	 * @param {Object} options - the handler options
    	 * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance
    	 * @param {string} options.varName - the name of the variable / intensity / contrast / threshold manipulated by QUEST
    	 * @param {number} options.startVal - initial guess for the threshold
    	 * @param {number} options.startValSd - standard deviation of the initial guess
    	 * @param {number} options.minVal - minimum value for the threshold
    	 * @param {number} options.maxVal - maximum value for the threshold
    	 * @param {number} [options.pThreshold=0.82] - threshold criterion expressed as probability of getting a correct response
    	 * @param {number} options.nTrials - maximum number of trials
    	 * @param {number} options.stopInterval - minimum [5%, 95%] confidence interval required for the loop to stop
    	 * @param {QuestHandler.Method} options.method - the QUEST method
    	 * @param {number} [options.beta=3.5] - steepness of the QUEST psychometric function
    	 * @param {number} [options.delta=0.01] - fraction of trials with blind responses
    	 * @param {number} [options.gamma=0.5] - fraction of trails that would generate a correct response when the threshold is infinitely small
    	 * @param {number} [options.grain=0.01] - quantization of the internal table
    	 * @param {string} options.name - name of the handler
    	 * @param {boolean} [options.autoLog= false] - whether or not to log
    	 */
    	constructor({
    		psychoJS,
    		varName,
    		startVal,
    		startValSd,
    		minVal,
    		maxVal,
    		pThreshold,
    		nTrials,
    		stopInterval,
    		method,
    		beta,
    		delta,
    		gamma,
    		grain,
    		name,
    		autoLog
    	} = {})
    	{
    		super({
    			psychoJS,
    			name,
    			autoLog,
    			method: TrialHandler.Method.SEQUENTIAL,
    			trialList: Array(nTrials),
    			nReps: 1
    		});

    		this._addAttribute("varName", varName);
    		this._addAttribute("startVal", startVal);
    		this._addAttribute("minVal", minVal, Number.MIN_VALUE);
    		this._addAttribute("maxVal", maxVal, Number.MAX_VALUE);
    		this._addAttribute("startValSd", startValSd);
    		this._addAttribute("pThreshold", pThreshold, 0.82);
    		this._addAttribute("nTrials", nTrials);
    		this._addAttribute("stopInterval", stopInterval, Number.MIN_VALUE);
    		this._addAttribute("beta", beta, 3.5);
    		this._addAttribute("delta", delta, 0.01);
    		this._addAttribute("gamma", gamma, 0.5);
    		this._addAttribute("grain", grain, 0.01);
    		this._addAttribute("method", method, QuestHandler.Method.QUANTILE);

    		// setup jsQuest:
    		this._setupJsQuest();
    		this._estimateQuestValue();
    	}

    	/**
    	 * Setter for the method attribute.
    	 *
    	 * @param {mixed} method - the method value, PsychoPy-style values ("mean", "median",
    	 * "quantile") are converted to their respective QuestHandler.Method values
    	 * @param {boolean} log - whether or not to log the change of seed
    	 */
    	 setMethod(method, log)
    	 {
    		let methodMapping = {
    			"quantile": QuestHandler.Method.QUANTILE,
    			"mean": QuestHandler.Method.MEAN,
    			"mode": QuestHandler.Method.MODE
    		};
    		// If method is a key in methodMapping, convert method to corresponding value
    		if (methodMapping.hasOwnProperty(method))
    		{
    			method = methodMapping[method];
    		}
    		this._setAttribute("method", method, log);
    	}

    	/**
    	 * Add a response and update the PDF.
    	 *
    	 * @param{number} response	- the response to the trial, must be either 0 (incorrect or
    	 * non-detected) or 1 (correct or detected)
    	 * @param{number | undefined} value - optional intensity / contrast / threshold
    	 * @param{boolean} [doAddData = true] - whether or not to add the response as data to the
    	 * 	experiment
    	 */
    	addResponse(response, value, doAddData = true)
    	{
    		// check that response is either 0 or 1:
    		if (response !== 0 && response !== 1)
    		{
    			throw {
    				origin: "QuestHandler.addResponse",
    				context: "when adding a trial response",
    				error: `the response must be either 0 or 1, got: ${JSON.stringify(response)}`
    			};
    		}

    		if (doAddData)
    		{
    			this._psychoJS.experiment.addData(this._name + '.response', response);
    		}

    		// update the QUEST pdf:
    		if (typeof value !== "undefined")
    		{
    			this._jsQuest = jsQUEST.QuestUpdate(this._jsQuest, value, response);
    		}
    		else
    		{
    			this._jsQuest = jsQUEST.QuestUpdate(this._jsQuest, this._questValue, response);
    		}

    		if (!this._finished)
    		{
    			this.next();

    			// estimate the next value of the QUEST variable
    			// (and update the trial list and snapshots):
    			this._estimateQuestValue();
    		}
    	}

    	/**
    	 * Simulate a response.
    	 *
    	 * @param{number} trueValue - the true, known value of the threshold / contrast / intensity
    	 * @returns{number} the simulated response, 0 or 1
    	 */
    	simulate(trueValue)
    	{
    		const response = jsQUEST.QuestSimulate(this._jsQuest, this._questValue, trueValue);

    		// restrict to limits:
    		this._questValue = Math.max(this._minVal, Math.min(this._maxVal, this._questValue));

    		this._psychoJS.logger.debug(`simulated response: ${response}`);

    		return response;
    	}

    	/**
    	 * Get the mean of the Quest posterior PDF.
    	 *
    	 * @returns {number} the mean
    	 */
    	mean()
    	{
    		return jsQUEST.QuestMean(this._jsQuest);
    	}

    	/**
    	 * Get the standard deviation of the Quest posterior PDF.
    	 *
    	 * @returns {number} the standard deviation
    	 */
    	sd()
    	{
    		return jsQUEST.QuestSd(this._jsQuest);
    	}

    	/**
    	 * Get the mode of the Quest posterior PDF.
    	 *
    	 * @returns {number} the mode
    	 */
    	mode()
    	{
    		const [mode, pdf] = jsQUEST.QuestMode(this._jsQuest);
    		return mode;
    	}

    	/**
    	 * Get the standard deviation of the Quest posterior PDF.
    	 *
    	 * @param{number} quantileOrder the quantile order
    	 * @returns {number} the quantile
    	 */
    	quantile(quantileOrder)
    	{
    		return jsQUEST.QuestQuantile(this._jsQuest, quantileOrder);
    	}

    	/**
    	 * Get the current value of the variable / contrast / threshold.
    	 *
    	 * @returns {number} the current QUEST value for the variable / contrast / threshold
    	 */
    	getQuestValue()
    	{
    		return this._questValue;
    	}

    	/**
    	 * Get the current value of the variable / contrast / threshold.
    	 *
    	 * <p>This is the getter associated to getQuestValue.</p>
    	 *
    	 * @returns {number} the intensity of the current staircase, or undefined if the trial has ended
    	 */
    	get intensity()
    	{
    		return this.getQuestValue();
    	}

    	/**
    	 * Get an estimate of the 5%-95% confidence interval (CI).
    	 *
    	 * @param{boolean} [getDifference=false] - if true, return the width of the CI instead of the CI
    	 * @returns{number[] | number} the 5%-95% CI or the width of the CI
    	 */
    	confInterval(getDifference = false)
    	{
    		const CI = [
    			jsQUEST.QuestQuantile(this._jsQuest, 0.05),
    			jsQUEST.QuestQuantile(this._jsQuest, 0.95)
    		];

    		if (getDifference)
    		{
    			return Math.abs(CI[0] - CI[1]);
    		}
    		else
    		{
    			return CI;
    		}
    	}

    	/**
    	 * Setup the JS Quest object.
    	 *
    	 * @protected
    	 */
    	_setupJsQuest()
    	{
    		this._jsQuest = jsQUEST.QuestCreate(
    			this._startVal,
    			this._startValSd,
    			this._pThreshold,
    			this._beta,
    			this._delta,
    			this._gamma,
    			this._grain);
    	}

    	/**
    	 * Estimate the next value of the QUEST variable, based on the current value
    	 * and on the selected QUEST method.
    	 *
    	 * @protected
    	 */
    	_estimateQuestValue()
    	{
    		// estimate the value based on the chosen QUEST method:
    		if (this._method === QuestHandler.Method.QUANTILE)
    		{
    			this._questValue = jsQUEST.QuestQuantile(this._jsQuest);
    		}
    		else if (this._method === QuestHandler.Method.MEAN)
    		{
    			this._questValue = jsQUEST.QuestMean(this._jsQuest);
    		}
    		else if (this._method === QuestHandler.Method.MODE)
    		{
    			const [mode, pdf] = jsQUEST.QuestMode(this._jsQuest);
    			this._questValue = mode;
    		}
    		else
    		{
    			throw {
    				origin: "QuestHandler._estimateQuestValue",
    				context: "when estimating the next value of the QUEST variable",
    				error: `unknown method: ${this._method}, please use: mean, mode, or quantile`
    			};
    		}

    		this._psychoJS.logger.debug(`estimated value for QUEST variable ${this._varName}: ${this._questValue}`);

    		// check whether we should finish the trial:
    		if (this.thisN > 0 && (this.nRemaining === 0 || this.confInterval(true) < this._stopInterval))
    		{
    			this._finished = true;

    			// update the snapshots associated with the current trial in the trial list:
    			for (let t = 0; t < this._snapshots.length - 1; ++t)
    			{
    				// the current trial is the last defined one:
    				if (typeof this._trialList[t + 1] === "undefined")
    				{
    					this._snapshots[t].finished = true;
    					break;
    				}
    			}

    			return;
    		}

    		// update the next undefined trial in the trial list, and the associated snapshot:
    		for (let t = 0; t < this._trialList.length; ++t)
    		{
    			if (typeof this._trialList[t] === "undefined")
    			{
    				this._trialList[t] = { [this._varName]: this._questValue };

    				if (typeof this._snapshots[t] !== "undefined")
    				{
    					this._snapshots[t][this._varName] = this._questValue;
    					this._snapshots[t].trialAttributes.push(this._varName);
    				}
    				break;
    			}
    		}
    	}
    }

    /**
     * QuestHandler method
     *
     * @enum {Symbol}
     * @readonly
     */
    QuestHandler.Method = {
    	/**
    	 * Quantile threshold estimate.
    	 */
    	QUANTILE: Symbol.for("QUANTILE"),

    	/**
    	 * Mean threshold estimate.
    	 */
    	MEAN: Symbol.for("MEAN"),

    	/**
    	 * Mode threshold estimate.
    	 */
    	MODE: Symbol.for("MODE")
    };