diff --git a/.gitignore b/.gitignore index 8c8d2b39..dc777dee 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ # Don't check-in any of these data-files /data-files/debugging_db.db /data-files/g3d-license.txt -/data-files/log.txt +/data-files/*log.txt /data-files/*.db /data-files/*.csv /data-files/*.pdn diff --git a/data-files/samples/adaptive_stimulus.Experiment.Any b/data-files/samples/adaptive_stimulus.Experiment.Any new file mode 100644 index 00000000..e83919a6 --- /dev/null +++ b/data-files/samples/adaptive_stimulus.Experiment.Any @@ -0,0 +1,78 @@ +{ + description = "Adaptive Stimulus Example"; + + // This must be logged for the adaptation script to work! + trialParametersToLog: ["frameRate"]; + + // 1-hit fast firing weapon + weapon = { + firePeriod = 0.1; + damagePerSecond = 10; + }; + + sessions = [ + { + id = "Intro"; + description = "A session that introduces the conditions"; + + referenceTargetInitialFeedback = "This session familiarizes you with the frame rates used in this experiment"; + sessionCompleteFeedback = "Introduction complete, please let the experimenter know if you could not notice a difference!"; + + // Single intro task + tasks = [ + { + id = "intro"; + trialOrders = [ + { order = ["60", "108"]; } + ]; + } + ]; + + // Use 2 named trials to introduce conditions + trials = [ + { + id = "60"; + frameRate = 60; + trialSuccessFeedback = "This was an example of a 60Hz trial"; + targetIds = ["moving", "moving", "moving"]; + }, + { + id = "108"; + frameRate = 108; + trialSuccessFeedback = "This was an example of a 108Hz trial"; + targetIds = ["moving", "moving", "moving"]; + } + ] + }, + { + id = "AdaptFrameRate"; + description = "An example session dynamically adapting frame rate"; + + referenceTargetInitialFeedback = "This session adapts frame rate based on your answers to questions\nRespond to each question to the best of your ability!"; + taskFailureFeedback = "Successfully completed the task!"; // Since we do not provide a correct answer task will always "fail" + + // The task array handles all creation of trials here (no trials array required) + tasks = [ + { + id = "fr_adapt"; + type = "adaptive"; // Required to indicate this is adaptive + count = 4; + + // This command will be called to generate a new trials.Any file + adaptationCmd = "python samples/framerate_adapt_sample.py"; + } + ]; + } + ]; + + targets = [ + { + "id": "moving", + "axisLocked": [ false, false, true ], + "destSpace": "player", + "motionChangePeriod": [ 2, 3 ], + "speed": [ 7, 10 ], + "visualSize": [ 0.05, 0.05 ] + } + ]; +} \ No newline at end of file diff --git a/data-files/samples/adaptive_stimulus.Status.Any b/data-files/samples/adaptive_stimulus.Status.Any new file mode 100644 index 00000000..bfbbd8fa --- /dev/null +++ b/data-files/samples/adaptive_stimulus.Status.Any @@ -0,0 +1,11 @@ +{ + currentUser = "Sample User"; + sessions = ( "Intro", "AdaptFrameRate"); + settingsVersion = 1; + users = ( + { + id = "Sample User"; + sessions = ( "Intro", "AdaptFrameRate"); + } ); + +} \ No newline at end of file diff --git a/data-files/samples/adaptive_stimulus/fpsci_results.py b/data-files/samples/adaptive_stimulus/fpsci_results.py new file mode 100644 index 00000000..3da79f0e --- /dev/null +++ b/data-files/samples/adaptive_stimulus/fpsci_results.py @@ -0,0 +1,101 @@ +import sqlite3 # Used for db queries + +# Utility methods +def runQuery(db, query): + # Handle option for db as filename instead of sqlite3.Connection + if type(db) is str: db = sqlite3.connect(db) + c = db.cursor(); c.execute(query) + return c.fetchall() + +def unpack_results(rows): + if len(rows) > 0 and len(rows[0]) == 1: + # Make single-item rows a single array of values + for i, row in enumerate(rows): rows[i] = row[0] + return rows + +def runQueryAndUnpack(db, query): return unpack_results(runQuery(db, query)) + +# Run a query that returns a single result (first value) +def runSingleResultQuery(db, query): + rows = runQuery(db, query) + if len(rows) == 0: return None + else: return rows[0][0] + +# User methods +def getLastResultsFilename(dir): + import glob, os + files = list(glob.glob(os.path.join(dir, '*.db'))) + if len(files) == 0: return None + files.sort(key=lambda x: os.path.getmtime(x), reverse=True) + return files[0] + +def getLastResultsDb(path): + return sqlite3.connect(getLastResultsFilename(path)) + +def getUserFromDb(db): + if type(db) is str: return db.split('_')[-2] + elif type(db) is sqlite3.Connection: + return runSingleResultQuery(db, 'SELECT subject_id FROM Users LIMIT 1') + +def getCurrentTaskId(db): return runSingleResultQuery(db, 'SELECT task_id FROM Tasks ORDER BY rowid DESC LIMIT 1') + +def getCurrentTaskIndex(db, taskId=None): + q = 'SELECT task_index FROM Tasks ' + if taskId is not None: q += f'WHERE task_id is "{taskId}" ' + q += 'ORDER BY rowid DESC LIMIT 1' + return runSingleResultQuery(db, q) + +def getTaskStartTime(db, taskId=None, taskIndex=None): + q = 'SELECT start_time FROM Tasks ' + if taskId is not None: + q += f'WHERE task_id is "{taskId}" ' + if taskIndex is not None: q += f'AND task_index is {taskIndex} ' + q += 'ORDER BY rowid DESC LIMIT 1' + return runSingleResultQuery(db, q) + +def getTrialCountByTask(db, taskId=None, taskIndex=None): + q = 'SELECT trials_complete FROM Tasks ' + if taskId is not None: + q += f'WHERE task_id is "{taskId}" ' + if taskIndex is not None: q += f'AND task_index is {taskIndex} ' + q += 'ORDER BY rowid DESC LIMIT 1' + count = runSingleResultQuery(db, q) + if count is None: return 0 + return count + +def getTotalTrialCount(db): + rows = runQuery(db, f'SELECT rowid FROM Trials') + if rows is None: return 0 + return len(rows) + +def getLastQuestionResponses(db, n=1, taskId=None, taskIdx=None, startTime=None): + # You should specify a task id in order to filter on index + if taskId is None and taskIdx is not None: raise Exception("Task index was provided but no task id!") + # If no task ID is specified check all records + q = 'SELECT response FROM Questions ' + if taskId is not None: + q += f'WHERE task_id is "{taskId}" ' + if taskIdx is not None: q += f'AND task_index is {taskIdx} ' + if startTime is not None: q += f'AND time > "{startTime}" ' + q += f'ORDER BY rowid DESC LIMIT {n}' + if n == 1: return runSingleResultQuery(db, q) + else: return runQueryAndUnpack(db, q) + +def getLastTrialIds(db, n=1, taskId=None, taskIndex=None): + q = f'SELECT trial_id FROM Trials ' + if taskId is not None: + q += f'WHERE task_id is "{taskId}" ' + if taskIndex is not None: q += f'AND task_index is {taskIndex} ' + q += f'ORDER BY rowid DESC LIMIT {n}' + + if n == 1: return runSingleResultQuery(db, q) + else: return runQueryAndUnpack(db, q) + +def getLastTrialParams(db, paramName, n=1, taskId=None, taskIndex=None): + q = f'SELECT {paramName} FROM Trials ' + if taskId is not None: + q += f'WHERE task_id is "{taskId}" ' + if taskIndex is not None: q += f'AND task_index is {taskIndex} ' + q += f'ORDER BY rowid DESC LIMIT {n}' + if n == 1: return runSingleResultQuery(db, q) + else: return runQueryAndUnpack(db, q) diff --git a/data-files/samples/adaptive_stimulus/fpsci_task_gen.py b/data-files/samples/adaptive_stimulus/fpsci_task_gen.py new file mode 100644 index 00000000..0a63011a --- /dev/null +++ b/data-files/samples/adaptive_stimulus/fpsci_task_gen.py @@ -0,0 +1,62 @@ +VERBOSE = True # This sets whether additional checks are performed and warnings/errors printed + +# An empty task (task complete signal) with optional progress value +def emptyTaskConfig(progress=None): + config = { + 'trials': [], + } + if progress is not None: config['progress'] = progress + return config + +# A task which sets a single parameter to an array of values and asks a question +def singleParamTaskConfig(param, values, targetIds, questions, progress=None, questionIdx=None, correctAnswer=None, description=None): + # Build base config + config = { + 'trials': singleParamTrialArray(param, values, targetIds), + 'questions': questions, + } + # Add optional fields + if progress is not None: config['progress'] = progress + if description is not None: config['description'] = description + if questionIdx is not None: config['questionIndex'] = questionIdx + if correctAnswer is not None: config['correctAnswer'] = correctAnswer + return config + +# Note this method returns a trials array, not a complete task config! +def singleParamTrialArray(param, values, targetIds, randomizeOrder=False): + if VERBOSE: + if type(targetIds) is not list: print(f'ERROR: Provided targetIds must be a list (is a {type(targetIds)})!') + if len(values) == 0: print('ERROR: No values provided!') + trials = [] + if randomizeOrder: + import random + random.shuffle(values) + for val in values: + # Create a trial + trials.append({ + 'id': f'{val}', + f'{param}': val, + 'targetIds': targetIds # This assumes identical targets for all trials + }) + return trials + +# Set the target ids for a specific trial (allows customization of targets on a per-trial basis) +def setTrialTargetIds(config, trialId, targetIds): + if type(targetIds) is not list and VERBOSE: + raise f'Provided targetIds must be a list (is a {type(targetIds)})!' + # Find the trial in the config array and modify its targetIds field + for t in config['trials']: + if t['id'] == trialId: + t['targetIds'] = targetIds # Update the targetIds + return + raise f'Could not find trial with id {trialId}!' + +# Write this configuration to an any file (defaults to trials.Any) +def writeToAny(path, config, fname='trials.Any'): + import os, json + # Add filename if missing from provided path + if os.path.isdir(path): path = os.path.join(path, fname) + if not path.endswith('Any') and VERBOSE: print('WARNING: Results should be written to a ".Any" file!') + # Dump JSON to file + with open(path, 'w') as f: json.dump(config, f, indent=4) + diff --git a/data-files/samples/framerate_adapt_sample.py b/data-files/samples/framerate_adapt_sample.py new file mode 100644 index 00000000..bca53d2d --- /dev/null +++ b/data-files/samples/framerate_adapt_sample.py @@ -0,0 +1,131 @@ +import adaptive_stimulus.fpsci_results as results +import adaptive_stimulus.fpsci_task_gen as taskgen +import numpy as np + +# File paths +RESULTS_DIR = './results/' # Path to current results directory +CONFIG_DIR = './' # Path to output trials.Any file +OUTPUT_LOG = 'pylog.txt' # Path to write log of runtime to (for debug) + +# Adaptation constants +BASE_FRAMERATE_HZ = 60 # The base frame rate to compare to +INITIAL_STEP_PERCENT = 80 # The amount of the base frame rate to step up/down (negative to step down) + +# Stop conditions +ND_TO_END = 4 # The amount of answers of "these are the same condition" to terminate +MAX_QUESTIONS = 100 # An alternate end crtiera (must be > ND_TO_END!) + +# The (single) question configuration for our experiment (could be a list) +QUESTION = [ + { + 'type': 'MultipleChoice', + 'prompt': 'Was this trial different than the previous one?', + 'options': ['yes', 'no'], + 'optionKeys': ['y', 'n'], + } +] + +# Targets to use for all trials (referenced from experiment config) +TARGETS = ["moving", "moving", "moving"] + +# Redirect print output to log file +import sys +orig_stdout = sys.stdout +f = open(OUTPUT_LOG, 'a+') # Open in append mode (keep previous results) +sys.stdout = f + +# Trap case for when configuration above is incorrect +if MAX_QUESTIONS < ND_TO_END: + print("ERROR: Must have ND_END < MAX_QUESTIONS!") + exit() + +# Get results file(name) and print it to console +from datetime import datetime +fname = results.getLastResultsFilename(RESULTS_DIR) +print(f'\nReading {fname} @ {datetime.now()}...') + +# Open results database +db = results.getLastResultsDb(RESULTS_DIR) + +# 2 ways to get the task id (hard code or query database) +TASK_ID = 'fr_adapt' # Hard-coding this requires keeping it in-sync with the config file, but avoids edge-cases +# TASK_ID = results.getCurrentTaskId(db) # (Alternative) read whatever the most recent task_id is from the experiment config file (should be written by FPSci) +# if TASK_ID is None: print('WARNING: Did not find a valid task_id in the database!') + +TASK_INDEX = results.getCurrentTaskIndex(db, TASK_ID) +if TASK_INDEX is None: + print(f'WARNING: Did not find a valid task_index for task {TASK_ID}, using 0 instead!') + TASK_INDEX = 0 +print(f'Using task id: "{TASK_ID}" and index: {TASK_INDEX}') + +# Print trial status (informational only) +total_trials = results.getTotalTrialCount(db) +task_trial_cnt = results.getTrialCountByTask(db, TASK_ID) +idx_trial_cnt = results.getTrialCountByTask(db, TASK_ID, TASK_INDEX) +print(f'Results database contains a total of {total_trials} trial(s). {task_trial_cnt} ({idx_trial_cnt}) for this task (index)!') + +# Use time to filter out only questions that come from our current task iteration +START_TIME = results.getTaskStartTime(db, TASK_ID, TASK_INDEX) +print(f'Filtering responses using task start time of: {START_TIME}') + +# Get answers for questions from our task (used for generating next task) +answers = results.getLastQuestionResponses(db, MAX_QUESTIONS, TASK_ID, TASK_INDEX, START_TIME) +print(f'Got {len(answers)} responses from results file...') + +# Check various termination conditions +done = False # Flag indicating the task is complete +if(len(answers) >= MAX_QUESTIONS): + # We have asked too many questions (forced end w/o convergence) + print('Termination criteria reached: maximum question count!') + done = True +elif len(answers) >= ND_TO_END: + # Check for enough "no difference" responses to end + done = True + for a in answers[:ND_TO_END]: + # If any answer is "yes there is a difference" we are not done + if 'yes' in a.lower(): done = False; break + if done: print(f'Termination criteria reached: {ND_TO_END} consectuive "no difference" responses!') + +# Create an empty task config (signals end of adaptation if unpopulated) +config = taskgen.emptyTaskConfig() + +# Update the next set of trials based on previous responses +if not done: + if(len(answers) == 0): # First time running this task (create initial conditions) + print(f"No responses from task {TASK_ID} creating first set...") + otherRate = BASE_FRAMERATE_HZ * (1.0+INITIAL_STEP_PERCENT/100.0) + rates = [BASE_FRAMERATE_HZ, otherRate] + else: # At least 1 previous trial has been run + # Try to get last 2 rates from the + try: lastRates = results.getLastTrialParams(db, 'frameRate', 2, TASK_ID, TASK_INDEX) # Get last 2 frame rates from trials table + except: + print('ERROR: Could not get "frameRate" parameter from previous trials, make sure it is in your experiment/session trialParamsToLog!') + exit() # Cannot recover from this condition (don't know previous frame rates)... + lastRates = [float(r) for r in lastRates] # Convert to float + print(f"Found last trial rates of {lastRates[0]} and {lastRates[1]} Hz") + + #TODO: Replace this logic in the future (code decides how many answers were "yes I can tell a difference" required to adapt) + answerCount = min(len(answers), ND_TO_END) + ndRatio = sum([1 for a in answers[:answerCount] if 'no' in a.lower()]) / answerCount + print(f'{100*ndRatio}% of last {answerCount} responses were "no difference"...') + + if ndRatio >= 0.5: # Step away from the baseline + print('Mostly "no difference": keep last levels') + rates = lastRates + else: # Step towards the baseline + print('Mostly report a difference: move towards baseline') + rates = [np.average(lastRates), BASE_FRAMERATE_HZ] + + print(f'Next rates will be {rates}Hz...') + + # Generate task (w/ shuffled condition order) + import random; random.shuffle(rates) + config = taskgen.singleParamTaskConfig('frameRate', rates, TARGETS, QUESTION, correctAnswer='Yes') + +# Write task config to trials.Any file +taskgen.writeToAny(CONFIG_DIR, config) +print('Wrote config to trials.Any!') + +# Restore original stdout +sys.stdout = orig_stdout +f.close() diff --git a/docs/experimentConfigReadme.md b/docs/experimentConfigReadme.md index 54d9cddc..39300979 100644 --- a/docs/experimentConfigReadme.md +++ b/docs/experimentConfigReadme.md @@ -100,12 +100,20 @@ FPSci tasks are a way to affiliate trials into meaningful grouping with repeat l Tasks are configured using the following parameters: - `id` an identifier for the task, used for logging +- `type` can be `"constant"` for constant stimulus (uses trial orders) or `"adaptive"` for [script-based adaptive stimlulus](#adaptive-stimulus) + +The fields below are used for `"constant"` `type` tasks and ignored for `"adaptive"` tasks - `trialOrders` an array of valid orderings of trials (referenced by `id`) with each order consisting of: - `order` the set of trials (in order) to be presented in this order - `correctAnswer` the correct answer to respond to (the final) multiple choice question in the task (see [below](#task-successfailure-criteria)) - `questions` a question(s) asked after each `trialOrder` is completed - `count` a number of times to repeat each of the `trialOrders` specified in this task +The fields below are used for `"adaptive"` `type` tasks and ignored for `"constant"` tasks +- `adaptationCmd` the command to run to adapt stimulus +- `adaptationConfigPath` is the path to get the adaptive configuration from after running the `adaptationCmd` (default is `trials.Any`) +- `removeAdaptationConfig` controls whether FPSci removes the adaptiation config from disk after reading it (preventing accidental "double reads"). This defaults to true. + Note that rather than being a direct `Array>` the `trialOrders` array needs to be an `Array` in order to parse correctly in the Any format. To do this the `trialOrders` array requires specification of an `order` array within it to accomplish this nesting, as demonstrated in the example below. For example a task may specify presenting 2 trials (in either order) then asking a question comparing them. An example of this configuration is provided below: @@ -131,6 +139,13 @@ tasks = [ // Repeat each order 10 times (20 total sets of 2 trials) count = 10; }, + { + id = "adaptive"; // Example of adaptive task + + adaptationCmd = "python samples/framerate_adapt_sample.py"; // Must be provided + adaptationConfigPath = "trials.Any"; // Default value is "trials.Any" in data-files directory + removeAdaptationConfig = true; // Default is to remove the trials.Any file after read + } ] ``` @@ -146,6 +161,19 @@ When no `tasks` are specified in a session configuration, FPSci treats trials as As described [above](#session-configuration) the `randomizeTaskOrder` session-level configuration parameter allows the experiment designer to select tasks in either the order they are specified or a random order. To support legacy configuration `randomizeTrialOrder` is treated as equivalent to `randomizeTaskOrder` in FPSci, but is overwritten when `randomizeTaskOrder` is defined in the config. Trial order randomization within `order`s in tasks is current not supported in FPSci (all orders need to be specified explicitly). +#### Adaptive Stimulus +FPSci implements adaptive stimulus using a scripting-based flow. The `adaptationCmd` is called whenever the set of trials provided by a previous call is empty. FPSci expects this command to write a short trial config to the `adaptationConfigPath` (or `trials.Any` by deafult). The fields of this adaptive config file are as follows: + +- `trials` is the only required field in the config and specifies a list of trials, using any desired [general config](general_config.md) +- `progress` is the progress to report in this task, since this cannot be determined by FPSci, if unspecified `progress` is `fnan` +- `questions` is a list of questions to ask following completing all trials presented in the `trials` array +- `questionIndex` is the index of the question to assess correctness based upon (defaults to `-1` or the final trial) +- `correctAnswer` the answer to declare as "correct" for the question indexed by `questionIndex` + +The adaptive stimulus script indicates that the task is complete by returning an empty `trials` array. + +Note that for adaptive stimulus scripting that requires feedback from the application the adaptation script needs to read the FPSci results database to get its + ### Trial Configuration Trials provide the lowest level of [general configuration](general_configuration.md) in FPSci. Trials are specified with the following parameters: - `id` is a name to refer to this trial by in logging (and in [tasks](#task-configuration)) diff --git a/source/FPSciApp.cpp b/source/FPSciApp.cpp index 990f9321..d9735c2e 100644 --- a/source/FPSciApp.cpp +++ b/source/FPSciApp.cpp @@ -1485,7 +1485,7 @@ void FPSciApp::onUserInput(UserInput* ui) { } - playerCamera->filmSettings().setSensitivity(sceneBrightness); + if(notNull(playerCamera)) playerCamera->filmSettings().setSensitivity(sceneBrightness); END_PROFILER_EVENT(); } @@ -1635,7 +1635,7 @@ void FPSciApp::oneFrame() { // The debug camera is not in the scene, so we have // to explicitly pose it. This actually does nothing, but // it allows us to trigger the TAA code. - playerCamera->onPose(m_posed3D); + if(notNull(playerCamera)) playerCamera->onPose(m_posed3D); } m_poseWatch.tock(); END_PROFILER_EVENT(); diff --git a/source/FpsConfig.h b/source/FpsConfig.h index cd660722..0e1fab91 100644 --- a/source/FpsConfig.h +++ b/source/FpsConfig.h @@ -309,6 +309,7 @@ class CommandSpec { bool blocking = false; ///< Flag to indicate to block on this process complete CommandSpec() {} + CommandSpec(const String& cmd, const bool foreground, const bool block): cmdStr(cmd), foreground(foreground), blocking(block) {}; CommandSpec(const Any& any); Any toAny(const bool forceAll = true) const; diff --git a/source/Logger.cpp b/source/Logger.cpp index bfd58f5c..fb4d1dd5 100644 --- a/source/Logger.cpp +++ b/source/Logger.cpp @@ -50,6 +50,14 @@ void FPSciLogger::initResultsFile(const String& filename, logPrintf(("Error opening log file: " + filename).c_str()); // Write an error to the log } + // Build up array of all (additional) trial parameters to log + m_trialParams = sessConfig->logger.trialParamsToLog; + for (const TrialConfig& t : sessConfig->trials) { + for (const String& p : t.logger.trialParamsToLog) { + if (!m_trialParams.contains(p)) m_trialParams.append(p); + } + } + // Create tables if a new log file is opened if (createNewFile) { createExperimentsTable(expConfigFilename); @@ -57,16 +65,7 @@ void FPSciLogger::initResultsFile(const String& filename, createTasksTable(); createTargetTypeTable(); createTargetsTable(); - - // Build up array of all trial parameters to log - m_trialParams = sessConfig->logger.trialParamsToLog; - for (const TrialConfig& t : sessConfig->trials) { - for (const String& p : t.logger.trialParamsToLog) { - if (!m_trialParams.contains(p)) m_trialParams.append(p); - } - } createTrialsTable(m_trialParams); - createTargetTrajectoryTable(); createPlayerActionTable(); createFrameInfoTable(); @@ -251,6 +250,7 @@ void FPSciLogger::createTasksTable() { void FPSciLogger::addTask(const String& sessId, const int blockIdx, const String& taskId, const int taskIdx, const Array& trialOrder) { m_taskTimeStr = genUniqueTimestamp(); + String trialOrderStr = trialOrder.length() > 0 ? "'" + Any(trialOrder).unparse() + "'" : "NULL"; const RowEntry taskValues = { "'" + sessId + "'", String(std::to_string(blockIdx)), @@ -258,7 +258,7 @@ void FPSciLogger::addTask(const String& sessId, const int blockIdx, const String String(std::to_string(taskIdx)), "'" + m_taskTimeStr + "'", "NULL", - "'" + Any(trialOrder).unparse() + "'", + trialOrderStr, "0", "0" }; @@ -311,6 +311,7 @@ void FPSciLogger::createTargetTrajectoryTable() { } void FPSciLogger::recordTargetLocations(const Array& locations) { + if (locations.length() == 0) return; Array rows; for (const auto& loc : locations) { String stateStr = presentationStateToString(loc.state); @@ -344,6 +345,7 @@ void FPSciLogger::createPlayerActionTable() { } void FPSciLogger::recordPlayerActions(const Array& actions) { + if (actions.length() == 0) return; Array rows; for (PlayerAction action : actions) { String stateStr = presentationStateToString(action.state); @@ -384,6 +386,7 @@ void FPSciLogger::createFrameInfoTable() { } void FPSciLogger::recordFrameInfo(const Array& frameInfo) { + if (frameInfo.length() == 0) return; Array rows; for (FrameInfo info : frameInfo) { Array frameValues = { @@ -546,10 +549,12 @@ void FPSciLogger::loggerThreadEntry() recordPlayerActions(playerActions); recordTargetLocations(targetLocations); - insertRowsIntoDB(m_db, "Questions", questions); - insertRowsIntoDB(m_db, "Targets", targets); - insertRowsIntoDB(m_db, "Users", users); - insertRowsIntoDB(m_db, "Trials", trials); + if(questions.length() > 0) insertRowsIntoDB(m_db, "Questions", questions); + if(targets.length() > 0) insertRowsIntoDB(m_db, "Targets", targets); + if(users.length() > 0) insertRowsIntoDB(m_db, "Users", users); + if(trials.length() > 0) insertRowsIntoDB(m_db, "Trials", trials); + + if (m_flushNow) m_flushNow = false; lk.lock(); } diff --git a/source/Logger.h b/source/Logger.h index 320c2c6e..f5a5da7d 100644 --- a/source/Logger.h +++ b/source/Logger.h @@ -149,7 +149,7 @@ class FPSciLogger : public ReferenceCountedObject { void logTargetTypes(const Array>& targets); /** Wakes up the logging thread and flushes even if the buffer limit is not reached yet. */ - void flush(bool blockUntilDone); + void flush(bool blockUntilDone=false); /** Generate a timestamp for logging */ static String genUniqueTimestamp(); diff --git a/source/Session.cpp b/source/Session.cpp index efe614d6..9f7289a9 100644 --- a/source/Session.cpp +++ b/source/Session.cpp @@ -72,41 +72,61 @@ Any TrialConfig::toAny(const bool forceAll) const { TaskConfig::TaskConfig(const Any& any) { FPSciAnyTableReader reader(any); reader.get("id", id, "Tasks must be provided w/ an \"id\" field!"); - reader.get("trialOrders", trialOrders, "Tasks must be provided w/ trial orders!"); reader.getIfPresent("count", count); - reader.getIfPresent("questions", questions); - - // Get the list of options from the final multiple choice question - Array options; - for (int i = 0; i < questions.size(); i++) { - // Use options from the final multiple choice question - if (questions[i].type == Question::Type::MultipleChoice) { - questionIdx = i; - options = questions[i].options; - } + // Get task type + String typeStr; + if (reader.getIfPresent("type", typeStr)) { + if (!typeStr.compare("adaptive")) type = TaskType::adaptive; } - - // Check that each trial order has a valid correctAnswer (if specified) - for (TrialOrder& order : trialOrders) { - if (!order.correctAnswer.empty()) { - if (options.size() == 0) { - throw format("Task \"%s\" specifies a \"correctAnswer\" but no multiple choice questions are specified!", id); + if (type == TaskType::constant) { + // Get constant stimulus parameters + reader.get("trialOrders", trialOrders, "Constant stimulus tasks must be provided w/ trial orders!"); + reader.getIfPresent("questions", questions); + + // Get the list of options from the final multiple choice question + Array options; + for (int i = 0; i < questions.size(); i++) { + // Use options from the final multiple choice question + if (questions[i].type == Question::Type::MultipleChoice) { + questionIdx = i; + options = questions[i].options; } - // This order has a specified correct answer - if (!options.contains(order.correctAnswer)) { - throw format("The specfied \"correctAnswer\" (\"%s\") for task \"%s\" is not a valid option for the final multiple choice question in this task!", order.correctAnswer, id); + } + + // Check that each trial order has a valid correctAnswer (if specified) + for (TrialOrder& order : trialOrders) { + if (!order.correctAnswer.empty()) { + if (options.size() == 0) { + throw format("Task \"%s\" specifies a \"correctAnswer\" but no multiple choice questions are specified!", id); + } + // This order has a specified correct answer + if (!options.contains(order.correctAnswer)) { + throw format("The specfied \"correctAnswer\" (\"%s\") for task \"%s\" is not a valid option for the final multiple choice question in this task!", order.correctAnswer, id); + } } } } + else if(type == TaskType::adaptive){ + // Get adaptive stimulus parameters + reader.get("adaptationCmd", adaptCmd); + reader.getIfPresent("adaptationConfigPath", adaptConfigPath); + reader.getIfPresent("removeAdaptationConfig", removeAdaptConfig); + } } Any TaskConfig::toAny(const bool forceAll) const { TaskConfig def; Any a(Any::TABLE); a["id"] = id; - a["trialOrders"] = trialOrders; - if (forceAll || def.count != count) a["count"] = count; - if (forceAll || questions.length() > 0) a["questions"] = questions; + if (type == TaskType::constant) { + a["trialOrders"] = trialOrders; + if (forceAll || def.count != count) a["count"] = count; + if (forceAll || questions.length() > 0) a["questions"] = questions; + } + else if (type == TaskType::adaptive) { + a["adaptationCmd"] = adaptCmd; + if (forceAll || def.adaptConfigPath != adaptConfigPath) a["adaptationConfigPath"] = adaptConfigPath; + } return a; } @@ -115,6 +135,7 @@ SessionConfig::SessionConfig(const Any& any) : FpsConfig(any, defaultConfig()) { FPSciAnyTableReader reader(any); Set uniqueIds; + bool allAdaptive = true; switch (settingsVersion) { case 1: TrialConfig::defaultConfig() = (FpsConfig)(*this); // Setup the default configuration for trials here @@ -126,20 +147,30 @@ SessionConfig::SessionConfig(const Any& any) : FpsConfig(any, defaultConfig()) { reader.getIfPresent("randomizeTaskOrder", randomizeTaskOrder); reader.getIfPresent("weightByCount", weightByCount); reader.getIfPresent("blockCount", blockCount); - reader.get("trials", trials, format("Issues in the (required) \"trials\" array for session: \"%s\"", id)); - for (int i = 0; i < trials.length(); i++) { - if (trials[i].id.empty()) { // Look for trials without an id - trials[i].id = String(std::to_string(i)); // Autoname w/ index - } - uniqueIds.insert(trials[i].id); - if (uniqueIds.size() != i + 1) { - logPrintf("ERROR: Duplicate trial ID \"%s\" found (trials without IDs are assigned an ID equal to their index in the trials array)!\n", trials[i].id); + reader.getIfPresent("tasks", tasks); + + for (TaskConfig& task : tasks) { + if (task.type != TaskType::adaptive) { + allAdaptive = false; + break; } } - if (uniqueIds.size() != trials.size()) { - throw "Duplicate trial IDs found in experiment config. Check log.txt for details!"; + if (!allAdaptive) { + // Only get and check trials when not all tasks are adaptive + reader.get("trials", trials, format("Issues in the (required) \"trials\" array for session: \"%s\"", id)); + for (int i = 0; i < trials.length(); i++) { + if (trials[i].id.empty()) { // Look for trials without an id + trials[i].id = String(std::to_string(i)); // Autoname w/ index + } + uniqueIds.insert(trials[i].id); + if (uniqueIds.size() != i + 1) { + logPrintf("ERROR: Duplicate trial ID \"%s\" found (trials without IDs are assigned an ID equal to their index in the trials array)!\n", trials[i].id); + } + } + if (uniqueIds.size() != trials.size()) { + throw "Duplicate trial IDs found in experiment config. Check log.txt for details!"; + } } - reader.getIfPresent("tasks", tasks); break; default: debugPrintf("Settings version '%d' not recognized in SessionConfig.\n", settingsVersion); @@ -164,6 +195,55 @@ Any SessionConfig::toAny(const bool forceAll) const { return a; } +bool Session::adaptStimulus(const String& adaptCmd) { + const TaskConfig& task = m_sessConfig->tasks[m_currTaskIdx]; + // Make sure any results are written to disk (make this blocking in the future) + logger->flush(); + + // Build and run blocking command to update task + CommandSpec cmd = CommandSpec(adaptCmd, false, true); + runCommand(cmd, "Adaptive stimulus update"); + + // Get fields from Any + Any a = Any::fromFile(task.adaptConfigPath); + AnyTableReader reader(a); + reader.getIfPresent("progress", m_adaptiveProgress); + reader.getIfPresent("questions", m_adaptiveQuestions); + reader.getIfPresent("questionIndex", m_adaptiveQuestionIndex); + reader.getIfPresent("correctAnswer", m_adaptiveCorrectAnswer); + reader.get("trials", m_adaptiveTrials, format("The provided adaptive stimulus config file (%s) does not contain any trials!", m_sessConfig->tasks[m_currTaskIdx].adaptConfigPath).c_str()); + + // Remove Any file if requested (avoids duplicate reads) + if (task.removeAdaptConfig) remove(task.adaptConfigPath.c_str()); + + if (m_adaptiveTrials.length() == 0) { // Script returned no new trials (end of adaptive task) + m_completedTasks[m_currTaskIdx][0] += 1; // Mark this task (single order) as complete + m_remainingTasks[m_currTaskIdx][0] -= 1; // Clear the remaining tasks (was -1 to indicate unknown) + m_adaptiveTrialCount = 0; // Clear the adaptive trial count for this task + return true; // Return true to indicate complete + } + + m_adaptiveTargetConfigs.clear(); + m_currTrialIdx = -1; // Pre-decrement so incrememnt brings this to 0 for the first trial index + + if (m_adaptiveQuestionIndex == -1) { + m_adaptiveQuestionIndex = m_adaptiveQuestions.size() - 1; // Use final question if unspecified + } + + // Build up m_taskTrials array and manage completed + for (int i = 0; i < m_adaptiveTrials.size(); i++) { + m_taskTrials.insert(0, createShared(m_adaptiveTrials[i])); + m_adaptiveTargetConfigs.append(Array>()); + m_completedTaskTrials.set(m_adaptiveTrials[i].id, 0); + // Build targets array here + for (const String& id : m_taskTrials[i]->targetIds) { + m_adaptiveTargetConfigs[i].append(m_app->experimentConfig.getTargetConfigById(id)); + } + } + + return false; +} + float SessionConfig::getTrialOrdersPerBlock(void) const { float count = 0.f; if (tasks.length() == 0) { @@ -212,7 +292,7 @@ Session::Session(FPSciApp* app) : m_app(app), m_weapon(app->weapon) { const RealTime Session::targetFrameTime() { const RealTime defaultFrameTime = 1.0 / m_app->window()->settings().refreshRate; - if (!m_hasSession) return defaultFrameTime; + if (!m_hasSession || isNull(m_trialConfig)) return defaultFrameTime; uint arraySize = m_trialConfig->render.frameTimeArray.size(); if (arraySize > 0) { @@ -239,65 +319,104 @@ const RealTime Session::targetFrameTime() return defaultFrameTime; } -bool Session::nextTrial() { - // Do we need to create a new task? - if(m_taskTrials.length() == 0) { - // Build an array of unrun tasks in this block - Array> unrunTaskIdxs; - for (int i = 0; i < m_remainingTasks.size(); i++) { - for (int j = 0; j < m_remainingTasks[i].size(); j++) { - if (m_remainingTasks[i][j] > 0 || m_remainingTasks[i][j] == -1) { - if (m_sessConfig->randomizeTaskOrder && m_sessConfig->weightByCount) { - // Weight by remaining count of this trial type - for (int k = 0; k < m_remainingTasks[i][j]; k++) { - unrunTaskIdxs.append(Array(i,j)); // Add proportional to the # of times this task needs to be run +bool Session::nextTrial(const bool init) { + const TaskConfig& task = m_sessConfig->tasks.size() == 0 ? TaskConfig() : m_sessConfig->tasks[m_currTaskIdx]; + bool adaptiveDone = false; + if (m_taskTrials.length() == 0) { // We are out of trials, load a new task + if (task.type == TaskType::adaptive && !init) { // We are in an existing task (won't need to do any task math) + int lastTrialCount = m_adaptiveTrialCount; // This will be cleared by the following call (if done) + adaptiveDone = adaptStimulus(task.adaptCmd); // This method handles reading from the resulting input file + if (adaptiveDone) { + logger->updateTaskEntry(lastTrialCount, true); // Make sure this is marked as completed here + } + } + // If we have finished a constant stimulus trial order or the adaptive stimulus is done advance to a new task + if (task.type == TaskType::constant || adaptiveDone || init) { + // Build an array of unrun tasks in this block + Array> unrunTaskIdxs; + for (int i = 0; i < m_remainingTasks.size(); i++) { + for (int j = 0; j < m_remainingTasks[i].size(); j++) { + if (m_remainingTasks[i][j] > 0 || m_remainingTasks[i][j] == -1) { + if (m_sessConfig->randomizeTaskOrder && m_sessConfig->weightByCount) { + const int iterCount = m_remainingTasks[i][j] != -1 ? m_remainingTasks[i][j] : 1; + // Weight by remaining count of this trial type + for (int k = 0; k < iterCount; k++) { + unrunTaskIdxs.append(Array(i, j)); // Add proportional to the # of times this task needs to be run + } + } + else { + // Add a single instance for each trial that is unrun (don't weight by remaining count) + unrunTaskIdxs.append(Array(i, j)); } - } - else { - // Add a single instance for each trial that is unrun (don't weight by remaining count) - unrunTaskIdxs.append(Array(i,j)); } } } - } - if (unrunTaskIdxs.size() == 0) return false; // If there are no remaining tasks return - - // Pick the new task and trial order index - int idx = 0; - // Are we randomizing task order (or randomizing trial order when trials are treated as tasks)? - if (m_sessConfig->randomizeTaskOrder) { - idx = Random::common().integer(0, unrunTaskIdxs.size() - 1); // Pick a random trial from within the array - } - m_currTaskIdx = unrunTaskIdxs[idx][0]; - m_currOrderIdx = unrunTaskIdxs[idx][1]; - - m_completedTaskTrials.clear(); - // Populate the task trial and completed task trials array - Array trialIds; - String taskId; - if (m_sessConfig->tasks.size() == 0) { - // There are no tasks in this session, we need to create this task based on a single trial - trialIds = Array(m_sessConfig->trials[m_currTaskIdx].id); - taskId = m_sessConfig->trials[m_currTaskIdx].id; - } - else { - trialIds = m_sessConfig->tasks[m_currTaskIdx].trialOrders[m_currOrderIdx].order; - taskId = m_sessConfig->tasks[m_currTaskIdx].id; - } - for (const String& trialId : trialIds) { - m_taskTrials.insert(0, m_sessConfig->getTrialIndex(trialId)); // Insert trial at the front of the task trials - m_completedTaskTrials.set(trialId, 0); - } + if (unrunTaskIdxs.size() == 0) return false; // If there are no remaining tasks return - // Add task to tasks table in database - logger->addTask(m_sessConfig->id, m_currBlock-1, taskId, getTaskCount(m_currTaskIdx), trialIds); + // Pick the new task and trial order index + int idx = 0; + // Are we randomizing task order (or randomizing trial order when trials are treated as tasks)? + if (m_sessConfig->randomizeTaskOrder) { + idx = Random::common().integer(0, unrunTaskIdxs.size() - 1); // Pick a random trial from within the array + } + m_currTaskIdx = unrunTaskIdxs[idx][0]; + m_currOrderIdx = unrunTaskIdxs[idx][1]; + m_completedTaskTrials.clear(); + + const TaskConfig& task = m_sessConfig->tasks.size() == 0 ? TaskConfig() : m_sessConfig->tasks[m_currTaskIdx]; + + // Populate the task trial and completed task trials array + if (task.type == TaskType::constant) { + Array trialIds; + String taskId; + if (m_sessConfig->tasks.size() == 0) { + // There are no tasks in this session, we need to create this task based on a single trial + trialIds = Array(m_sessConfig->trials[m_currTaskIdx].id); + taskId = m_sessConfig->trials[m_currTaskIdx].id; + } + else { + trialIds = task.trialOrders[m_currOrderIdx].order; + taskId = task.id; + } + for (const String& trialId : trialIds) { + int trialIdx = m_sessConfig->getTrialIndex(trialId); + m_taskTrials.insert(0, createShared(m_sessConfig->trials[trialIdx])); // Insert trial at the front of the task trials + m_completedTaskTrials.set(trialId, 0); + } + + // Add task to tasks table in database + logger->addTask(m_sessConfig->id, m_currBlock - 1, taskId, getTaskCount(m_currTaskIdx), trialIds); + } + else if (task.type == TaskType::adaptive) { + // This case handles when we need to transition to a new adaptive stimulus task (re-adapt stimulus here b/c previous call was empty) + if (init || adaptiveDone) { + // Write the new task to the database (allow script to read it on call) + logger->addTask(m_sessConfig->id, m_currBlock - 1, task.id, getTaskCount(m_currTaskIdx), Array()); + } + // Adapt stimulus for a new task here + adaptiveDone = adaptStimulus(task.adaptCmd); + if(adaptiveDone) { + // TODO: Decide what to do when this new task returns an empty result... + } + } + } } - // Select the next trial from this task (pop trial from back of task trails) - m_currTrialIdx = m_taskTrials.pop(); + // Check for no new trial (can come from adaptive stimulus case being finished) + if (m_taskTrials.length() == 0) return false; // Get and update the trial configuration - m_trialConfig = TrialConfig::createShared(m_sessConfig->trials[m_currTrialIdx]); + m_trialConfig = m_taskTrials.pop(); + + // Select the next trial from this task (pop trial from back of task trails) + if (task.type == TaskType::constant) { + m_currTrialIdx = m_sessConfig->getTrialIndex(m_trialConfig->id); + } + else if(task.type == TaskType::adaptive){ + m_currTrialIdx += 1; // Increment the index for adaptive stimulus + m_adaptiveTrialCount += 1; // Count the overall trials presented + } + // Respawn player for first trial in session (override session-level spawn position) m_app->updateTrial(m_trialConfig, false, m_firstTrial); if (m_firstTrial) m_firstTrial = false; @@ -347,25 +466,28 @@ bool Session::nextBlock(bool init) { } } } - else { + else { // Note all adaptive stimlulus follows this case for (int i = 0; i < m_sessConfig->tasks.size(); i++) { if (init) { m_completedTasks.append(Array()); // Initialize task-level completed trial orders array m_remainingTasks.append(Array()); // Initialize task-level remaining trial orders array } - for (int j = 0; j < m_sessConfig->tasks[i].trialOrders.size(); j++) { + // Use a single order for adaptive stimulus, allow multiple orders for constant stimulus + const TaskConfig& task = m_sessConfig->tasks[i]; + const int orderCnt = task.type == TaskType::constant ? task.trialOrders.size() : 1; // Only 1 order for adaptive tasks + for (int j = 0; j < orderCnt; j++) { if (init) { m_completedTasks[i].append(0); // Zero completed count - m_remainingTasks[i].append(m_sessConfig->tasks[i].count); // Append to complete count + m_remainingTasks[i].append(Array(task.count)); // Append to complete count } else { m_completedTasks[i][j] = 0; - m_remainingTasks[i][j] += m_sessConfig->tasks[i].count; + m_remainingTasks[i][j] += task.count; } } } } - return nextTrial(); + return nextTrial(true); } void Session::onInit(String filename, String description) { @@ -398,17 +520,29 @@ void Session::onInit(String filename, String description) { // Iterate over the sessions here and add a config for each m_targetConfigs = m_app->experimentConfig.getTargetsByTrial(m_sessConfig->id); - nextBlock(true); + if (!nextBlock(true)) { // No new block to run go straight to feedback + currentState = PresentationState::sessionFeedback; + m_askQuestions = false; + } } else { // Invalid session, move to displaying message currentState = PresentationState::sessionFeedback; + m_askQuestions = false; } } void Session::randomizePosition(const shared_ptr& target) const { static const Point3 initialSpawnPos = m_camera->frame().translation; - const int trialIdx = m_sessConfig->getTrialIndex(m_trialConfig->id); - shared_ptr config = m_targetConfigs[trialIdx][target->paramIdx()]; + shared_ptr config; + if (m_sessConfig->tasks[m_currTaskIdx].type == TaskType::constant) { + const int trialIdx = m_sessConfig->getTrialIndex(m_trialConfig->id); + config = m_targetConfigs[trialIdx][target->paramIdx()]; + } + else { + for (shared_ptr tConfig : m_adaptiveTargetConfigs[m_currTrialIdx]) { + if (tConfig->id == target->id()) config = tConfig; + } + } const bool isWorldSpace = config->destSpace == "world"; Point3 loc; @@ -476,11 +610,15 @@ void Session::initTargetAnimation(const bool task) { } void Session::spawnTrialTargets(Point3 initialSpawnPos, bool previewMode) { + const TaskConfig& task = m_sessConfig->tasks.size() == 0 ? TaskConfig() : m_sessConfig->tasks[m_currTaskIdx]; + const Array> trialTargetConfigs = task.type == TaskType::constant ? m_targetConfigs[m_currTrialIdx] : m_adaptiveTargetConfigs[m_currTrialIdx]; + // Iterate through the targets - for (int i = 0; i < m_targetConfigs[m_currTrialIdx].size(); i++) { + for (int i = 0; i < trialTargetConfigs.size(); i++) { const Color3 previewColor = m_trialConfig->targetView.previewColor; - shared_ptr target = m_targetConfigs[m_currTrialIdx][i]; - const String name = format("%s_%d_%d_%d_%s_%d", m_sessConfig->id, m_currTaskIdx, m_currOrderIdx, m_completedTasks[m_currTaskIdx][m_currOrderIdx], target->id, i); + const shared_ptr target = trialTargetConfigs[i]; + const int trialIdx = task.type == TaskType::constant ? m_completedTasks[m_currTaskIdx][m_currOrderIdx] : m_adaptiveTrialCount - 1; + const String name = format("%s_%d_%d_%d_%s_%d", m_sessConfig->id, m_currTaskIdx, m_currOrderIdx, trialIdx, target->id, i); const float spawn_eccV = (target->symmetricEccV ? randSign() : 1) * Random::common().uniform(target->eccV[0], target->eccV[1]); const float spawn_eccH = (target->symmetricEccH ? randSign() : 1) * Random::common().uniform(target->eccH[0], target->eccH[1]); @@ -526,13 +664,14 @@ void Session::processResponse() { recordTrialResponse(m_destroyedTargets, totalTargets); // Record the trial response into the database // Update completed/remaining task state - if (m_taskTrials.size() == 0) { // Task is complete update tracking - m_completedTasks[m_currTaskIdx][m_currOrderIdx] += 1; // Mark task trial order as completed + const TaskConfig& task = m_sessConfig->tasks.size() == 0 ? TaskConfig() : m_sessConfig->tasks[m_currTaskIdx]; + if (m_taskTrials.size() == 0 && task.type == TaskType::constant) { // Task is complete update tracking + m_completedTasks[m_currTaskIdx][m_currOrderIdx] += 1; // Mark task trial order as completed if constant (task isn't necessarily done if adaptive) if (m_remainingTasks[m_currTaskIdx][m_currOrderIdx] > 0) { // Remove task trial order from remaining m_remainingTasks[m_currTaskIdx][m_currOrderIdx] -= 1; } } - m_completedTaskTrials[m_trialConfig->id] += 1; // Incrememnt count of this trial type in task + m_completedTaskTrials[m_trialConfig->id] += 1; // Increment count of this trial type in task // This update is only used for completed trials if (notNull(logger)) { @@ -545,14 +684,14 @@ void Session::processResponse() { } bool taskComplete = true; for (int remaining : m_remainingTasks[i]) { - if (remaining > 0) { + if (remaining != 0) { taskComplete = false; break; } } if (taskComplete) { completeTasks += 1; } } - + if (task.type == TaskType::adaptive) completeTrials = m_adaptiveTrialCount; // Update session entry in database logger->updateSessionEntry(m_currBlock > m_sessConfig->blockCount, completeTasks, completeTrials); } @@ -640,27 +779,34 @@ void Session::updatePresentationState() { m_feedbackMessage = ""; // Clear the feedback message if (m_taskTrials.length() == 0) newState = PresentationState::taskQuestions; // Move forward to providing task-level feedback else { // Individual trial complete, go back to reference target - logger->updateTaskEntry(m_sessConfig->tasks[m_currTaskIdx].trialOrders[m_currOrderIdx].order.size() - m_taskTrials.size(), false); - nextTrial(); - newState = PresentationState::referenceTarget; + const TaskConfig& task = m_sessConfig->tasks.size() == 0 ? TaskConfig() : m_sessConfig->tasks[m_currTaskIdx]; + const int completeTrials = task.type == TaskType::constant ? + task.trialOrders[m_currOrderIdx].order.size() - m_taskTrials.size() : + m_adaptiveTrialCount; + logger->updateTaskEntry(completeTrials, false); + if (!nextTrial()) newState = PresentationState::taskQuestions; // No new trials are available, move to task questions + else newState = PresentationState::referenceTarget; } } } } else if (currentState == PresentationState::taskQuestions) { bool allAnswered = true; + TaskConfig task; if (m_sessConfig->tasks.size() > 0) { + task = m_sessConfig->tasks[m_currTaskIdx]; // Only ask questions if a task is specified (otherwise trial-level questions have already been presented) - allAnswered = presentQuestions(m_sessConfig->tasks[m_currTaskIdx].questions); + allAnswered = task.type == TaskType::constant ? presentQuestions(task.questions) : presentQuestions(m_adaptiveQuestions); } if (allAnswered) { m_currQuestionIdx = -1; // Reset the question index - if (m_sessConfig->tasks.size() > 0) { - const int qIdx = m_sessConfig->tasks[m_currTaskIdx].questionIdx; - bool success = true; // Assume success (for case with no questions) - if (qIdx >= 0 && !m_sessConfig->tasks[m_currTaskIdx].trialOrders[m_currOrderIdx].correctAnswer.empty()) { - // Update the success value if a valid question was asked and correctAnswer was given - success = m_sessConfig->tasks[m_currTaskIdx].questions[qIdx].result == m_sessConfig->tasks[m_currTaskIdx].trialOrders[m_currOrderIdx].correctAnswer; + if (m_sessConfig->tasks.size() > 0 && task.questions.size() > 0) { + const int qIdx = task.type == TaskType::constant ? task.questionIdx : m_adaptiveQuestionIndex; + const String& correctAnswer = task.type == TaskType::constant ? task.trialOrders[m_currOrderIdx].correctAnswer : m_adaptiveCorrectAnswer; + const String& result = task.type == TaskType::constant ? task.questions[qIdx].result : m_adaptiveQuestions[qIdx].result; + bool success = true; // Assume success (for case with no questions) + if (qIdx >= 0 && !correctAnswer.empty()) { // Update the success value if a valid question was asked and correctAnswer was given + success = result == correctAnswer; } if (success) m_feedbackMessage = formatFeedback(m_sessConfig->feedback.taskSuccess); else m_feedbackMessage = formatFeedback(m_sessConfig->feedback.taskFailure); @@ -672,8 +818,12 @@ void Session::updatePresentationState() { if (stateElapsedTime > m_sessConfig->timing.taskFeedbackDuration) { m_feedbackMessage = ""; int completeTrials = 1; // Assume 1 completed trial (if we don't have specified tasks) - if (m_sessConfig->tasks.size() > 0) completeTrials = m_sessConfig->tasks[m_currTaskIdx].trialOrders[m_currOrderIdx].order.size() - m_taskTrials.size(); - logger->updateTaskEntry(completeTrials, true); + const TaskConfig& task = m_sessConfig->tasks.size() == 0 ? TaskConfig() : m_sessConfig->tasks[m_currTaskIdx]; + if (m_sessConfig->tasks.size() > 0) { + completeTrials = task.type == TaskType::constant ? task.trialOrders[m_currOrderIdx].order.size() - m_taskTrials.size() : m_adaptiveTrialCount; + } + const bool complete = task.type == TaskType::constant ? true : false; + logger->updateTaskEntry(completeTrials, complete); if (blockComplete()) { m_currBlock++; if (m_currBlock > m_sessConfig->blockCount) { // End of session (all blocks complete) @@ -687,15 +837,16 @@ void Session::updatePresentationState() { } else { // Individual trial complete, go back to reference target m_feedbackMessage = ""; // Clear the feedback message - nextTrial(); - newState = PresentationState::referenceTarget; + if (!nextTrial()) newState = PresentationState::sessionFeedback; + else newState = PresentationState::referenceTarget; } } } else if (currentState == PresentationState::sessionFeedback) { if (m_hasSession) { if (stateElapsedTime > m_sessConfig->timing.sessionFeedbackDuration && (!m_sessConfig->timing.sessionFeedbackRequireClick || !m_app->shootButtonUp)) { - bool allAnswered = presentQuestions(m_sessConfig->questionArray); // Ask session-level questions + bool allAnswered = true; + if(m_askQuestions) allAnswered = presentQuestions(m_sessConfig->questionArray); // Ask session-level questions if (allAnswered) { // Present questions until done here // Write final session timestamp to log if (notNull(logger) && m_sessConfig->logger.enable) { @@ -808,8 +959,10 @@ void Session::onSimulation(RealTime rdt, SimTime sdt, SimTime idt) { updatePresentationState(); // 2. Record target trajectories, view direction trajectories, and mouse motion. - accumulateTrajectories(); - accumulateFrameInfo(rdt, sdt, idt); + if (notNull(m_trialConfig)) { + accumulateTrajectories(); + accumulateFrameInfo(rdt, sdt, idt); + } } bool Session::presentQuestions(Array& questions) { @@ -826,7 +979,8 @@ bool Session::presentQuestions(Array& questions) { if (m_sessConfig->logger.enable) { // Log the question and its answer if (currentState == PresentationState::trialFeedback) { // End of trial question, log trial id and index - logger->addQuestion(questions[m_currQuestionIdx], m_sessConfig->id, m_app->dialog, m_sessConfig->tasks[m_currTaskIdx].id, m_lastTaskIndex, m_trialConfig->id, m_completedTaskTrials[m_trialConfig->id]-1); + const String taskId = m_sessConfig->tasks.size() == 0 ? "" : m_sessConfig->tasks[m_currTaskIdx].id; + logger->addQuestion(questions[m_currQuestionIdx], m_sessConfig->id, m_app->dialog, taskId, m_lastTaskIndex, m_trialConfig->id, m_completedTaskTrials[m_trialConfig->id]-1); } else if (currentState == PresentationState::taskQuestions) { // End of task question, log task id (no trial info) @@ -857,10 +1011,9 @@ bool Session::presentQuestions(Array& questions) { void Session::recordTrialResponse(int destroyedTargets, int totalTargets) { if (!m_sessConfig->logger.enable) return; // Skip this if the logger is disabled - if (m_trialConfig->logger.logTrialResponse) { - String taskId; - if (m_sessConfig->tasks.size() == 0) taskId = m_trialConfig->id; - else taskId = m_sessConfig->tasks[m_currTaskIdx].id; + if (m_trialConfig->logger.logTrialResponse) { + const String taskId = m_sessConfig->tasks.size() == 0 ? m_trialConfig->id : m_sessConfig->tasks[m_currTaskIdx].id; + const int trialIdx = m_sessConfig->tasks.size() == 0 || m_sessConfig->tasks[m_currTaskIdx].type == TaskType::constant ? m_completedTasks[m_currTaskIdx][m_currOrderIdx] : m_adaptiveTrialCount - 1; // Get the (unique) index for this run of the task m_lastTaskIndex = getTaskCount(m_currTaskIdx); // Trials table. Record trial start time, end time, and task completion time. @@ -870,7 +1023,7 @@ void Session::recordTrialResponse(int destroyedTargets, int totalTargets) { "'" + taskId + "'", String(std::to_string(m_lastTaskIndex)), "'" + m_trialConfig->id + "'", - String(std::to_string(m_completedTasks[m_currTaskIdx][m_currOrderIdx])), + String(std::to_string(trialIdx)), "'" + m_taskStartTime + "'", "'" + m_taskEndTime + "'", String(std::to_string(m_pretrialDuration)), @@ -964,29 +1117,33 @@ float Session::getRemainingTrialTime() { float Session::getProgress() { if (notNull(m_sessConfig)) { - // Get progress across tasks - float remainingTrialOrders = 0.f; - for (Array trialOrderCounts : m_remainingTasks) { - for (int orderCount : trialOrderCounts) { - if (orderCount < 0) return 0.f; // Infinite trials, never make any progress - remainingTrialOrders += (float)orderCount; + if (m_sessConfig->tasks[m_currTaskIdx].type == TaskType::adaptive) { + return m_adaptiveProgress; + } + else { // Get progress across constant tasks + float remainingTrialOrders = 0.f; + for (Array trialOrderCounts : m_remainingTasks) { + for (int orderCount : trialOrderCounts) { + if (orderCount < 0) return 0.f; // Infinite trials, never make any progress + remainingTrialOrders += (float)orderCount; + } } - } - // Get progress in current task - int completedTrialsInOrder = 0; - int totalTrialsInOrder = 1; // If there aren't tasks specified there is always 1 trial in this order (single order/trial task) - if(m_sessConfig->tasks.size() > 0) totalTrialsInOrder = m_sessConfig->tasks[m_currTaskIdx].trialOrders[m_currOrderIdx].order.length(); - for (const String& trialId : m_completedTaskTrials.getKeys()) { - completedTrialsInOrder += m_completedTaskTrials[trialId]; - } - float currTaskProgress = (float) completedTrialsInOrder / (float) totalTrialsInOrder; - - // Start by getting task-level progress (based on m_remainingTrialOrders) - float overallProgress = 1.f - (remainingTrialOrders / m_sessConfig->getTrialOrdersPerBlock()); - // Special case to avoid "double counting" completed tasks (if incomplete add progress in the current task, if complete it has been counted) - if (currTaskProgress < 1) overallProgress += currTaskProgress / m_sessConfig->getTrialOrdersPerBlock(); - return overallProgress; + // Get progress in current task + int completedTrialsInOrder = 0; + int totalTrialsInOrder = 1; // If there aren't tasks specified there is always 1 trial in this order (single order/trial task) + if (m_sessConfig->tasks.size() > 0) totalTrialsInOrder = m_sessConfig->tasks[m_currTaskIdx].trialOrders[m_currOrderIdx].order.length(); + for (const String& trialId : m_completedTaskTrials.getKeys()) { + completedTrialsInOrder += m_completedTaskTrials[trialId]; + } + float currTaskProgress = (float)completedTrialsInOrder / (float)totalTrialsInOrder; + + // Start by getting task-level progress (based on m_remainingTrialOrders) + float overallProgress = 1.f - (remainingTrialOrders / m_sessConfig->getTrialOrdersPerBlock()); + // Special case to avoid "double counting" completed tasks (if incomplete add progress in the current task, if complete it has been counted) + if (currTaskProgress < 1) overallProgress += currTaskProgress / m_sessConfig->getTrialOrdersPerBlock(); + return overallProgress; + } } return fnan(); } diff --git a/source/Session.h b/source/Session.h index 7a776c1a..64ff25c2 100644 --- a/source/Session.h +++ b/source/Session.h @@ -167,13 +167,27 @@ class TrialOrder { } }; +enum class TaskType { + constant, + adaptive +}; + class TaskConfig { public: - String id; ///< Task ID (used for logging) - Array trialOrders; ///< Valid trial orders for this task - Array questions; ///< Task-level questions - int questionIdx = -1; // The index of the (task-level) question corresponding to the correct answer above (populated automatically) - int count = 1; ///< Count of times to present this task (in each of the trialOrders, currently unused) + String id; ///< Task ID (used for logging) + TaskType type = TaskType::constant; ///< The type of this task (constant vs adaptive) + + // Constant task type parameters + Array trialOrders; ///< Valid trial orders for this task + Array questions; ///< Task-level questions + int questionIdx = -1; ///< The index of the (task-level) question corresponding to the correct answer above (populated automatically) + + // Adaptive task type parameters + String adaptCmd; ///< For adaptive tasks, the command to call for adaptation + String adaptConfigPath = "trials.Any"; ///< For adaptive tasks, the input Any file to read for newly adapted trial(s) + bool removeAdaptConfig = true; ///< For adaptive tasks, remove the adaptive config after consuming it + + int count = 1; ///< Count of times to present this task (in each of the trialOrders, currently unused) TaskConfig() {}; TaskConfig(const Any& any); @@ -228,6 +242,7 @@ class Session : public ReferenceCountedObject { int m_trialShotsHit = 0; ///< Count of total hits in this trial bool m_firstTrial = true; ///< Is this the first trial in this session? bool m_hasSession; ///< Flag indicating whether psych helper has loaded a valid session + bool m_askQuestions = true; ///< Semaphore used to skip session-level questions when no task is presented int m_currBlock = 1; ///< Index to the current block of trials String m_feedbackMessage; ///< Message to show when trial complete @@ -239,17 +254,27 @@ class Session : public ReferenceCountedObject { Array> m_hittableTargets; ///< Array of targets that can be hit Array> m_unhittableTargets; ///< Array of targets that can't be hit + float m_adaptiveProgress = fnan(); ///< Progress reported in last adaptive feedback + int m_adaptiveTrialCount = 0; ///< Count of total adaptive trials + Array m_adaptiveQuestions; ///< Questions provided by adaptive feedback + int m_adaptiveQuestionIndex = -1; ///< Correct answer to adaptive stimulus question + String m_adaptiveCorrectAnswer; ///< Correct answer to adaptive stimulus question + Array m_adaptiveTrials; ///< Storage for adaptive trials + Array>> m_adaptiveTargetConfigs; ///< Array of adaptive target configs (dynamic) + Table m_lastLogTargetLoc; ///< Last logged target location (used for logOnChange) Point3 m_lastRefTargetPos; ///< Last reference target location (used for aim invalidation) int m_frameTimeIdx = 0; ///< Frame time index - int m_currTaskIdx; ///< Current task index (from tasks array) - int m_currOrderIdx; ///< Current trial order index - int m_currTrialIdx; ///< Current trial index (from the trials array) + int m_currTaskIdx = 0; ///< Current task index (from tasks array) + int m_currOrderIdx = 0; ///< Current trial order index + int m_currTrialIdx = 0; ///< Current trial index (from the trials array) int m_currQuestionIdx = -1; ///< Current question index - Array m_taskTrials; ///< Indexes of trials (from trials array) for this task + + Array> m_taskTrials; ///< Pointers to trials for this task Array> m_remainingTasks; ///< Count of remaining trials of each order Array> m_completedTasks; ///< Count of completions of each trial order + int m_lastTaskIndex = 0; ///< Used for providing task index on questions Table m_completedTaskTrials; ///< Count of completed trial types in this task Array>> m_targetConfigs; ///< Target configurations by trial @@ -317,6 +342,8 @@ class Session : public ReferenceCountedObject { m_sessProcesses.clear(); } + + bool adaptStimulus(const String& adaptCmd); String formatFeedback(const String& input); String formatCommand(const String& input); bool presentQuestions(Array& questions); @@ -327,7 +354,9 @@ class Session : public ReferenceCountedObject { /** Get the total target count for the current trial */ int totalTrialTargets() const { int totalTargets = 0; - for (shared_ptr target : m_targetConfigs[m_currTrialIdx]) { + const TaskConfig& task = m_sessConfig->tasks.size() == 0 ? TaskConfig() : m_sessConfig->tasks[m_currTaskIdx]; + const Array> targetConfigs = task.type == TaskType::constant ? m_targetConfigs[m_currTrialIdx] : m_adaptiveTargetConfigs[m_currTrialIdx]; + for (shared_ptr target : targetConfigs) { if (target->respawnCount == -1) { totalTargets = -1; // Ininite spawn case break; @@ -452,7 +481,7 @@ class Session : public ReferenceCountedObject { void spawnTrialTargets(Point3 initialSpawnPos, bool previewMode = false); bool blockComplete() const; - bool nextTrial(); + bool nextTrial(const bool init = false); const RealTime targetFrameTime();