data/ExperimentHandler.js

/**
 * Experiment Handler
 *
 * @author Alain Pitiot
 * @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 * as XLSX from "xlsx";
import { MonotonicClock } from "../util/Clock.js";
import { PsychObject } from "../util/PsychObject.js";
import * as util from "../util/Util.js";

/**
 * <p>An ExperimentHandler keeps track of multiple loops and handlers. It is particularly useful
 * for generating a single data file from an experiment with many different loops (e.g. interleaved
 * staircases or loops within loops.</p>
 *
 * @extends PsychObject
 */
export class ExperimentHandler extends PsychObject
{
	/**
	 * Getter for experimentEnded.
	 */
	get experimentEnded()
	{
		return this._experimentEnded;
	}

	/**
	 * Setter for experimentEnded.
	 */
	set experimentEnded(ended)
	{
		this._experimentEnded = ended;
	}

	/**
	 * Legacy experiment getters.
	 */
	get _thisEntry()
	{
		return this._currentTrialData;
	}

	get _entries()
	{
		return this._trialsData;
	}

	/**
	 * @memberof module:data
	 * @param {Object} options
	 * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance
	 * @param {string} options.name - name of the experiment
	 * @param {Object} options.extraInfo - additional information, such as session name, participant name, etc.
	 */
	constructor({
		psychoJS,
		name,
		extraInfo,
		dataFileName
	} = {})
	{
		super(psychoJS, name);

		this._addAttribute("extraInfo", extraInfo);

		// process the extra info:
		this._experimentName = (typeof extraInfo.expName === "string" && extraInfo.expName.length > 0)
			? extraInfo.expName
			: this.psychoJS.config.experiment.name;
		this._participant = (typeof extraInfo.participant === "string" && extraInfo.participant.length > 0)
			? extraInfo.participant
			: "PARTICIPANT";
		this._session = (typeof extraInfo.session === "string" && extraInfo.session.length > 0)
			? extraInfo.session
			: "SESSION";
		this._datetime = (typeof extraInfo.date !== "undefined")
			? extraInfo.date
			: MonotonicClock.getDateStr();

		this._addAttribute(
			"dataFileName",
			dataFileName,
			`${this._participant}_${this._experimentName}_${this._datetime}`
		);

		// loop handlers:
		this._loops = [];
		this._unfinishedLoops = [];

		// data dictionaries (one per trial) and current data dictionary:
		this._trialsKeys = [];
		this._trialsData = [];
		this._currentTrialData = {};

		this._experimentEnded = false;
	}

	/**
	 * Whether or not the current entry (i.e. trial data) is empty.
	 * <p>Note: this is mostly useful at the end of an experiment, in order to ensure that the last entry is saved.</p>
	 *
	 * @returns {boolean} whether or not the current entry is empty
	 * @todo This really should be renamed: IsCurrentEntryNotEmpty
	 */
	isEntryEmpty()
	{
		return (Object.keys(this._currentTrialData).length > 0);
	}

	/**
	 * Add a loop.
	 *
	 * <p> The loop might be a {@link TrialHandler}, for instance.</p>
	 * <p> Data from this loop will be included in the resulting data files.</p>
	 *
	 * @param {Object} loop - the loop, e.g. an instance of TrialHandler or StairHandler
	 */
	addLoop(loop)
	{
		this._loops.push(loop);
		this._unfinishedLoops.push(loop);
		loop.experimentHandler = this;
	}

	/**
	 * Remove the given loop from the list of unfinished loops, e.g. when it has completed.
	 *
	 * @param {Object} loop - the loop, e.g. an instance of TrialHandler or StairHandler
	 */
	removeLoop(loop)
	{
		const index = this._unfinishedLoops.indexOf(loop);
		if (index !== -1)
		{
			this._unfinishedLoops.splice(index, 1);
		}
	}

	/**
	 * Add the key/value pair.
	 *
	 * <p> Multiple key/value pairs can be added to any given entry of the data file. There are
	 * considered part of the same entry until a call to {@link nextEntry} is made. </p>
	 *
	 * @param {Object} key - the key
	 * @param {Object} value - the value
	 */
	addData(key, value)
	{
		if (this._trialsKeys.indexOf(key) === -1)
		{
			this._trialsKeys.push(key);
		}

		// turn arrays into their json equivalent:
		if (Array.isArray(value))
		{
			value = JSON.stringify(value);
		}

		this._currentTrialData[key] = value;
	}

	/**
	 * Inform this ExperimentHandler that the current trial has ended.  Further calls to {@link addData}
	 * will be associated with the next trial.
	 *
	 * @param {Object | Object[] | undefined} snapshots - array of loop snapshots
	 */
	nextEntry(snapshots)
	{
		if (typeof snapshots !== "undefined")
		{
			// turn single snapshot into a one-element array:
			if (!Array.isArray(snapshots))
			{
				snapshots = [snapshots];
			}

			for (const snapshot of snapshots)
			{
				const attributes = ExperimentHandler._getLoopAttributes(snapshot);
				for (let a in attributes)
				{
					if (attributes.hasOwnProperty(a))
					{
						this._currentTrialData[a] = attributes[a];
					}
				}
			}
		}
		// this is to support legacy generated JavaScript code and does not properly handle
		// loops within loops:
		else
		{
			for (const loop of this._unfinishedLoops)
			{
				const attributes = ExperimentHandler._getLoopAttributes(loop);
				for (const a in attributes)
				{
					if (attributes.hasOwnProperty(a))
					{
						this._currentTrialData[a] = attributes[a];
					}
				}
			}
		}

		// add the extraInfo dict to the data:
		for (let a in this.extraInfo)
		{
			if (this.extraInfo.hasOwnProperty(a))
			{
				this._currentTrialData[a] = this.extraInfo[a];
			}
		}

		this._trialsData.push(this._currentTrialData);

		this._currentTrialData = {};
	}

	/**
	 * Save the results of the experiment.
	 *
	 * <ul>
	 *   <li>For an experiment running locally, the results are offered for immediate download.</li>
	 *   <li>For an experiment running on the server, the results are uploaded to the server.</li>
	 * </ul>
	 * <p>
	 *
	 * @param {Object} options
	 * @param {Array.<Object>} [options.attributes] - the attributes to be saved
	 * @param {boolean} [options.sync=false] - whether or not to communicate with the server in a synchronous manner
	 * @param {string} [options.tag=''] - an optional tag to add to the filename to which the data is saved (for CSV and XLSX saving options)
	 * @param {boolean} [options.clear=false] - whether or not to clear all experiment results immediately after they are saved (this is useful when saving data in separate chunks, throughout an experiment)
	 */
	async save({
		attributes = [],
		sync = false,
		tag = "",
		clear = false
	} = {})
	{
		this._psychoJS.logger.info("[PsychoJS] Save experiment results.");

		// get attributes:
		if (attributes.length === 0)
		{
			attributes = this._trialsKeys.slice();
			for (let l = 0; l < this._loops.length; l++)
			{
				const loop = this._loops[l];

				const loopAttributes = ExperimentHandler._getLoopAttributes(loop);
				for (let a in loopAttributes)
				{
					if (loopAttributes.hasOwnProperty(a))
					{
						attributes.push(a);
					}
				}
			}
			for (let a in this.extraInfo)
			{
				if (this.extraInfo.hasOwnProperty(a))
				{
					attributes.push(a);
				}
			}
		}

		let data = this._trialsData;
		// if the experiment data have to be cleared, we first make a copy of them:
		if (clear)
		{
			data = this._trialsData.slice();
			this._trialsData = [];
		}

		// save to a .csv file:
		if (this._psychoJS.config.experiment.saveFormat === ExperimentHandler.SaveFormat.CSV)
		{
			// note: we use the XLSX library as it automatically deals with header, takes care of quotes,
			// newlines, etc.
			// TODO only save the given attributes
			const worksheet = XLSX.utils.json_to_sheet(data);
			// prepend BOM
			const csv = "\ufeff" + XLSX.utils.sheet_to_csv(worksheet);

			// upload data to the pavlovia server or offer them for download:
			const filenameWithoutPath = this._dataFileName.split(/[\\/]/).pop();
			const key = `${filenameWithoutPath}${tag}.csv`;
			if (
				this._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER
				&& this._psychoJS.config.experiment.status === "RUNNING"
				&& !this._psychoJS._serverMsg.has("__pilotToken")
			)
			{
				return /*await*/ this._psychoJS.serverManager.uploadData(key, csv, sync);
			}
			else
			{
				util.offerDataForDownload(key, csv, "text/csv");
			}
		}
		// save to the database on the pavlovia server:
		else if (this._psychoJS.config.experiment.saveFormat === ExperimentHandler.SaveFormat.DATABASE)
		{
			const gitlabConfig = this._psychoJS.config.gitlab;
			const __projectId = (typeof gitlabConfig !== "undefined" && typeof gitlabConfig.projectId !== "undefined") ? gitlabConfig.projectId : undefined;

			let documents = [];

			for (let r = 0; r < data.length; r++)
			{
				let doc = {
					__projectId,
					__experimentName: this._experimentName,
					__participant: this._participant,
					__session: this._session,
					__datetime: this._datetime
				};
				for (let h = 0; h < attributes.length; h++)
				{
					doc[attributes[h]] = data[r][attributes[h]];
				}

				documents.push(doc);
			}

			// upload data to the pavlovia server or offer them for download:
			if (
				this._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER
				&& this._psychoJS.config.experiment.status === "RUNNING"
				&& !this._psychoJS._serverMsg.has("__pilotToken")
			)
			{
				const key = "results"; // name of the mongoDB collection
				return /*await*/ this._psychoJS.serverManager.uploadData(key, JSON.stringify(documents), sync);
			}
			else
			{
				util.offerDataForDownload("results.json", JSON.stringify(documents), "application/json");
			}
		}
	}

	/**
	 * Get the attribute names and values for the current trial of a given loop.
	 * <p> Only info relating to the trial execution are returned.</p>
	 *
	 * @protected
	 * @param {Object} loop - the loop
	 */
	static _getLoopAttributes(loop)
	{
		// standard trial attributes:
		const properties = ["thisRepN", "thisTrialN", "thisN", "thisIndex", "stepSizeCurrent", "ran", "order"];
		let attributes = {};
		const loopName = loop.name;
		for (const loopProperty in loop)
		{
			if (properties.includes(loopProperty))
			{
				const key = (loopProperty === "stepSizeCurrent") ? loopName + ".stepSize" : loopName + "." + loopProperty;
				attributes[key] = loop[loopProperty];
			}
		}

		// specific trial attributes:
		if (typeof loop.getCurrentTrial === "function")
		{
			const currentTrial = loop.getCurrentTrial();
			for (const trialProperty in currentTrial)
			{
				attributes[trialProperty] = currentTrial[trialProperty];
			}
		}

		/* TODO
		// method of constants
		if hasattr(loop, 'thisTrial'):
				trial = loop.thisTrial
				if hasattr(trial,'items'):#is a TrialList object or a simple dict
						for property,val in trial.items():
								if property not in self._paramNamesSoFar:
										self._paramNamesSoFar.append(property)
								names.append(property)
								vals.append(val)
				elif trial==[]:#we haven't had 1st trial yet? Not actually sure why this occasionally happens (JWP)
						pass
				else:
						names.append(loopName+'.thisTrial')
						vals.append(trial)

		// single StairHandler
		elif hasattr(loop, 'intensities'):
				names.append(loopName+'.intensity')
				if len(loop.intensities)>0:
						vals.append(loop.intensities[-1])
				else:
						vals.append(None)*/

		return attributes;
	}
}

/**
 * Experiment result format
 *
 * @enum {Symbol}
 * @readonly
 */
ExperimentHandler.SaveFormat = {
	/**
	 * Results are saved to a .csv file
	 */
	CSV: Symbol.for("CSV"),

	/**
	 * Results are saved to a database
	 */
	DATABASE: Symbol.for("DATABASE"),
};

/**
 * Experiment environment.
 *
 * @enum {Symbol}
 * @readonly
 */
ExperimentHandler.Environment = {
	SERVER: Symbol.for("SERVER"),
	LOCAL: Symbol.for("LOCAL"),
};