1
0
mirror of https://github.com/psychopy/psychojs.git synced 2025-05-10 10:40:54 +00:00

Merge pull request #478 from apitiot/2022.1.0

FF completed the information available in the experiment data for MultiStairHandler
This commit is contained in:
Alain Pitiot 2022-02-11 11:02:56 +01:00 committed by GitHub
commit 0b1f318830
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 266 additions and 61 deletions

View File

@ -176,7 +176,7 @@ export class PsychoJS
}
this.logger.info("[PsychoJS] Initialised.");
this.logger.info("[PsychoJS] @version 2021.3.0");
this.logger.info("[PsychoJS] @version 2022.1.0");
// hide the initialisation message:
jQuery("#root").addClass("is-ready");
@ -312,7 +312,7 @@ export class PsychoJS
* @async
* @public
*/
async start({ configURL = "config.json", expName = "UNKNOWN", expInfo = {}, resources = [] } = {})
async start({ configURL = "config.json", expName = "UNKNOWN", expInfo = {}, resources = [], dataFileName } = {})
{
this.logger.debug();
@ -344,6 +344,7 @@ export class PsychoJS
this._experiment = new ExperimentHandler({
psychoJS: this,
extraInfo: expInfo,
dataFileName
});
// setup the logger:

View File

@ -50,6 +50,9 @@ export class ServerManager extends PsychObject
// resources is a map of <name: string, { path: string, status: ResourceStatus, data: any }>
this._resources = new Map();
this._nbLoadedResources = 0;
this._setupPreloadQueue();
this._addAttribute("autoLog", autoLog);
this._addAttribute("status", ServerManager.Status.READY);
@ -138,7 +141,9 @@ export class ServerManager extends PsychObject
const self = this;
return new Promise((resolve, reject) =>
{
const url = this._psychoJS.config.pavlovia.URL + "/api/v2/experiments/" + encodeURIComponent(self._psychoJS.config.experiment.fullpath) + "/sessions";
const url = this._psychoJS.config.pavlovia.URL
+ "/api/v2/experiments/" + this._psychoJS.config.gitlab.projectId
+ "/sessions";
jQuery.post(url, data, null, "json")
.done((data, textStatus) =>
{
@ -219,8 +224,9 @@ export class ServerManager extends PsychObject
this.setStatus(ServerManager.Status.BUSY);
// prepare DELETE query:
const url = this._psychoJS.config.pavlovia.URL + "/api/v2/experiments/" + encodeURIComponent(this._psychoJS.config.experiment.fullpath) + "/sessions/"
+ this._psychoJS.config.session.token;
const url = this._psychoJS.config.pavlovia.URL
+ "/api/v2/experiments/" + this._psychoJS.config.gitlab.projectId
+ "/sessions/" + this._psychoJS.config.session.token;
// synchronous query the pavlovia server:
if (sync)
@ -231,7 +237,7 @@ export class ServerManager extends PsychObject
request.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
request.send(JSON.stringify(data));
*/
/* This does not work in Chrome before of a CORS bug
/* This does not work in Chrome because of a CORS bug
await fetch(url, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json;charset=UTF-8' },
@ -318,30 +324,72 @@ export class ServerManager extends PsychObject
}
/****************************************************************************
* Get the status of a resource.
* Get the status of a single resource or the reduced status of an array of resources.
*
* <p>If an array of resources is given, getResourceStatus returns a single, reduced status
* that is the status furthest away from DOWNLOADED, with the status ordered as follow:
* ERROR (furthest from DOWNLOADED), REGISTERED, DOWNLOADING, and DOWNLOADED</p>
* <p>For example, given three resources:
* <ul>
* <li>if at least one of the resource status is ERROR, the reduced status is ERROR</li>
* <li>if at least one of the resource status is DOWNLOADING, the reduced status is DOWNLOADING</li>
* <li>if the status of all three resources is REGISTERED, the reduced status is REGISTERED</li>
* <li>if the status of all three resources is DOWNLOADED, the reduced status is DOWNLOADED</li>
* </ul>
* </p>
*
* @name module:core.ServerManager#getResourceStatus
* @function
* @public
* @param {string} name of the requested resource
* @return {core.ServerManager.ResourceStatus} status of the resource
* @throws {Object.<string, *>} exception if no resource with that name has previously been registered
* @param {string | string[]} names names of the resources whose statuses are requested
* @return {core.ServerManager.ResourceStatus} status of the resource if there is only one, or reduced status otherwise
* @throws {Object.<string, *>} if at least one of the names is not that of a previously
* registered resource
*/
getResourceStatus(name)
getResourceStatus(names)
{
const response = {
origin: "ServerManager.getResourceStatus",
context: "when getting the status of resource: " + name,
context: `when getting the status of resource(s): ${JSON.stringify(names)}`,
};
const pathStatusData = this._resources.get(name);
if (typeof pathStatusData === "undefined")
// sanity checks:
if (typeof names === 'string')
{
// throw { ...response, error: 'unknown resource' };
throw Object.assign(response, { error: "unknown resource" });
names = [names];
}
if (!Array.isArray(names))
{
throw Object.assign(response, { error: "names should be either a string or an array of strings" });
}
const statusOrder = new Map([
[Symbol.keyFor(ServerManager.ResourceStatus.ERROR), 0],
[Symbol.keyFor(ServerManager.ResourceStatus.REGISTERED), 1],
[Symbol.keyFor(ServerManager.ResourceStatus.DOWNLOADING), 2],
[Symbol.keyFor(ServerManager.ResourceStatus.DOWNLOADED), 3]
]);
let reducedStatus = ServerManager.ResourceStatus.DOWNLOADED;
for (const name of names)
{
const pathStatusData = this._resources.get(name);
if (typeof pathStatusData === "undefined")
{
// throw { ...response, error: 'unknown resource' };
throw Object.assign(response, {
error: `unable to find a previously registered resource with name: ${name}`
});
}
// update the reduced status according to the order given by statusOrder:
if (statusOrder.get(Symbol.keyFor(pathStatusData.status)) <
statusOrder.get(Symbol.keyFor(reducedStatus)))
{
reducedStatus = pathStatusData.status;
}
}
return pathStatusData.status;
return reducedStatus;
}
/****************************************************************************
@ -404,7 +452,7 @@ export class ServerManager extends PsychObject
* </ul>
*
* @name module:core.ServerManager#prepareResources
* @param {Array.<{name: string, path: string, download: boolean} | Symbol>} [resources=[]] - the list of resources
* @param {String | Array.<{name: string, path: string, download: boolean} | String | Symbol>} [resources=[]] - the list of resources or a single resource
* @function
* @public
*/
@ -424,9 +472,13 @@ export class ServerManager extends PsychObject
// register the resources:
if (resources !== null)
{
if (typeof resources === "string")
{
resources = [resources];
}
if (!Array.isArray(resources))
{
throw "resources should be an array of objects";
throw "resources should be either (a) a string or (b) an array of string or objects";
}
// whether all resources have been requested:
@ -471,6 +523,20 @@ export class ServerManager extends PsychObject
throw "resources must be manually specified when the experiment is running locally: ALL_RESOURCES cannot be used";
}
// convert those resources that are only a string to an object with name and path:
for (let r = 0; r < resources.length; ++r)
{
const resource = resources[r];
if (typeof resource === "string")
{
resources[r] = {
name: resource,
path: resource,
download: true
}
}
}
for (let { name, path, download } of resources)
{
if (!this._resources.has(name))
@ -504,19 +570,26 @@ export class ServerManager extends PsychObject
// download those registered resources for which download = true
// note: we return a Promise that will be resolved when all the resources are downloaded
return new Promise((resolve, reject) =>
if (resourcesToDownload.size === 0)
{
const uuid = this.on(ServerManager.Event.RESOURCE, (signal) =>
return Promise.resolve();
}
else
{
return new Promise((resolve, reject) =>
{
if (signal.message === ServerManager.Event.DOWNLOAD_COMPLETED)
const uuid = this.on(ServerManager.Event.RESOURCE, (signal) =>
{
this.off(ServerManager.Event.RESOURCE, uuid);
resolve();
}
});
if (signal.message === ServerManager.Event.DOWNLOAD_COMPLETED)
{
this.off(ServerManager.Event.RESOURCE, uuid);
resolve();
}
});
this._downloadResources(resourcesToDownload);
});
this._downloadResources(resourcesToDownload);
});
}
}
catch (error)
{
@ -987,8 +1060,6 @@ export class ServerManager extends PsychObject
count: resources.size,
});
this._nbLoadedResources = 0;
// based on the resource extension either (a) add it to the preload manifest, (b) mark it for
// download by howler, or (c) add it to the document fonts
const preloadManifest = [];
@ -1066,7 +1137,6 @@ export class ServerManager extends PsychObject
// start loading resources marked for preload.js:
if (preloadManifest.length > 0)
{
this._setupPreloadQueue(resources);
this._preloadQueue.loadManifest(preloadManifest);
}
else
@ -1175,10 +1245,14 @@ export class ServerManager extends PsychObject
* @name module:core.ServerManager#_setupPreloadQueue
* @function
* @protected
* @param {Set} resources - a set of names of previously registered resources
*/
_setupPreloadQueue(resources)
_setupPreloadQueue()
{
const response = {
origin: "ServerManager._setupPreloadQueue",
context: "when setting up a preload queue"
};
this._preloadQueue = new createjs.LoadQueue(true, "", true);
const self = this;
@ -1213,7 +1287,7 @@ export class ServerManager extends PsychObject
this._preloadQueue.addEventListener("complete", (event) =>
{
self._preloadQueue.close();
if (self._nbLoadedResources === resources.size)
if (self._nbLoadedResources === self._resources.size)
{
self.setStatus(ServerManager.Status.READY);
self.emit(ServerManager.Event.RESOURCE, {
@ -1336,6 +1410,11 @@ ServerManager.Status = {
* @public
*/
ServerManager.ResourceStatus = {
/**
* There was an error during downloading, or the resource is in an unknown state.
*/
ERROR: Symbol.for("ERROR"),
/**
* The resource has been registered.
*/
@ -1350,9 +1429,4 @@ ServerManager.ResourceStatus = {
* The resource has been downloaded.
*/
DOWNLOADED: Symbol.for("DOWNLOADED"),
/**
* There was an error during downloading, or the resource is in an unknown state.
*/
ERROR: Symbol.for("ERROR"),
};

View File

@ -68,12 +68,33 @@ export class ExperimentHandler extends PsychObject
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 = [];
@ -94,6 +115,7 @@ export class ExperimentHandler extends PsychObject
* @function
* @public
* @returns {boolean} whether or not the current entry is empty
* @todo This really should be renamed: IsCurrentEntryNotEmpty
*/
isEntryEmpty()
{
@ -278,15 +300,6 @@ export class ExperimentHandler extends PsychObject
}
}
// get various experiment info:
const info = this.extraInfo;
const __experimentName = (typeof info.expName !== "undefined") ? info.expName : this.psychoJS.config.experiment.name;
const __participant = ((typeof info.participant === "string" && info.participant.length > 0) ? info.participant : "PARTICIPANT");
const __session = ((typeof info.session === "string" && info.session.length > 0) ? info.session : "SESSION");
const __datetime = ((typeof info.date !== "undefined") ? info.date : MonotonicClock.getDateStr());
const gitlabConfig = this._psychoJS.config.gitlab;
const __projectId = (typeof gitlabConfig !== "undefined" && typeof gitlabConfig.projectId !== "undefined") ? gitlabConfig.projectId : undefined;
let data = this._trialsData;
// if the experiment data have to be cleared, we first make a copy of them:
if (clear)
@ -306,7 +319,8 @@ export class ExperimentHandler extends PsychObject
const csv = "\ufeff" + XLSX.utils.sheet_to_csv(worksheet);
// upload data to the pavlovia server or offer them for download:
const key = `${__participant}_${__experimentName}_${__datetime}${tag}.csv`;
const filenameWithoutPath = this._dataFileName.split(/[\\/]/).pop();
const key = `${filenameWithoutPath}${tag}.csv`;
if (
this._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER
&& this._psychoJS.config.experiment.status === "RUNNING"
@ -323,11 +337,20 @@ export class ExperimentHandler extends PsychObject
// 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, __participant, __session, __datetime };
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]];

View File

@ -111,10 +111,12 @@ export class MultiStairHandler extends TrialHandler
};
}
this._psychoJS.experiment.addData(this._name+'.response', response);
if (!this._finished)
{
// update the current staircase:
this._currentStaircase.addResponse(response, value);
// update the current staircase, but do not add the response again:
this._currentStaircase.addResponse(response, value, false);
// move onto the next trial:
this._nextTrial();
@ -206,6 +208,7 @@ export class MultiStairHandler extends TrialHandler
const args = Object.assign({}, condition);
args.psychoJS = this._psychoJS;
args.varName = this._varName;
// label becomes name:
args.name = condition.label;
args.autoLog = this._autoLog;
if (typeof condition.nTrials === "undefined")
@ -254,7 +257,7 @@ export class MultiStairHandler extends TrialHandler
// if the current pass is empty, get a new one:
if (this._currentPass.length === 0)
{
this._currentPass = this._staircases.filter(handler => !handler.finished);
this._currentPass = this._staircases.filter( handler => !handler.finished );
if (this._multiMethod === TrialHandler.Method.SEQUENTIAL)
{
@ -322,12 +325,48 @@ export class MultiStairHandler extends TrialHandler
{
if (typeof this._trialList[t] === "undefined")
{
this._trialList[t] = {[this._varName]: value};
this._trialList[t] = {
[this._name+"."+this._varName]: value,
[this._name+".intensity"]: value
};
for (const attribute of this._currentStaircase._userAttributes)
{
// "name" becomes "label" again:
if (attribute === "name")
{
this._trialList[t][this._name+".label"] = this._currentStaircase["_name"];
}
else if (attribute !== "trialList" && attribute !== "extraInfo")
{
this._trialList[t][this._name+"."+attribute] = this._currentStaircase["_" + attribute];
}
}
if (typeof this._snapshots[t] !== "undefined")
{
this._snapshots[t][this._varName] = value;
this._snapshots[t].trialAttributes.push(this._varName);
let fieldName = this._name + "." + this._varName;
this._snapshots[t][fieldName] = value;
this._snapshots[t].trialAttributes.push(fieldName);
fieldName = this._name + ".intensity";
this._snapshots[t][fieldName] = value;
this._snapshots[t].trialAttributes.push(fieldName);
for (const attribute of this._currentStaircase._userAttributes)
{
// "name" becomes "label" again:
if (attribute === 'name')
{
fieldName = this._name + ".label";
this._snapshots[t][fieldName] = this._currentStaircase["_name"];
this._snapshots[t].trialAttributes.push(fieldName);
}
else if (attribute !== 'trialList' && attribute !== 'extraInfo')
{
fieldName = this._name+"."+attribute;
this._snapshots[t][fieldName] = this._currentStaircase["_" + attribute];
this._snapshots[t].trialAttributes.push(fieldName);
}
}
}
break;
}

View File

@ -118,10 +118,12 @@ export class QuestHandler extends TrialHandler
* @public
* @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{number | undefined} value - optional intensity / contrast / threshold
* @param{boolean} [doAddData = true] - whether or not to add the response as data to the
* experiment
* @returns {void}
*/
addResponse(response, value)
addResponse(response, value, doAddData = true)
{
// check that response is either 0 or 1:
if (response !== 0 && response !== 1)
@ -133,6 +135,11 @@ export class QuestHandler extends TrialHandler
};
}
if (doAddData)
{
this._psychoJS.experiment.addData(this._name + '.response', response);
}
// update the QUEST pdf:
if (typeof value !== "undefined")
{
@ -145,7 +152,10 @@ export class QuestHandler extends TrialHandler
if (!this._finished)
{
// estimate the next value of the QUEST variable (and update the trial list and snapshots):
this.next();
// estimate the next value of the QUEST variable
// (and update the trial list and snapshots):
this._estimateQuestValue();
}
}

View File

@ -688,7 +688,7 @@ export class TrialHandler extends PsychObject
context: "when preparing a sequence of trials",
};
// get an array of the indices of the elements of trialList :
// get an array of the indices of the elements of trialList:
const indices = Array.from(this.trialList.keys());
if (this._method === TrialHandler.Method.SEQUENTIAL)

View File

@ -1433,3 +1433,58 @@ export function extensionFromMimeType(mimeType)
return '.dat';
}
/**
* Get an estimate of the download speed, by repeatedly downloading an image file from a distant
* server.
*
* @name module:util.getDownloadSpeed
* @function
* @public
* @param {PsychoJS} psychoJS the instance of PsychoJS
* @param {number} [nbDownloads = 1] the number of image downloads over which to average
* the download speed
* @return {number} the download speed, in megabits per second
*/
export async function getDownloadSpeed(psychoJS, nbDownloads = 1)
{
// url of the image to download and size of the image in bits:
// TODO use a variety of files, with different sizes
const imageUrl = "https://upload.wikimedia.org/wikipedia/commons/a/a6/Brandenburger_Tor_abends.jpg";
const imageSize_b = 2707459 * 8;
return new Promise( (resolve, reject) =>
{
let downloadTimeAccumulator = 0;
let downloadCounter = 0;
const download = new Image();
download.onload = () =>
{
const toc = performance.now();
downloadTimeAccumulator += (toc-tic);
++ downloadCounter;
if (downloadCounter === nbDownloads)
{
const speed_bps = (imageSize_b * nbDownloads) / (downloadTimeAccumulator / 1000);
resolve(speed_bps / 1024 / 1024);
}
else
{
tic = performance.now();
download.src = `${imageUrl}?salt=${tic}`;
}
}
download.onerror = (event) =>
{
const errorMsg = `unable to estimate the download speed: ${JSON.stringify(event)}`;
psychoJS.logger.error(errorMsg);
reject(errorMsg);
}
let tic = performance.now();
download.src = `${imageUrl}?salt=${tic}`;
});
}

View File

@ -145,7 +145,10 @@ export class VisualStim extends util.mix(MinimalStim).with(WindowMixin)
if (hasChanged)
{
let radians = -ori * 0.017453292519943295;
this._rotationMatrix = [[Math.cos(radians), -Math.sin(radians)], [Math.sin(radians), Math.cos(radians)]];
this._rotationMatrix = [
[Math.cos(radians), -Math.sin(radians)],
[Math.sin(radians), Math.cos(radians)]
];
this._onChange(true, true)();
}