From 82c08d7ea56270903d1eeeb4497ad62828f7863c Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Tue, 11 Jul 2023 22:09:42 -0300 Subject: [PATCH 01/21] Refactor query section of demographics collection --- docs/CHANGELOG.rst | 4 +++ klibs/KLCommunication.py | 74 ++++++++++++++++++++++++++++------------ 2 files changed, 56 insertions(+), 22 deletions(-) diff --git a/docs/CHANGELOG.rst b/docs/CHANGELOG.rst index 069f373..802c742 100644 --- a/docs/CHANGELOG.rst +++ b/docs/CHANGELOG.rst @@ -53,6 +53,10 @@ Runtime Changes: returning to avoid registering spurious input. * KLibs will now raise an error on launch if any required tables or columns are missing from the database. +* Demographics collection has been changed so that queries in + `user_queries.json` are skipped if they do not correspond to a column in the + `participants` table of the database. Additionally, the query for the + participant's unique identifer is now always collected first. API Changes: diff --git a/klibs/KLCommunication.py b/klibs/KLCommunication.py index af2351c..e02671f 100755 --- a/klibs/KLCommunication.py +++ b/klibs/KLCommunication.py @@ -5,6 +5,7 @@ import re from os.path import join from shutil import copyfile, copytree +from collections import OrderedDict from sdl2 import (SDL_StartTextInput, SDL_StopTextInput, SDL_KEYDOWN, SDLK_ESCAPE, SDLK_BACKSPACE, SDLK_RETURN, SDLK_KP_ENTER, SDL_TEXTINPUT) @@ -37,6 +38,30 @@ def alert(text): flip() +def _get_demographics_queries(db, queries): + # Get all columns that need to be filled during demographics + required = [] + exclude = ['id', 'created', 'random_seed', 'klibs_commit'] + for col in db.get_columns('participants'): + if col not in exclude: + required.append(col) + + # Ensure all required demographics cols have corresponding queries + query_cols = [q.database_field for q in queries] + missing = set(required).difference(set(query_cols)) + if len(missing): + e = "Missing entries in '{0}' for the following database fields: {1}" + raise RuntimeError(e.format("user_queries.json", str(list(missing)))) + + # Gather queries into a dict for easy use + query_set = OrderedDict() + for q in queries: + if q.database_field in required: + query_set[q.database_field] = q + + return query_set + + def collect_demographics(anonymous=False): '''Collects participant demographics and writes them to the 'participants' table in the experiment's database, based on the queries in the "demographic" section of the project's @@ -52,7 +77,8 @@ def collect_demographics(anonymous=False): user for input. ''' - from klibs.KLEnvironment import exp, db + from klibs.KLEnvironment import db + # Prior to starting block/trial loop, should ensure participant ID has been obtained # ie. demographic questions aren't being asked for this experiment if not P.collect_demographics and not anonymous: return @@ -67,26 +93,30 @@ def collect_demographics(anonymous=False): except ValueError: pass - # collect a response and handle errors for each question - for q in user_queries.demographic: - if q.active: - # if querying unique identifier, make sure it doesn't already exist in db - if q.database_field == P.unique_identifier: - existing = [utf8(pid) for pid in db.get_unique_ids()] - while True: - value = query(q, anonymous=anonymous) - if utf8(value) in existing: - err = ("A participant with that ID already exists!\n" - "Please try a different identifier.") - fill() - blit(message(err, "alert", align='center', blit_txt=False), 5, P.screen_c) - flip() - any_key() - else: - break - else: - value = query(q, anonymous=anonymous) - demographics.log(q.database_field, value) + # Gather demographic queries, separating id query from others + queries = _get_demographics_queries(db, user_queries.demographic) + id_query = queries[P.unique_identifier] + queries.pop(P.unique_identifier) + + # Collect the unique identifier for the participant + unique_id = None + existing = [utf8(pid) for pid in db.get_unique_ids()] + while not unique_id: + unique_id = query(id_query, anonymous=anonymous) + if utf8(unique_id) in existing: + unique_id = None + err = ("A participant with that ID already exists!\n" + "Please try a different identifier.") + fill() + blit(message(err, "alert", align='center', blit_txt=False), 5, P.screen_c) + flip() + any_key() + demographics.log(P.unique_identifier, unique_id) + + # Collect all other demographics queries + for db_col, q in queries.items(): + value = query(q, anonymous=anonymous) + demographics.log(db_col, value) # typical use; P.collect_demographics is True and called automatically by klibs if not P.demographics_collected: @@ -94,7 +124,7 @@ def collect_demographics(anonymous=False): P.p_id = P.participant_id P.demographics_collected = True # Log info about current runtime environment to database - if 'session_info' in db.table_schemas.keys(): + if 'session_info' in db.tables: runtime_info = EntryTemplate('session_info') for col, value in runtime_info_init().items(): runtime_info.log(col, value) From 79fcb7d47744593f85e04c0071927311c1943975 Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Tue, 11 Jul 2023 23:10:57 -0300 Subject: [PATCH 02/21] Prevent repeated demographics collection --- klibs/KLCommunication.py | 52 ++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/klibs/KLCommunication.py b/klibs/KLCommunication.py index e02671f..109b0e2 100755 --- a/klibs/KLCommunication.py +++ b/klibs/KLCommunication.py @@ -78,10 +78,12 @@ def collect_demographics(anonymous=False): ''' from klibs.KLEnvironment import db - # Prior to starting block/trial loop, should ensure participant ID has been obtained + # NOTE: Prior to starting block/trial loop, should ensure participant ID has been obtained - # ie. demographic questions aren't being asked for this experiment - if not P.collect_demographics and not anonymous: return + # If demographics already collected, raise error + if P.demographics_collected: + e = "Demographics have already been collected for this participant." + raise RuntimeError(e) # first insert required, automatically-populated fields demographics = EntryTemplate('participants') @@ -118,29 +120,27 @@ def collect_demographics(anonymous=False): value = query(q, anonymous=anonymous) demographics.log(db_col, value) - # typical use; P.collect_demographics is True and called automatically by klibs - if not P.demographics_collected: - P.participant_id = db.insert(demographics) - P.p_id = P.participant_id - P.demographics_collected = True - # Log info about current runtime environment to database - if 'session_info' in db.tables: - runtime_info = EntryTemplate('session_info') - for col, value in runtime_info_init().items(): - runtime_info.log(col, value) - if P.condition and 'condition' in runtime_info.schema.keys(): - runtime_info.log('condition', P.condition) - db.insert(runtime_info) - # Save copy of experiment.py and config files as they were for participant - if not P.development_mode: - pid = P.random_seed if P.multi_user else P.participant_id # pid set at end for multiuser - P.version_dir = join(P.versions_dir, "p{0}_{1}".format(pid, now(True))) - os.mkdir(P.version_dir) - copyfile("experiment.py", join(P.version_dir, "experiment.py")) - copytree(P.config_dir, join(P.version_dir, "Config")) - else: - # The context for this is: collect_demographics is set to false but then explicitly called later - db.update(demographics.table, demographics.defined) + # Insert demographics in database and get db id number + P.participant_id = db.insert(demographics) + P.p_id = P.participant_id + P.demographics_collected = True + + # Log info about current runtime environment to database + if 'session_info' in db.tables: + runtime_info = EntryTemplate('session_info') + for col, value in runtime_info_init().items(): + runtime_info.log(col, value) + if P.condition and 'condition' in runtime_info.schema.keys(): + runtime_info.log('condition', P.condition) + db.insert(runtime_info) + + # Save copy of experiment.py and config files as they were for participant + if not P.development_mode: + pid = P.random_seed if P.multi_user else P.participant_id # pid set at end for multiuser + P.version_dir = join(P.versions_dir, "p{0}_{1}".format(pid, now(True))) + os.mkdir(P.version_dir) + copyfile("experiment.py", join(P.version_dir, "experiment.py")) + copytree(P.config_dir, join(P.version_dir, "Config")) def init_default_textstyles(): From 2127bac5eb61f87c4ca85d0de5abc368a0e9250d Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Mon, 24 Jul 2023 13:42:28 -0300 Subject: [PATCH 03/21] Refactor collect_demographics --- klibs/KLCommunication.py | 38 ++++++++++++++++---------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/klibs/KLCommunication.py b/klibs/KLCommunication.py index 109b0e2..b0597b1 100755 --- a/klibs/KLCommunication.py +++ b/klibs/KLCommunication.py @@ -85,16 +85,6 @@ def collect_demographics(anonymous=False): e = "Demographics have already been collected for this participant." raise RuntimeError(e) - # first insert required, automatically-populated fields - demographics = EntryTemplate('participants') - demographics.log('created', now(True)) - try: - # columns moved to session_info in newer templates - demographics.log("random_seed", P.random_seed) - demographics.log("klibs_commit", P.klibs_commit) - except ValueError: - pass - # Gather demographic queries, separating id query from others queries = _get_demographics_queries(db, user_queries.demographic) id_query = queries[P.unique_identifier] @@ -113,26 +103,30 @@ def collect_demographics(anonymous=False): blit(message(err, "alert", align='center', blit_txt=False), 5, P.screen_c) flip() any_key() - demographics.log(P.unique_identifier, unique_id) + + # Initialize demographics info for partcipant + demographics = { + P.unique_identifier: unique_id, + "created": now(True), + } + if "random_seed" in db.get_columns("participants"): + # Required for compatibility with older projects + demographics["random_seed"] = P.random_seed + demographics["klibs_commit"] = P.klibs_commit # Collect all other demographics queries for db_col, q in queries.items(): - value = query(q, anonymous=anonymous) - demographics.log(db_col, value) + demographics[db_col] = query(q, anonymous=anonymous) # Insert demographics in database and get db id number - P.participant_id = db.insert(demographics) - P.p_id = P.participant_id + P.participant_id = P.p_id = db.insert(demographics, "participants") P.demographics_collected = True # Log info about current runtime environment to database - if 'session_info' in db.tables: - runtime_info = EntryTemplate('session_info') - for col, value in runtime_info_init().items(): - runtime_info.log(col, value) - if P.condition and 'condition' in runtime_info.schema.keys(): - runtime_info.log('condition', P.condition) - db.insert(runtime_info) + runtime_info = runtime_info_init() + if P.condition and "condition" in db.get_columns("session_info"): + runtime_info["condition"] = P.condition + db.insert(runtime_info, "session_info") # Save copy of experiment.py and config files as they were for participant if not P.development_mode: From 11ff961de18c06e16b93d4d9823b2474158f1f15 Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Sat, 29 Jul 2023 15:12:49 -0300 Subject: [PATCH 04/21] Ensure demographics collected prior to first block --- klibs/KLExperiment.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/klibs/KLExperiment.py b/klibs/KLExperiment.py index a14d0af..2e4c87b 100755 --- a/klibs/KLExperiment.py +++ b/klibs/KLExperiment.py @@ -46,7 +46,12 @@ def __execute_experiment__(self, *args, **kwargs): """ from klibs.KLGraphics import clear + if not P.demographics_collected: + e = "Demographics must be collected before the first block of the task." + raise RuntimeError(e) + if self.blocks == None: + # If structure provided, just ignore trial factory and use structure to generate? self.blocks = self.trial_factory.export_trials() P.block_number = 0 From 3bdff68a9fbaaf13a064d6d838856f61e2b1a5f6 Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Sat, 29 Jul 2023 15:29:45 -0300 Subject: [PATCH 05/21] Add session_count to runtime info --- klibs/KLCommunication.py | 6 ++++-- klibs/KLParams.py | 3 +-- klibs/KLRuntimeInfo.py | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/klibs/KLCommunication.py b/klibs/KLCommunication.py index b0597b1..8151dc7 100755 --- a/klibs/KLCommunication.py +++ b/klibs/KLCommunication.py @@ -124,8 +124,10 @@ def collect_demographics(anonymous=False): # Log info about current runtime environment to database runtime_info = runtime_info_init() - if P.condition and "condition" in db.get_columns("session_info"): - runtime_info["condition"] = P.condition + if "session_count" in db.get_columns("session_info"): + runtime_info["session_count"] = P.session_count + if P.condition: + runtime_info["condition"] = P.condition db.insert(runtime_info, "session_info") # Save copy of experiment.py and config files as they were for participant diff --git a/klibs/KLParams.py b/klibs/KLParams.py index 836a5a7..e3268ed 100755 --- a/klibs/KLParams.py +++ b/klibs/KLParams.py @@ -47,13 +47,12 @@ collect_demographics = True manual_demographics_collection = False manual_trial_generation = False -multi_session_project = False multi_user = False # creates temp copy of db that gets merged into master at end trials_per_block = 0 blocks_per_experiment = 0 +session_count = 1 conditions = [] default_condition = None -table_defaults = {} # default column values for db tables when using EntryTemplate run_practice_blocks = True # (not implemented in klibs itself) color_output = False # whether cso() outputs colorized text or not diff --git a/klibs/KLRuntimeInfo.py b/klibs/KLRuntimeInfo.py index 1af6b0b..5ac2876 100644 --- a/klibs/KLRuntimeInfo.py +++ b/klibs/KLRuntimeInfo.py @@ -26,6 +26,7 @@ trials_per_block integer not null, blocks_per_session integer not null, + session_count integer not null, os_version text not null, python_version text not null, From 4bcfcf280c3082e2315da3d2a9ef8ed7e36c54f0 Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Thu, 3 Aug 2023 19:08:44 -0300 Subject: [PATCH 06/21] Add basic multisession support --- klibs/KLCommunication.py | 109 +++++++++++++++++++++++-------- klibs/KLDatabase.py | 52 +++++++++++---- klibs/tests/test_KLDatabase.py | 10 ++- klibs/tests/test_KLExperiment.py | 1 + 4 files changed, 124 insertions(+), 48 deletions(-) diff --git a/klibs/KLCommunication.py b/klibs/KLCommunication.py index 8151dc7..332fc7a 100755 --- a/klibs/KLCommunication.py +++ b/klibs/KLCommunication.py @@ -17,10 +17,10 @@ from klibs.KLEventQueue import pump, flush from klibs.KLUtilities import pretty_list, now, utf8, make_hash from klibs.KLUtilities import colored_stdout as cso -from klibs.KLDatabase import EntryTemplate +from klibs.KLDatabase import _get_session_info from klibs.KLRuntimeInfo import runtime_info_init from klibs.KLGraphics import blit, clear, fill, flip -from klibs.KLUserInterface import ui_request, any_key +from klibs.KLUserInterface import ui_request, key_pressed from klibs.KLText import TextStyle, add_text_style @@ -38,6 +38,29 @@ def alert(text): flip() +def _simple_prompt(msg, loc=None, resp_keys=[]): + # Shows a message and waits for a key response + if not loc: + loc = P.screen_c + fill() + blit(msg, 5, location=loc) + flip() + resp = None + while not resp: + q = pump() + ui_request(queue=q) + # If specific response keys defined, check each of them + for key in resp_keys: + if key_pressed(key, queue=q): + resp = key + break + # Otherwise, end the loop if any key is pressed + if not len(resp_keys): + if key_pressed(queue=q): + resp = True + return resp + + def _get_demographics_queries(db, queries): # Get all columns that need to be filled during demographics required = [] @@ -91,35 +114,62 @@ def collect_demographics(anonymous=False): queries.pop(P.unique_identifier) # Collect the unique identifier for the participant - unique_id = None - existing = [utf8(pid) for pid in db.get_unique_ids()] - while not unique_id: - unique_id = query(id_query, anonymous=anonymous) - if utf8(unique_id) in existing: - unique_id = None + unique_id = query(id_query, anonymous=anonymous) + p_id = db.get_db_id(unique_id) + while p_id is not None: + id_info = _get_session_info(db, p_id)[-1] + if P.session_count > 1: + session_num = id_info['num'] + 1 + # Already completed all sessions of the task. Create new ID? + if session_num > P.session_count: + txt = ( + "This participant has already completed all sessions of the task.\n" + "Please enter a different identifier." + ) + msg = message(txt, align="center") + _simple_prompt(msg) + # Participant has completed X of N sessions. Begin next session? + else: + txt = ( + "This participant has completed {0} of {1} sessions.\n" + "Begin next session? (Yes / No)" + ) + txt = txt.format(id_info['num'], P.session_count) + msg = message(txt, align="center") + resp = _simple_prompt(msg, resp_keys=["y", "n", "return"]) + if resp != "n": + P.condition = id_info['condition'] + P.session_number = session_num + break + else: err = ("A participant with that ID already exists!\n" "Please try a different identifier.") - fill() - blit(message(err, "alert", align='center', blit_txt=False), 5, P.screen_c) - flip() - any_key() - - # Initialize demographics info for partcipant - demographics = { - P.unique_identifier: unique_id, - "created": now(True), - } - if "random_seed" in db.get_columns("participants"): - # Required for compatibility with older projects - demographics["random_seed"] = P.random_seed - demographics["klibs_commit"] = P.klibs_commit - - # Collect all other demographics queries - for db_col, q in queries.items(): - demographics[db_col] = query(q, anonymous=anonymous) - - # Insert demographics in database and get db id number - P.participant_id = P.p_id = db.insert(demographics, "participants") + msg = message(err, style="alert", align="center") + _simple_prompt(msg) + # Retry with another id + unique_id = query(id_query, anonymous=anonymous) + p_id = db.get_db_id(unique_id) + + # If not reloading an existing participant, collect demographics + if p_id is None: + # Initialize demographics info for participant + demographics = { + P.unique_identifier: unique_id, + "created": now(True), + } + if "random_seed" in db.get_columns("participants"): + # Required for compatibility with older projects + demographics["random_seed"] = P.random_seed + demographics["klibs_commit"] = P.klibs_commit + + # Collect all demographics queries + for db_col, q in queries.items(): + demographics[db_col] = query(q, anonymous=anonymous) + + # Insert demographics in database and get db id number + p_id = db.insert(demographics, "participants") + + P.participant_id = P.p_id = p_id P.demographics_collected = True # Log info about current runtime environment to database @@ -132,6 +182,7 @@ def collect_demographics(anonymous=False): # Save copy of experiment.py and config files as they were for participant if not P.development_mode: + # TODO: Break this into a separate function, make it more useful pid = P.random_seed if P.multi_user else P.participant_id # pid set at end for multiuser P.version_dir = join(P.versions_dir, "p{0}_{1}".format(pid, now(True))) os.mkdir(P.version_dir) diff --git a/klibs/KLDatabase.py b/klibs/KLDatabase.py index 27f5fc2..3d21939 100755 --- a/klibs/KLDatabase.py +++ b/klibs/KLDatabase.py @@ -252,6 +252,16 @@ def rebuild_database(path, schema): shutil.move(tmppath, path) +def _get_session_info(db, pid): + # Gathers previous session info for a given database ID + cols = ['condition', 'session_number', 'complete'] + info = db.select('session_info', columns=cols, where={'participant_id': pid}) + sessions = [] + for cond, num, completed in info: + sessions.append({'condition': cond, 'num': num, 'completed': completed}) + return sessions + + class EntryTemplate(object): @@ -625,18 +635,22 @@ def _validate_structure(self, db): raise RuntimeError(e.format(table)) def _is_complete(self, pid): - # TODO: For multisession projects, need to know the number of sessions - # per experiment for this to work correctly: currently, this only checks - # whether all sessions so far were completed, even if there are more - # sessions remaining. - if 'session_info' in self._primary.table_schemas: - q = "SELECT complete FROM session_info WHERE participant_id = ?" - sessions = self._primary.query(q, q_vars=[pid]) + this_id = {'participant_id': pid} + db = self._primary # Always use primary db for data export + if 'session_info' in db.tables: + # Ensure participant has completed all sessions of task + if "session_count" in db.get_columns("session_info"): + last_session, num_sessions = db.select( + 'session_info', ['session_number', 'session_count'], where=this_id + )[-1] + if last_session < num_sessions: + return False + # Ensure all sessions were successfully completed + sessions = db.select('session_info', ['complete'], where=this_id) complete = [bool(s[0]) for s in sessions] return all(complete) else: - q = "SELECT id FROM trials WHERE participant_id = ?" - trialcount = len(self._primary.query(q, q_vars=[pid])) + trialcount = len(db.select('trials', ['id'], where=this_id)) return trialcount >= P.trials_per_block * P.blocks_per_experiment def _log_export(self, pid, table): @@ -655,13 +669,25 @@ def _already_exported(self, pid, table): matches = self._primary.select('export_history', where=this_id) return len(matches) > 0 + def get_db_id(self, unique_id): + """Gets the numeric database ID for a given unique identifier. - def get_unique_ids(self): - """Retrieves all existing unique id values from the main database. + If no matching unique ID exists in the 'participants' table, this will + return None. + + Args: + unique_id (str): The participant identifier (e.g. 'P03') for which + to retrieve the corresponding database ID. + Returns: + int or None: The numeric database ID corresponding to the given + unique identifier, or None if no match found. """ - id_rows = self._primary.select('participants', columns=[P.unique_identifier]) - return [row[0] for row in id_rows] + id_filter = {P.unique_identifier: unique_id} + ret = self._primary.select('participants', columns=['id'], where=id_filter) + if not ret: + return None + return ret[0][0] def write_local_to_master(self): diff --git a/klibs/tests/test_KLDatabase.py b/klibs/tests/test_KLDatabase.py index 4b2756a..85fb232 100644 --- a/klibs/tests/test_KLDatabase.py +++ b/klibs/tests/test_KLDatabase.py @@ -111,6 +111,7 @@ def test_insert(self, db): # Test handling of 'allow null' columns _init_params_pytest() data = runtime_info_init() + data['session_count'] = 1 db.insert(data, table='session_info') assert db.last_row_id('session_info') == 1 # Test exception when unable to coerce value to column type @@ -220,18 +221,15 @@ def test_init_multi_user(self, db_test_path): assert dat.table_schemas['participants']['age']['type'] == klibs.PY_INT dat.close() - def test_get_unique_ids(self, db_test_path): + def test_get_db_id(self, db_test_path): dat = kldb.DatabaseManager(db_test_path) # Add test data id_data = build_test_data() for row in id_data: dat.insert(row, table='participants') for pid in (1, 2, 3): - dat.insert(generate_data_row(pid), table='trials') - dat.insert(generate_data_row(pid, trial=2), table='trials') - assert "P0{0}".format(pid) in dat.get_unique_ids() - assert len(dat.get_unique_ids()) == 3 - assert not "P04" in dat.get_unique_ids() + assert dat.get_db_id("P0{0}".format(pid)) == pid + assert not dat.get_db_id("P04") dat.close() def test_remove_data(self, db_test_path): diff --git a/klibs/tests/test_KLExperiment.py b/klibs/tests/test_KLExperiment.py index c29eaae..e46798b 100755 --- a/klibs/tests/test_KLExperiment.py +++ b/klibs/tests/test_KLExperiment.py @@ -14,6 +14,7 @@ def experiment(): template_path = resource_filename('klibs', 'resources/template') P.ind_vars_file_path = os.path.join(template_path, "independent_variables.py") P.ind_vars_file_local_path = os.path.join(template_path, "doesnt_exist.py") + P.demographics_collected = True P.manual_trial_generation = True P.project_name = "PROJECT_NAME" return Experiment() From 894c1640889bc704f5ed13a357613028097ed09d Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Fri, 4 Aug 2023 12:41:52 -0300 Subject: [PATCH 07/21] Add support for resuming from a given block/trial --- klibs/KLExperiment.py | 24 ++++++++++++++++++++++-- klibs/KLTrialFactory.py | 6 ++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/klibs/KLExperiment.py b/klibs/KLExperiment.py index 2e4c87b..56d73eb 100755 --- a/klibs/KLExperiment.py +++ b/klibs/KLExperiment.py @@ -45,6 +45,7 @@ def __execute_experiment__(self, *args, **kwargs): """ from klibs.KLGraphics import clear + from klibs.KLTrialFactory import TrialIterator if not P.demographics_collected: e = "Demographics must be collected before the first block of the task." @@ -54,14 +55,33 @@ def __execute_experiment__(self, *args, **kwargs): # If structure provided, just ignore trial factory and use structure to generate? self.blocks = self.trial_factory.export_trials() - P.block_number = 0 + # Check whether we're resuming from an incomplete session and fast-forward if we are + resume_session = False + if P.block_number > 0 or P.trial_number > 0: + # Drop completed blocks + trimmed = [block for block in self.blocks][(P.block_number - 1): ] + # Drop completed trials + if P.trial_number < len(trimmed[0].trials): + practice_first = trimmed[0].practice + trimmed[0] = TrialIterator(trimmed[0][(P.trial_number - 1): ]) + trimmed[0].practice = practice_first # re-set practice flag if needed + else: + # If at end of current block, jump to next block + trimmed = trimmed[1:] + P.block_number += 1 + # Prepare for resuming session + self.blocks = trimmed + P.block_number -= 1 + resume_session = True + P.trial_id = 0 for block in self.blocks: P.recycle_count = 0 P.block_number += 1 P.practicing = block.practice self.block() - P.trial_number = 1 + P.trial_number = P.trial_number if resume_session else 1 + resume_session = False for trial in block: # ie. list of trials try: P.trial_id += 1 # Increments regardless of recycling diff --git a/klibs/KLTrialFactory.py b/klibs/KLTrialFactory.py index 96bc372..86cec4e 100755 --- a/klibs/KLTrialFactory.py +++ b/klibs/KLTrialFactory.py @@ -112,6 +112,12 @@ def __init__(self, block_of_trials): self.i = 0 self.__practice = False + def __getitem__(self, i): + return self.trials[i] + + def __setitem__(self, i, x): + self.trials[i] = x + def __next__(self): if self.i >= self.length: self.i = 0 From 50516cc9d9470f0b0ba212112725548ce29e5ea0 Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Mon, 7 Aug 2023 11:33:52 -0300 Subject: [PATCH 08/21] Refactor existing ID messages --- klibs/KLCommunication.py | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/klibs/KLCommunication.py b/klibs/KLCommunication.py index 332fc7a..14cd543 100755 --- a/klibs/KLCommunication.py +++ b/klibs/KLCommunication.py @@ -101,7 +101,19 @@ def collect_demographics(anonymous=False): ''' from klibs.KLEnvironment import db - # NOTE: Prior to starting block/trial loop, should ensure participant ID has been obtained + + # Define user init prompt strings + txt = { + 'exists': + ("A participant with that ID already exists!\n" + "Please try a different identifier."), + 'next_session': + ("This participant has completed {0} of {1} sessions.\n" + "Begin next session? (Yes / No)"), + 'all_done': + ("This participant has already completed all sessions of the task.\n" + "Please enter a different identifier."), + } # If demographics already collected, raise error if P.demographics_collected: @@ -117,34 +129,27 @@ def collect_demographics(anonymous=False): unique_id = query(id_query, anonymous=anonymous) p_id = db.get_db_id(unique_id) while p_id is not None: - id_info = _get_session_info(db, p_id)[-1] + last_session = _get_session_info(db, p_id)[-1] if P.session_count > 1: - session_num = id_info['num'] + 1 + session_num = last_session['num'] + 1 # Already completed all sessions of the task. Create new ID? if session_num > P.session_count: - txt = ( - "This participant has already completed all sessions of the task.\n" - "Please enter a different identifier." - ) - msg = message(txt, align="center") + msg = message(txt['all_done'], align="center") _simple_prompt(msg) # Participant has completed X of N sessions. Begin next session? else: - txt = ( - "This participant has completed {0} of {1} sessions.\n" - "Begin next session? (Yes / No)" + msg = message( + txt['next_session'].format(last_session['num'], P.session_count), + align="center" ) - txt = txt.format(id_info['num'], P.session_count) - msg = message(txt, align="center") resp = _simple_prompt(msg, resp_keys=["y", "n", "return"]) if resp != "n": - P.condition = id_info['condition'] + P.condition = last_session['condition'] P.session_number = session_num break else: - err = ("A participant with that ID already exists!\n" - "Please try a different identifier.") - msg = message(err, style="alert", align="center") + # Participant exists and not multisession, so try another + msg = message(txt['exists'], style="alert", align="center") _simple_prompt(msg) # Retry with another id unique_id = query(id_query, anonymous=anonymous) @@ -183,6 +188,7 @@ def collect_demographics(anonymous=False): # Save copy of experiment.py and config files as they were for participant if not P.development_mode: # TODO: Break this into a separate function, make it more useful + # TODO: FileExistsError if re-creating ID within same minute pid = P.random_seed if P.multi_user else P.participant_id # pid set at end for multiuser P.version_dir = join(P.versions_dir, "p{0}_{1}".format(pid, now(True))) os.mkdir(P.version_dir) From f7c9b0609dcf16ae0a6c30f511780de1f04b4e3c Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Mon, 7 Aug 2023 12:45:16 -0300 Subject: [PATCH 09/21] Ensure session_num in user tables for multisession --- klibs/KLDatabase.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/klibs/KLDatabase.py b/klibs/KLDatabase.py index 3d21939..b9380d5 100755 --- a/klibs/KLDatabase.py +++ b/klibs/KLDatabase.py @@ -596,7 +596,7 @@ def __init__(self, path, local_path=None): self._local_path = local_path # Initialize connections to database(s) self._primary = Database(path) - self._validate_structure(self._primary) + self._validate_structure(self._primary, P.session_count > 1) self._local = None if self.multi_user: shutil.copy(path, local_path) @@ -613,7 +613,7 @@ def _current(self): # mode and the normal database otherwise return self._local if self.multi_user else self._primary - def _validate_structure(self, db): + def _validate_structure(self, db, multisession=False): # Ensure basic required tables exist e = "Required table '{0}' is not present in the database." required = ['participants', P.primary_table] @@ -633,6 +633,14 @@ def _validate_structure(self, db): for table in _get_user_tables(db): if not 'participant_id' in db.get_columns(table): raise RuntimeError(e.format(table)) + # Ensure that the required columns are present for multisession + e = "Missing required column for multi-session project '{0}' in table '{1}'." + if multisession: + user_tables = _get_user_tables(db) + for table in user_tables: + if not 'session_num' in db.get_columns(table): + raise RuntimeError(e.format('session_num', table)) + def _is_complete(self, pid): this_id = {'participant_id': pid} From 219b5f89099db3bdde16c36f851def5647a0ff9e Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Mon, 7 Aug 2023 14:43:10 -0300 Subject: [PATCH 10/21] Only require 'not null' cols have queries --- klibs/KLCommunication.py | 3 ++- klibs/KLDatabase.py | 29 +++++++++++++++-------------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/klibs/KLCommunication.py b/klibs/KLCommunication.py index 14cd543..35fbb72 100755 --- a/klibs/KLCommunication.py +++ b/klibs/KLCommunication.py @@ -65,8 +65,9 @@ def _get_demographics_queries(db, queries): # Get all columns that need to be filled during demographics required = [] exclude = ['id', 'created', 'random_seed', 'klibs_commit'] + col_info = db.table_schemas['participants'] for col in db.get_columns('participants'): - if col not in exclude: + if col not in exclude and not col_info[col]['allow_null']: required.append(col) # Ensure all required demographics cols have corresponding queries diff --git a/klibs/KLDatabase.py b/klibs/KLDatabase.py index b9380d5..36d7d2b 100755 --- a/klibs/KLDatabase.py +++ b/klibs/KLDatabase.py @@ -325,24 +325,25 @@ def _build_table_schemas(self): table_cols = OrderedDict() self.cursor.execute("PRAGMA table_info({0})".format(table)) columns = self.cursor.fetchall() - + # convert sqlite3 types to python types for col in columns: - if col[2].lower() == SQL_STR: - col_type = PY_STR - elif col[2].lower() == SQL_BIN: - col_type = PY_BIN - elif col[2].lower() in (SQL_INT, SQL_KEY): - col_type = PY_INT - elif col[2].lower() in (SQL_FLOAT, SQL_REAL, SQL_NUMERIC): - col_type = PY_FLOAT - elif col[2].lower() == SQL_BOOL: - col_type = PY_BOOL + colname, coltype, not_null, default= col[1:5] + if coltype.lower() == SQL_STR: + py_type = PY_STR + elif coltype.lower() == SQL_BIN: + py_type = PY_BIN + elif coltype.lower() in (SQL_INT, SQL_KEY): + py_type = PY_INT + elif coltype.lower() in (SQL_FLOAT, SQL_REAL, SQL_NUMERIC): + py_type = PY_FLOAT + elif coltype.lower() == SQL_BOOL: + py_type = PY_BOOL else: err_str = "Invalid or unsupported type ({0}) for {1}.{2}'" - raise ValueError(err_str.format(col[2], table, col[1])) - allow_null = col[3] == 0 - table_cols[col[1]] = {'type': col_type, 'allow_null': allow_null} + raise ValueError(err_str.format(coltype, table, colname)) + allow_null = (not_null == 0 or default is not None) + table_cols[colname] = {'type': py_type, 'allow_null': allow_null} tables[table] = table_cols return tables From bb267eae3c428abda36b430b6e07a6e18af7089b Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Mon, 7 Aug 2023 19:18:20 -0300 Subject: [PATCH 11/21] Add session reload/restart/skip logic --- klibs/KLCommunication.py | 100 ++++++++++++++++++++++++----- klibs/KLDatabase.py | 50 ++++++++++++--- klibs/KLParams.py | 1 + klibs/cli.py | 4 ++ klibs/resources/template/params.py | 2 +- 5 files changed, 130 insertions(+), 27 deletions(-) diff --git a/klibs/KLCommunication.py b/klibs/KLCommunication.py index 35fbb72..a5a3f18 100755 --- a/klibs/KLCommunication.py +++ b/klibs/KLCommunication.py @@ -17,7 +17,7 @@ from klibs.KLEventQueue import pump, flush from klibs.KLUtilities import pretty_list, now, utf8, make_hash from klibs.KLUtilities import colored_stdout as cso -from klibs.KLDatabase import _get_session_info +from klibs.KLDatabase import _get_user_tables from klibs.KLRuntimeInfo import runtime_info_init from klibs.KLGraphics import blit, clear, fill, flip from klibs.KLUserInterface import ui_request, key_pressed @@ -86,21 +86,45 @@ def _get_demographics_queries(db, queries): return query_set -def collect_demographics(anonymous=False): - '''Collects participant demographics and writes them to the 'participants' table in the - experiment's database, based on the queries in the "demographic" section of the project's - user_queries.json file. +def collect_demographics(anonymous=False, unique_id=None): + """Initializes the participant ID and collects any demographics queries. + + Calling this function collects a unique identifier from the participant (e.g. + 'P03') and initializes the session. - If P.manual_demographics_collection = True, this function should be called at some point during - the setup() section of your experiment class. Otherwise, this function will be run - automatically when the experiment is launched. + If no participant with that identifier already exists, this function will perform + demographics collection and add the participant to the database. All queries + in the project's ``user_queries.json`` file that have corresponding columns in the + 'participants' table in the database will be collected. If klibs was launched in + development mode, demographics will skipped and filled in with default values. + + If an entered ID already exists in the database, a few different things can + happen: + * If the participant exists but did not fully complete the last session of the + task, they will be prompted whether to a) restart the last session, b) resume + the last session from the last completed trial, or c) skip to the next session + (if the project is multi-session and not on the last session). + * If the participant completed the last session and the project is multi-session, + they will be asked if they want to reload the participant and start the next + session. If the participant has already completed all sessions, they will be + asked to try a different unique ID. + * If the participant completed the task and the project is `not` multi-session, + they will be told that the ID already exists and to try a different one. + + By default, this function is run automatically when an experiment is launched. + However, you can disable this by setting ``manual_demographics_collection`` to + True in the project's params file and call it manually at some later point + yourself. This function must be called before the start of the first block of + the task. Args: - anonymous (bool, optional): If True, this function will log all of the anonymous values for - the experiment's demographic queries to the database immediately without prompting the - user for input. + anonymous (bool, optional): If True, this function will auto-fill all + demographics fields with their anonymous values instead of collecting + responses from the participant. Defaults to False. + unique_id (str, optional): If provided, the initial unique ID prompt will be + skipped and this ID will be tried instead. - ''' + """ from klibs.KLEnvironment import db # Define user init prompt strings @@ -114,6 +138,14 @@ def collect_demographics(anonymous=False): 'all_done': ("This participant has already completed all sessions of the task.\n" "Please enter a different identifier."), + 'incomplete': + ("This participant did not complete {0} of the task.\n" + "Would you like to (r)estart from the beginning, or (c)ontinue from\n" + "the last completed trial?"), + 'incomplete_alt': + ("This participant did not complete {0} of the task.\n" + "Would you like to (r)estart from the beginning, (c)ontinue from the\n" + "last completed trial, or (s)kip to the next session?"), } # If demographics already collected, raise error @@ -127,11 +159,46 @@ def collect_demographics(anonymous=False): queries.pop(P.unique_identifier) # Collect the unique identifier for the participant - unique_id = query(id_query, anonymous=anonymous) + if not unique_id: + unique_id = query(id_query, anonymous=anonymous) p_id = db.get_db_id(unique_id) while p_id is not None: - last_session = _get_session_info(db, p_id)[-1] - if P.session_count > 1: + last_session = db.get_session_progress(p_id) + if not last_session['completed'] and not P.multi_user: + # Participant exists but didn't complete last session, so ask what to do + s = "the last session" if P.session_count > 1 else "all blocks" + if last_session['num'] == P.session_count: + prompt = txt['incomplete'].format(s) + options = ['r', 'c'] + else: + prompt = txt['incomplete_alt'].format(s) + options = ['r', 'c', 's'] + msg = message(prompt, align="center") + resp = _simple_prompt(msg, resp_keys=options) + if resp == "r": + # Delete all data from existing incomplete session & start again + # NOTE: Add prompt confirming deletion of old data? + P.session_number = last_session['num'] + last = {'participant_id': p_id} + if P.session_count > 1: + last['session_num'] = P.session_number + for table in _get_user_tables(db): + db.delete(table, where=last) + last = {'participant_id': p_id, 'session_number': P.session_number} + db.delete('session_info', where=last) + elif resp == "c": + # Get last completed block/trial numbers from db and set them + P.session_number = last_session['num'] + P.block_number = last_session['last_block'] + P.trial_number = last_session['last_trial'] + 1 + P.random_seed = last_session['random_seed'] + P.resumed_session = True + elif resp == "s": + # Increment session number and continue + P.session_number = last_session['num'] + 1 + P.condition = last_session['condition'] + break + elif P.session_count > 1: session_num = last_session['num'] + 1 # Already completed all sessions of the task. Create new ID? if session_num > P.session_count: @@ -184,7 +251,8 @@ def collect_demographics(anonymous=False): runtime_info["session_count"] = P.session_count if P.condition: runtime_info["condition"] = P.condition - db.insert(runtime_info, "session_info") + if not P.resumed_session: + db.insert(runtime_info, "session_info") # Save copy of experiment.py and config files as they were for participant if not P.development_mode: diff --git a/klibs/KLDatabase.py b/klibs/KLDatabase.py index 36d7d2b..e26be78 100755 --- a/klibs/KLDatabase.py +++ b/klibs/KLDatabase.py @@ -252,16 +252,6 @@ def rebuild_database(path, schema): shutil.move(tmppath, path) -def _get_session_info(db, pid): - # Gathers previous session info for a given database ID - cols = ['condition', 'session_number', 'complete'] - info = db.select('session_info', columns=cols, where={'participant_id': pid}) - sessions = [] - for cond, num, completed in info: - sessions.append({'condition': cond, 'num': num, 'completed': completed}) - return sessions - - class EntryTemplate(object): @@ -699,6 +689,46 @@ def get_db_id(self, unique_id): return ret[0][0] + def get_session_progress(self, pid): + """Gets information about the last session for a given database ID. + + This retrieves the task condition, session number, and random seed, + as well the participants' progress through the task (last block/trial + number) and whether they fully completed the last session. + + This is used internally for reloading multisession projects. + + Args: + pid (int): The database ID for the participant. + + Returns: + dict: A dictonary containing information about the participant's + last session. + + """ + db = self._primary + # Gathers previous session info for a given database ID + cols = ['condition', 'session_number', 'complete', 'random_seed'] + info = db.select('session_info', columns=cols, where={'participant_id': pid}) + cond, last_session_num, completed, random_seed = info[-1] + # Gather info about the participant's progress on the last session + where = {'participant_id': pid} + if 'session_num' in db.get_columns(P.primary_table): + where['session_num'] = last_session_num + last_trial, last_block = (0, 0) + progress = db.select(P.primary_table, ['trial_num', 'block_num'], where=where) + if len(progress): + last_trial, last_block = progress[-1] + return { + 'condition': cond, + 'num': last_session_num, + 'completed': completed, + 'random_seed': random_seed, + 'last_block': last_block, + 'last_trial': last_trial, + } + + def write_local_to_master(self): attach_q = 'ATTACH `{0}` AS master'.format(self._path) self._local.cursor.execute(attach_q) diff --git a/klibs/KLParams.py b/klibs/KLParams.py index e3268ed..b3224aa 100755 --- a/klibs/KLParams.py +++ b/klibs/KLParams.py @@ -29,6 +29,7 @@ block_number = 0 session_number = 1 recycle_count = 0 # reset on a per-block basis +resumed_session = False # Runtime Attributes project_name = None diff --git a/klibs/cli.py b/klibs/cli.py index 22ea949..a7c034e 100644 --- a/klibs/cli.py +++ b/klibs/cli.py @@ -321,6 +321,10 @@ def run(screen_size, path, condition, devmode, no_tracker, seed): cond_list = "', '".join(P.conditions) err("'{0}' is not a valid condition for this experiment (must be one of '{1}'). " "Please relaunch the experiment.".format(P.condition, cond_list)) + + # Error if trying to use multi-user and multi-session at the same time + if P.multi_user and P.session_count > 1: + err("Multi-user mode is not currently supported for multi-session projects.") # set some basic global Params if devmode: diff --git a/klibs/resources/template/params.py b/klibs/resources/template/params.py index 553361f..498bd2d 100755 --- a/klibs/resources/template/params.py +++ b/klibs/resources/template/params.py @@ -39,9 +39,9 @@ ######################################### # Experiment Structure ######################################### -multi_session_project = False trials_per_block = 0 blocks_per_experiment = 1 +session_count = 1 conditions = [] default_condition = None From e262f81d6f0caf9767d7cb77f05b6cbaa3480cbb Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Tue, 8 Aug 2023 12:44:03 -0300 Subject: [PATCH 12/21] Clarify resume session text --- klibs/KLCommunication.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/klibs/KLCommunication.py b/klibs/KLCommunication.py index a5a3f18..f0c9cc4 100755 --- a/klibs/KLCommunication.py +++ b/klibs/KLCommunication.py @@ -139,12 +139,12 @@ def collect_demographics(anonymous=False, unique_id=None): ("This participant has already completed all sessions of the task.\n" "Please enter a different identifier."), 'incomplete': - ("This participant did not complete {0} of the task.\n" + ("This participant did not complete {0} of the task.\n\n" "Would you like to (r)estart from the beginning, or (c)ontinue from\n" "the last completed trial?"), 'incomplete_alt': - ("This participant did not complete {0} of the task.\n" - "Would you like to (r)estart from the beginning, (c)ontinue from the\n" + ("This participant did not complete {0} of the task.\n\n" + "Would you like to (r)estart the session, (c)ontinue from the\n" "last completed trial, or (s)kip to the next session?"), } From b8a4c4bd57df8e5c90d50528ef953306ae248046 Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Tue, 8 Aug 2023 12:46:24 -0300 Subject: [PATCH 13/21] Fix resetting random seed on continue --- klibs/KLCommunication.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/klibs/KLCommunication.py b/klibs/KLCommunication.py index f0c9cc4..df55258 100755 --- a/klibs/KLCommunication.py +++ b/klibs/KLCommunication.py @@ -3,6 +3,7 @@ import os import re +import random from os.path import join from shutil import copyfile, copytree from collections import OrderedDict @@ -192,6 +193,7 @@ def collect_demographics(anonymous=False, unique_id=None): P.block_number = last_session['last_block'] P.trial_number = last_session['last_trial'] + 1 P.random_seed = last_session['random_seed'] + random.seed(P.random_seed) P.resumed_session = True elif resp == "s": # Increment session number and continue From e647eef2bb130724b8abb143652dc4536941c122 Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Tue, 8 Aug 2023 12:54:37 -0300 Subject: [PATCH 14/21] Remove version dir if it already exists --- klibs/KLCommunication.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/klibs/KLCommunication.py b/klibs/KLCommunication.py index df55258..a478276 100755 --- a/klibs/KLCommunication.py +++ b/klibs/KLCommunication.py @@ -5,7 +5,7 @@ import re import random from os.path import join -from shutil import copyfile, copytree +from shutil import copyfile, copytree, rmtree from collections import OrderedDict from sdl2 import (SDL_StartTextInput, SDL_StopTextInput, @@ -262,6 +262,8 @@ def collect_demographics(anonymous=False, unique_id=None): # TODO: FileExistsError if re-creating ID within same minute pid = P.random_seed if P.multi_user else P.participant_id # pid set at end for multiuser P.version_dir = join(P.versions_dir, "p{0}_{1}".format(pid, now(True))) + if os.path.exists(P.version_dir): + rmtree(P.version_dir) os.mkdir(P.version_dir) copyfile("experiment.py", join(P.version_dir, "experiment.py")) copytree(P.config_dir, join(P.version_dir, "Config")) From 25087969802cfebea6d921e1a1f6f2ecb6e7a90b Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Tue, 8 Aug 2023 13:52:25 -0300 Subject: [PATCH 15/21] Fix random seed column type --- klibs/KLRuntimeInfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/klibs/KLRuntimeInfo.py b/klibs/KLRuntimeInfo.py index 5ac2876..f3567ea 100644 --- a/klibs/KLRuntimeInfo.py +++ b/klibs/KLRuntimeInfo.py @@ -22,7 +22,7 @@ date text not null, time text not null, klibs_commit text not null, - random_seed text not null, + random_seed integer not null, trials_per_block integer not null, blocks_per_session integer not null, From d5775ac89659e502eccc686802ab0ee49b36d1d4 Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Tue, 8 Aug 2023 14:27:46 -0300 Subject: [PATCH 16/21] Fix random seed for continue session --- klibs/KLExperiment.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/klibs/KLExperiment.py b/klibs/KLExperiment.py index 56d73eb..6923d3d 100755 --- a/klibs/KLExperiment.py +++ b/klibs/KLExperiment.py @@ -35,8 +35,6 @@ def __init__(self): self._evm = EventManager() self.trial_factory = TrialFactory() - if P.manual_trial_generation is False: - self.trial_factory.generate() self.event_code_generator = None @@ -342,6 +340,9 @@ def run(self, *args, **kwargs): if not P.manual_eyelink_setup: self.el.setup() + if P.manual_trial_generation is False: + self.trial_factory.generate() + self.setup() try: self.__execute_experiment__(*args, **kwargs) From 7318a6a726f145e86d4785bf4d6067298f979e61 Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Tue, 19 Sep 2023 16:47:29 -0300 Subject: [PATCH 17/21] Fix export for multisession data --- klibs/KLDatabase.py | 51 ++++++++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/klibs/KLDatabase.py b/klibs/KLDatabase.py index e26be78..a9cb7c3 100755 --- a/klibs/KLDatabase.py +++ b/klibs/KLDatabase.py @@ -770,11 +770,9 @@ def close(self): def collect_export_data(self, base_table, multi_file=True, join_tables=[]): - uid = P.unique_identifier - participant_ids = self._primary.query("SELECT `id`, `{0}` FROM `participants`".format(uid)) - - colnames = [] + cols = {'p': []} sub = {P.unique_identifier: 'participant'} + multisession = "session_num" in self._primary.get_columns(base_table) # if P.default_participant_fields(_sf) is defined use that, but otherwise use # P.exclude_data_cols since that's the better way of doing things @@ -783,38 +781,53 @@ def collect_export_data(self, base_table, multi_file=True, join_tables=[]): for field in fields: if iterable(field): sub[field[0]] = field[1] - colnames.append(field[0]) + cols['p'].append(field[0]) else: - colnames.append(field) + cols['p'].append(field) else: for colname in self._primary.get_columns('participants'): if colname not in ['id'] + P.exclude_data_cols: - colnames.append(colname) + cols['p'].append(colname) for colname in P.append_info_cols: + if not 'info' in cols.keys(): + cols['info'] = [] if colname not in self._primary.get_columns('session_info'): err = "Column '{0}' does not exist in the session_info table." raise RuntimeError(err.format(colname)) - colnames.append(colname) + cols['info'].append(colname) for t in [base_table] + join_tables: + cols[t] = [] for colname in self._primary.get_columns(t): if colname not in ['id', P.id_field_name] + P.exclude_data_cols: - colnames.append(colname) + cols[t].append(colname) + + select_names = [] + colnames = [] + for t in ['p', 'info', base_table] + join_tables: + if not t in cols.keys(): + continue + for col in cols[t]: + select_names.append("{0}.`{1}`".format(t, col)) + colnames.append(col) + column_names = TAB.join(colnames) for colname in sub.keys(): column_names = column_names.replace(colname, sub[colname]) - + + uid = P.unique_identifier + participant_ids = self._primary.query("SELECT `id` FROM participants") data = [] for p in participant_ids: - selected_cols = ",".join(["`"+col+"`" for col in colnames]) - q = "SELECT " + selected_cols + " FROM participants " - if len(P.append_info_cols) and 'session_info' in self._primary.table_schemas: - info_cols = ",".join(['participant_id'] + P.append_info_cols) - q += "JOIN (SELECT " + info_cols + " FROM session_info) AS info " - q += "ON participants.id = info.participant_id " + q = "SELECT {0} ".format(", ".join(select_names)) + q += "FROM participants AS p " + if 'info' in cols.keys(): + q += "JOIN session_info AS info ON p.id = info.participant_id " for t in [base_table] + join_tables: - q += "JOIN {0} ON participants.id = {0}.participant_id ".format(t) - q += " WHERE participants.id = ?" - p_data = [] + q += "JOIN {0} ON p.id = {0}.participant_id ".format(t) + if multisession: + q += "AND info.session_number = {0}.session_num ".format(t) + q += "WHERE p.id = ? " + p_data = [] for trial in self._primary.query(q, q_vars=tuple([p[0]])): row_str = TAB.join(utf8(col) for col in trial) p_data.append(row_str) From 51aed63d01b64b5e19c2b429801713483750903f Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Wed, 20 Sep 2023 19:06:05 -0300 Subject: [PATCH 18/21] Make session, block, trial colnames params --- klibs/KLCommunication.py | 2 +- klibs/KLDatabase.py | 17 ++++++++++------- klibs/KLParams.py | 3 +++ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/klibs/KLCommunication.py b/klibs/KLCommunication.py index a478276..1c52e2b 100755 --- a/klibs/KLCommunication.py +++ b/klibs/KLCommunication.py @@ -182,7 +182,7 @@ def collect_demographics(anonymous=False, unique_id=None): P.session_number = last_session['num'] last = {'participant_id': p_id} if P.session_count > 1: - last['session_num'] = P.session_number + last[P.session_column] = P.session_number for table in _get_user_tables(db): db.delete(table, where=last) last = {'participant_id': p_id, 'session_number': P.session_number} diff --git a/klibs/KLDatabase.py b/klibs/KLDatabase.py index a9cb7c3..c617bac 100755 --- a/klibs/KLDatabase.py +++ b/klibs/KLDatabase.py @@ -629,8 +629,8 @@ def _validate_structure(self, db, multisession=False): if multisession: user_tables = _get_user_tables(db) for table in user_tables: - if not 'session_num' in db.get_columns(table): - raise RuntimeError(e.format('session_num', table)) + if not P.session_column in db.get_columns(table): + raise RuntimeError(e.format(P.session_column, table)) def _is_complete(self, pid): @@ -713,10 +713,12 @@ def get_session_progress(self, pid): cond, last_session_num, completed, random_seed = info[-1] # Gather info about the participant's progress on the last session where = {'participant_id': pid} - if 'session_num' in db.get_columns(P.primary_table): - where['session_num'] = last_session_num + if P.session_column in db.get_columns(P.primary_table): + where[P.session_column] = last_session_num last_trial, last_block = (0, 0) - progress = db.select(P.primary_table, ['trial_num', 'block_num'], where=where) + progress = db.select( + P.primary_table, [P.trial_column, P.block_column], where=where + ) if len(progress): last_trial, last_block = progress[-1] return { @@ -772,7 +774,7 @@ def close(self): def collect_export_data(self, base_table, multi_file=True, join_tables=[]): cols = {'p': []} sub = {P.unique_identifier: 'participant'} - multisession = "session_num" in self._primary.get_columns(base_table) + multisession = P.session_column in self._primary.get_columns(base_table) # if P.default_participant_fields(_sf) is defined use that, but otherwise use # P.exclude_data_cols since that's the better way of doing things @@ -825,7 +827,8 @@ def collect_export_data(self, base_table, multi_file=True, join_tables=[]): for t in [base_table] + join_tables: q += "JOIN {0} ON p.id = {0}.participant_id ".format(t) if multisession: - q += "AND info.session_number = {0}.session_num ".format(t) + session_col = "{0}.{1}".format(t, P.session_column) + q += "AND info.session_number = {0} ".format(session_col) q += "WHERE p.id = ? " p_data = [] for trial in self._primary.query(q, q_vars=tuple([p[0]])): diff --git a/klibs/KLParams.py b/klibs/KLParams.py index b3224aa..23347ae 100755 --- a/klibs/KLParams.py +++ b/klibs/KLParams.py @@ -109,6 +109,9 @@ id_field_name = "participant_id" primary_table = "trials" unique_identifier = "userhash" +session_column = "session_num" +block_column = "block_num" +trial_column = "trial_num" default_participant_fields = [] # for legacy use default_participant_fields_sf = [] # for legacy use exclude_data_cols = ["created"] From 508c977eeaad1efe9439cb35f848841181f971d7 Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Wed, 7 Feb 2024 17:42:08 -0400 Subject: [PATCH 19/21] Fix session resume at end of block --- klibs/KLExperiment.py | 1 + 1 file changed, 1 insertion(+) diff --git a/klibs/KLExperiment.py b/klibs/KLExperiment.py index 6923d3d..ddc7ae9 100755 --- a/klibs/KLExperiment.py +++ b/klibs/KLExperiment.py @@ -67,6 +67,7 @@ def __execute_experiment__(self, *args, **kwargs): # If at end of current block, jump to next block trimmed = trimmed[1:] P.block_number += 1 + P.trial_number = 1 # Prepare for resuming session self.blocks = trimmed P.block_number -= 1 From 0b310e5a8afe4f133379cbc0d0d530aab3471502 Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Wed, 7 Feb 2024 17:48:08 -0400 Subject: [PATCH 20/21] Try fixing unit tests --- klibs/tests/conftest.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/klibs/tests/conftest.py b/klibs/tests/conftest.py index adcda06..8180c25 100644 --- a/klibs/tests/conftest.py +++ b/klibs/tests/conftest.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +import sys import sdl2 import pytest import tempfile @@ -20,12 +21,18 @@ def _init_params_pytest(): P.screen_x, P.screen_y, P.refresh_rate = (1920, 1080, 60.0) +def _check_error_msg(): + # Convenience function for retrieving the current SDL error as a str + e = sdl2.SDL_GetError() + if sys.version_info[0] >= 3: + e = e.decode('utf-8', 'replace') + return e + @pytest.fixture(scope='module') def with_sdl(): sdl2.SDL_ClearError() ret = sdl2.SDL_Init(sdl2.SDL_INIT_VIDEO | sdl2.SDL_INIT_TIMER) - assert sdl2.SDL_GetError() == b"" - assert ret == 0 + assert ret == 0, _check_error_msg() yield sdl2.SDL_Quit() From f8b3625c286321fcbee60eab690a727c4b5cfa53 Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Wed, 7 Feb 2024 17:52:08 -0400 Subject: [PATCH 21/21] Try resolving merge conflicts --- .github/workflows/run_tests.yml | 28 ---------------------------- klibs/tests/conftest.py | 10 +--------- 2 files changed, 1 insertion(+), 37 deletions(-) diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 3131c32..5ad9e5c 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -12,34 +12,6 @@ on: jobs: - # Test on Python 2.7 - test-linux-27: - - name: Linux (Python 2.7) - runs-on: ubuntu-20.04 - container: python:2.7 - - env: - SDL_VIDEODRIVER: dummy - SDL_AUDIODRIVER: dummy - SDL_RENDER_DRIVER: software - - steps: - - uses: actions/checkout@v2 - - - name: Install dependencies for testing - run: | - apt update && apt install -y --fix-missing libgl1-mesa-dev - python -m pip install --upgrade pip - python -m pip install pytest mock - - - name: Install and test KLibs - run: | - python -m pip install . - klibs -h - pytest -vvl -rxXP - - # Test on all supported Python 3.x versions with Linux test-linux: diff --git a/klibs/tests/conftest.py b/klibs/tests/conftest.py index 8180c25..99cc5d4 100644 --- a/klibs/tests/conftest.py +++ b/klibs/tests/conftest.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- -import sys import sdl2 import pytest import tempfile @@ -21,18 +20,11 @@ def _init_params_pytest(): P.screen_x, P.screen_y, P.refresh_rate = (1920, 1080, 60.0) -def _check_error_msg(): - # Convenience function for retrieving the current SDL error as a str - e = sdl2.SDL_GetError() - if sys.version_info[0] >= 3: - e = e.decode('utf-8', 'replace') - return e - @pytest.fixture(scope='module') def with_sdl(): sdl2.SDL_ClearError() ret = sdl2.SDL_Init(sdl2.SDL_INIT_VIDEO | sdl2.SDL_INIT_TIMER) - assert ret == 0, _check_error_msg() + assert ret == 0, sdl2.SDL_GetError().decode('utf-8', 'replace') yield sdl2.SDL_Quit()