mirror of
https://github.com/psychopy/psychojs.git
synced 2025-05-10 10:40:54 +00:00
524 lines
17 KiB
HTML
524 lines
17 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>JSDoc: Source: data/ExperimentHandler.js</title>
|
|
|
|
<script src="scripts/prettify/prettify.js"> </script>
|
|
<script src="scripts/prettify/lang-css.js"> </script>
|
|
<!--[if lt IE 9]>
|
|
<script src="//html5shiv.googlecode.com/svn/trunk/html5.js"></script>
|
|
<![endif]-->
|
|
<link type="text/css" rel="stylesheet" href="styles/prettify-tomorrow.css">
|
|
<link type="text/css" rel="stylesheet" href="styles/jsdoc-default.css">
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<div id="main">
|
|
|
|
<h1 class="page-title">Source: data/ExperimentHandler.js</h1>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<section>
|
|
<article>
|
|
<pre class="prettyprint source linenums"><code>/**
|
|
* Experiment Handler
|
|
*
|
|
* @author Alain Pitiot
|
|
* @version 2021.2.0
|
|
* @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 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>
|
|
*
|
|
* @name module:data.ExperimentHandler
|
|
* @class
|
|
* @extends PsychObject
|
|
* @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.
|
|
*/
|
|
export class ExperimentHandler extends PsychObject
|
|
{
|
|
/**
|
|
* Getter for experimentEnded.
|
|
*
|
|
* @name module:data.ExperimentHandler#experimentEnded
|
|
* @function
|
|
* @public
|
|
*/
|
|
get experimentEnded()
|
|
{
|
|
return this._experimentEnded;
|
|
}
|
|
|
|
/**
|
|
* Setter for experimentEnded.
|
|
*
|
|
* @name module:data.ExperimentHandler#experimentEnded
|
|
* @function
|
|
* @public
|
|
*/
|
|
set experimentEnded(ended)
|
|
{
|
|
this._experimentEnded = ended;
|
|
}
|
|
|
|
/**
|
|
* Legacy experiment getters.
|
|
*/
|
|
get _thisEntry()
|
|
{
|
|
return this._currentTrialData;
|
|
}
|
|
|
|
get _entries()
|
|
{
|
|
return this._trialsData;
|
|
}
|
|
|
|
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>
|
|
*
|
|
* @name module:data.ExperimentHandler#isEntryEmpty
|
|
* @function
|
|
* @public
|
|
* @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>
|
|
*
|
|
* @name module:data.ExperimentHandler#addLoop
|
|
* @function
|
|
* @public
|
|
* @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.
|
|
*
|
|
* @name module:data.ExperimentHandler#removeLoop
|
|
* @function
|
|
* @public
|
|
* @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>
|
|
*
|
|
* @name module:data.ExperimentHandler#addData
|
|
* @function
|
|
* @public
|
|
* @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.
|
|
*
|
|
* @name module:data.ExperimentHandler#nextEntry
|
|
* @function
|
|
* @public
|
|
* @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>
|
|
*
|
|
* @name module:data.ExperimentHandler#save
|
|
* @function
|
|
* @public
|
|
* @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>
|
|
*
|
|
* @name module:data.ExperimentHandler#_getLoopAttributes
|
|
* @function
|
|
* @static
|
|
* @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
|
|
*
|
|
* @name module:core.ServerManager#SaveFormat
|
|
* @enum {Symbol}
|
|
* @readonly
|
|
* @public
|
|
*/
|
|
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
|
|
* @public
|
|
*/
|
|
ExperimentHandler.Environment = {
|
|
SERVER: Symbol.for("SERVER"),
|
|
LOCAL: Symbol.for("LOCAL"),
|
|
};
|
|
</code></pre>
|
|
</article>
|
|
</section>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
<nav>
|
|
<h2><a href="index.html">Home</a></h2><h3>Modules</h3><ul><li><a href="module-core.html">core</a></li><li><a href="module-data.html">data</a></li><li><a href="module-sound.html">sound</a></li><li><a href="module-util.html">util</a></li><li><a href="module-visual.html">visual</a></li></ul><h3>Classes</h3><ul><li><a href="FaceDetector_FaceDetector.html">FaceDetector</a></li><li><a href="module.data.MultiStairHandler.html">MultiStairHandler</a></li><li><a href="module.data.QuestHandler.html">QuestHandler</a></li><li><a href="module-core.BuilderKeyResponse.html">BuilderKeyResponse</a></li><li><a href="module-core.EventManager.html">EventManager</a></li><li><a href="module-core.GUI.html">GUI</a></li><li><a href="module-core.Keyboard.html">Keyboard</a></li><li><a href="module-core.KeyPress.html">KeyPress</a></li><li><a href="module-core.Logger.html">Logger</a></li><li><a href="module-core.MinimalStim.html">MinimalStim</a></li><li><a href="module-core.Mouse.html">Mouse</a></li><li><a href="module-core.PsychoJS.html">PsychoJS</a></li><li><a href="module-core.ServerManager.html">ServerManager</a></li><li><a href="module-core.Window.html">Window</a></li><li><a href="module-data.ExperimentHandler.html">ExperimentHandler</a></li><li><a href="module-data.MultiStairHandler.html">MultiStairHandler</a></li><li><a href="module-data.QuestHandler.html">QuestHandler</a></li><li><a href="module-data.Shelf.html">Shelf</a></li><li><a href="module-data.TrialHandler.html">TrialHandler</a></li><li><a href="module-sound.AudioClip.html">AudioClip</a></li><li><a href="module-sound.AudioClipPlayer.html">AudioClipPlayer</a></li><li><a href="module-sound.Microphone.html">Microphone</a></li><li><a href="module-sound.Sound.html">Sound</a></li><li><a href="module-sound.TonePlayer.html">TonePlayer</a></li><li><a href="module-sound.TrackPlayer.html">TrackPlayer</a></li><li><a href="module-sound.Transcriber.html">Transcriber</a></li><li><a href="module-sound.Transcript.html">Transcript</a></li><li><a href="module-util.Clock.html">Clock</a></li><li><a href="module-util.Color.html">Color</a></li><li><a href="module-util.CountdownTimer.html">CountdownTimer</a></li><li><a href="module-util.EventEmitter.html">EventEmitter</a></li><li><a href="module-util.MixinBuilder.html">MixinBuilder</a></li><li><a href="module-util.MonotonicClock.html">MonotonicClock</a></li><li><a href="module-util.PsychObject.html">PsychObject</a></li><li><a href="module-util.Scheduler.html">Scheduler</a></li><li><a href="module-visual.ButtonStim.html">ButtonStim</a></li><li><a href="module-visual.Camera.html">Camera</a></li><li><a href="module-visual.FaceDetector.html">FaceDetector</a></li><li><a href="module-visual.Form.html">Form</a></li><li><a href="module-visual.GratingStim.html">GratingStim</a></li><li><a href="module-visual.ImageStim.html">ImageStim</a></li><li><a href="module-visual.MovieStim.html">MovieStim</a></li><li><a href="module-visual.Polygon.html">Polygon</a></li><li><a href="module-visual.Rect.html">Rect</a></li><li><a href="module-visual.ShapeStim.html">ShapeStim</a></li><li><a href="module-visual.Slider.html">Slider</a></li><li><a href="module-visual.TextBox.html">TextBox</a></li><li><a href="module-visual.TextStim.html">TextStim</a></li><li><a href="module-visual.VisualStim.html">VisualStim</a></li></ul><h3>Interfaces</h3><ul><li><a href="module-sound.SoundPlayer.html">SoundPlayer</a></li></ul><h3>Mixins</h3><ul><li><a href="module-core.WindowMixin.html">WindowMixin</a></li><li><a href="module-util.ColorMixin.html">ColorMixin</a></li></ul>
|
|
</nav>
|
|
|
|
<br class="clear">
|
|
|
|
<footer>
|
|
Documentation generated by <a href="https://github.com/jsdoc/jsdoc">JSDoc 3.6.7</a> on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
|
|
</footer>
|
|
|
|
<script> prettyPrint(); </script>
|
|
<script src="scripts/linenumber.js"> </script>
|
|
</body>
|
|
</html>
|