CBACABLab 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- CBACABLab/LikertPrep.py +102 -0
- CBACABLab/LoadRawData.py +251 -0
- CBACABLab/LoadSQLData.py +311 -0
- CBACABLab/Plots.py +363 -0
- CBACABLab/Util.py +55 -0
- CBACABLab/__init__.py +5 -0
- CBACABLab/settings.json +193 -0
- cbacablab-0.0.1.dist-info/METADATA +46 -0
- cbacablab-0.0.1.dist-info/RECORD +11 -0
- cbacablab-0.0.1.dist-info/WHEEL +4 -0
- cbacablab-0.0.1.dist-info/licenses/LICENSE +21 -0
CBACABLab/LikertPrep.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
from .LoadRawData import LoadSettings
|
|
3
|
+
from .LoadSQLData import LoadQuestionsWide, LoadSurveyWithSensorDataWide
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def LikertToK(responses, scale, settings=None):
|
|
7
|
+
"""
|
|
8
|
+
Convert Likert responses to 0-based k values using a scale defined in settings.json.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
responses (pandas.Series): Raw responses (text or numeric).
|
|
12
|
+
scale (str or dict): Name of a scale under "LikertScales" in settings.json, or a mapping.
|
|
13
|
+
settings (dict): Optional pre-loaded settings.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
tuple: (k as nullable Int64 Series, bound)
|
|
17
|
+
|
|
18
|
+
Raises:
|
|
19
|
+
KeyError: If the scale name is unknown.
|
|
20
|
+
ValueError: If a non-missing response is not in the scale.
|
|
21
|
+
"""
|
|
22
|
+
if isinstance(scale, str):
|
|
23
|
+
scales = (settings or LoadSettings()).get("LikertScales", {})
|
|
24
|
+
if scale not in scales:
|
|
25
|
+
raise KeyError(f"Unknown Likert scale '{scale}'. Available: {sorted(scales)}")
|
|
26
|
+
scale = scales[scale]
|
|
27
|
+
mapping = {str(key).strip().lower(): value for key, value in scale.items()}
|
|
28
|
+
normalized = responses.astype("string").str.strip().str.lower()
|
|
29
|
+
# Numeric responses stored as floats (e.g. "4.0") should match "4"
|
|
30
|
+
normalized = normalized.str.replace(r"^(-?\d+)\.0+$", r"\1", regex=True)
|
|
31
|
+
k = normalized.map(mapping)
|
|
32
|
+
unknown = responses[normalized.notna() & k.isna()].unique()
|
|
33
|
+
if len(unknown):
|
|
34
|
+
raise ValueError(f"Responses not in scale: {list(unknown)}")
|
|
35
|
+
k = k.dropna().astype('int64')
|
|
36
|
+
return k, max(mapping.values()) # bound
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def AddLikertColumns(data, scales, question_column="question", response_column="response", settings=None):
|
|
40
|
+
"""
|
|
41
|
+
Add k and bound columns to panel data, using a Likert scale chosen per question.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
data (pandas.DataFrame): Data with a question column and a response column, such as the output of CreatePanelData.
|
|
45
|
+
scales (str or dict): A scale name applied to every question, or a dict of
|
|
46
|
+
{question: scale name or {label: k} mapping} giving each question its own scale.
|
|
47
|
+
question_column (str, optional): Column holding the question.
|
|
48
|
+
response_column (str, optional): Column holding the Likert response.
|
|
49
|
+
settings (dict, optional): Pre-loaded settings.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
pandas.DataFrame: A copy of data with nullable Int64 columns k and bound.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
KeyError: If a question has no scale, or a scale name is unknown.
|
|
56
|
+
ValueError: If a response is not in its question's scale.
|
|
57
|
+
"""
|
|
58
|
+
settings = settings or LoadSettings()
|
|
59
|
+
result = data.copy()
|
|
60
|
+
questions = result[question_column].dropna().unique()
|
|
61
|
+
if isinstance(scales, str):
|
|
62
|
+
scales = {question: scales for question in questions}
|
|
63
|
+
missing = [question for question in questions if question not in scales]
|
|
64
|
+
if missing:
|
|
65
|
+
raise KeyError(f"No Likert scale given for questions: {missing}")
|
|
66
|
+
|
|
67
|
+
for question in questions:
|
|
68
|
+
mask = result[question_column] == question
|
|
69
|
+
try:
|
|
70
|
+
k, bound = LikertToK(result.loc[mask, response_column], scales[question], settings)
|
|
71
|
+
except ValueError as error:
|
|
72
|
+
raise ValueError(f"Question '{question}': {error}") from error
|
|
73
|
+
result.loc[mask, "k"] = k
|
|
74
|
+
result.loc[mask, "bound"] = bound
|
|
75
|
+
result = result.astype({'k': 'int64', 'bound': 'int64'})
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def CreatePanelData(question_level_columns, respondent_level_columns, tagFilter=None, instrumentProperties=None, databaseName="data.db"):
|
|
81
|
+
"""
|
|
82
|
+
Create a panel dataset with exactly one row per respondent and question-level question.
|
|
83
|
+
|
|
84
|
+
Question-level data comes from LoadSurveyWithSensorDataWide: question, response, and one column per
|
|
85
|
+
sensor measure and tag (pupil_<tag>, duration_<tag>, <property>_<tag>). When no tags are specified, a tag is not included in the column names.
|
|
86
|
+
Respondent-level questions are added as one response column each.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
question_level_columns (list): List of question-level columns to include.
|
|
90
|
+
respondent_level_columns (list): List of respondent-level columns to include.
|
|
91
|
+
tagFilter (list, tuple, or set, optional): Only include these tags from the sensor data.
|
|
92
|
+
instrumentProperties (list, tuple, or set, optional): Instrument properties to include from the sensor data.
|
|
93
|
+
databaseName (str, optional): Name of the SQLite database file.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
pandas.DataFrame: Merged panel dataset.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
respondent_data = LoadQuestionsWide(respondent_level_columns, databaseName=databaseName)
|
|
100
|
+
question_data = LoadSurveyWithSensorDataWide(question_level_columns, databaseName=databaseName, tagFilter=tagFilter, instrumentProperties=instrumentProperties)
|
|
101
|
+
merged_data = pd.merge(question_data, respondent_data, on="respondent_id", how="left")
|
|
102
|
+
return merged_data
|
CBACABLab/LoadRawData.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import warnings
|
|
3
|
+
warnings.filterwarnings("ignore", category=UserWarning, module="openpyxl")
|
|
4
|
+
import sqlite3
|
|
5
|
+
import os
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
def LoadSettings(path=None):
|
|
9
|
+
"""Load module settings from settings.json (or the given JSON file)."""
|
|
10
|
+
if path is None:
|
|
11
|
+
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "settings.json")
|
|
12
|
+
with open(path, encoding="utf-8") as f:
|
|
13
|
+
return json.load(f)
|
|
14
|
+
|
|
15
|
+
InstrumentColumns = LoadSettings()["InstrumentColumns"]
|
|
16
|
+
|
|
17
|
+
def insertRow(con, row):
|
|
18
|
+
sql = "INSERT OR REPLACE INTO FullSurvey(respondent_id, question, response) VALUES (?, ?, ?)"
|
|
19
|
+
cur = con.cursor()
|
|
20
|
+
cur.execute(sql, row)
|
|
21
|
+
con.commit()
|
|
22
|
+
cur.close()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def AutoDetermineQuestionIdtoQuestionNameMapping(surveyName="Survey.xlsx", surveyCSVName="Survey.csv"):
|
|
26
|
+
"""
|
|
27
|
+
Automatically determine the mapping from question IDs to question names from a Qualtrics survey Excel file and CSV file.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
surveyName (str): The path to the Qualtrics Excel file.
|
|
31
|
+
surveyCSVName (str): The path to the Qualtrics CSV file.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
dict: A dictionary mapping question IDs to question names.
|
|
35
|
+
"""
|
|
36
|
+
qualtricsSurvey = pd.read_excel(surveyName, skiprows=[1])
|
|
37
|
+
qualtricsSurvey.rename(columns=lambda column: column.strip() if isinstance(column, str) else column, inplace=True)
|
|
38
|
+
qualtricsSurveyCSV = pd.read_csv(surveyCSVName)
|
|
39
|
+
qualtricsSurveyCSV.rename(columns=lambda column: column.strip() if isinstance(column, str) else column, inplace=True)
|
|
40
|
+
questionIdtoQuestionName = {}
|
|
41
|
+
for column_index, question_id in enumerate(qualtricsSurveyCSV.columns):
|
|
42
|
+
if isinstance(question_id, str) and question_id.startswith("QID"):
|
|
43
|
+
if column_index < len(qualtricsSurvey.columns):
|
|
44
|
+
questionIdtoQuestionName[question_id] = qualtricsSurvey.columns[column_index]
|
|
45
|
+
return questionIdtoQuestionName
|
|
46
|
+
|
|
47
|
+
def LoadSurveyData(databaseName="data.db", surveyName="Survey.xlsx"):
|
|
48
|
+
"""
|
|
49
|
+
Load Qualtrics survey responses from an Excel file and insert them into a SQLite database.
|
|
50
|
+
|
|
51
|
+
The FullSurvey table is dropped and recreated. The second row of the export (the question text row)
|
|
52
|
+
is skipped, and rows without a numeric iMotionsRespondent value are ignored, as are respondents 99 and 999.
|
|
53
|
+
Every column after the UserLanguage column is treated as a question. Blank responses are not stored.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
databaseName (str; optional): The name of the SQLite database file.
|
|
57
|
+
surveyName (str; optional): The path to the Qualtrics Excel file.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
None
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ValueError: If the UserLanguage column cannot be found.
|
|
64
|
+
"""
|
|
65
|
+
con = sqlite3.connect(databaseName)
|
|
66
|
+
cur = con.cursor()
|
|
67
|
+
cur.execute("DROP TABLE IF EXISTS FullSurvey")
|
|
68
|
+
cur.execute("CREATE TABLE FullSurvey (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, respondent_id INTEGER NOT NULL, question TEXT NOT NULL, response TEXT NOT NULL, UNIQUE(respondent_id, question) ON CONFLICT REPLACE)")
|
|
69
|
+
con.commit()
|
|
70
|
+
cur.close()
|
|
71
|
+
qualtricsSurvey = pd.read_excel(surveyName, skiprows=[1])
|
|
72
|
+
qualtricsSurvey.rename(columns=lambda column: column.strip() if isinstance(column, str) else column, inplace=True)
|
|
73
|
+
|
|
74
|
+
qualtricsSurvey["iMotionsRespondent"] = pd.to_numeric(
|
|
75
|
+
qualtricsSurvey["iMotionsRespondent"], errors="coerce"
|
|
76
|
+
)
|
|
77
|
+
qualtricsSurvey = qualtricsSurvey.loc[qualtricsSurvey["iMotionsRespondent"].notna()].copy()
|
|
78
|
+
|
|
79
|
+
marker_index = next(
|
|
80
|
+
(
|
|
81
|
+
index
|
|
82
|
+
for index, column in enumerate(qualtricsSurvey.columns)
|
|
83
|
+
if "".join(str(column).casefold().split()) == "userlanguage"
|
|
84
|
+
),
|
|
85
|
+
None,
|
|
86
|
+
)
|
|
87
|
+
if marker_index is None:
|
|
88
|
+
raise ValueError("Could not find 'User Language' or 'UserLanguage' column")
|
|
89
|
+
|
|
90
|
+
for _, row in qualtricsSurvey.iterrows():
|
|
91
|
+
respondent_id = int(row["iMotionsRespondent"])
|
|
92
|
+
if respondent_id in (99, 999):
|
|
93
|
+
continue
|
|
94
|
+
for question in qualtricsSurvey.columns[marker_index + 1:]:
|
|
95
|
+
response = row[question]
|
|
96
|
+
if pd.isna(response):
|
|
97
|
+
continue
|
|
98
|
+
response = str(response)
|
|
99
|
+
if response.strip():
|
|
100
|
+
insertRow(con, (respondent_id, str(question), response))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def insertSensorRow(con, row):
|
|
104
|
+
cur = con.cursor()
|
|
105
|
+
cur.execute("INSERT INTO SensorData (respondent_id, question, pupil, duration, tag) VALUES (?, ?, ?, ?, ?)", row)
|
|
106
|
+
con.commit()
|
|
107
|
+
cur.close()
|
|
108
|
+
|
|
109
|
+
def insertFaceRow(con, row):
|
|
110
|
+
cur = con.cursor()
|
|
111
|
+
cur.execute("INSERT INTO InstrumentData (respondent_id, question, property, value, tag) VALUES (?, ?, ?, ?, ?)", row)
|
|
112
|
+
con.commit()
|
|
113
|
+
cur.close()
|
|
114
|
+
|
|
115
|
+
def ExtractData(con, df, respondentid, boundedboxes=None, tag="default", questionIdtoQuestionName=None):
|
|
116
|
+
df = df.rename(columns=lambda column: column.strip() if isinstance(column, str) else column)
|
|
117
|
+
|
|
118
|
+
if boundedboxes is not None:
|
|
119
|
+
if {'Interpolated Gaze X', 'Interpolated Gaze Y'}.issubset(df.columns):
|
|
120
|
+
gaze_x, gaze_y = 'Interpolated Gaze X', 'Interpolated Gaze Y'
|
|
121
|
+
elif {'Gaze X', 'Gaze Y'}.issubset(df.columns):
|
|
122
|
+
gaze_x, gaze_y = 'Gaze X', 'Gaze Y'
|
|
123
|
+
else:
|
|
124
|
+
raise ValueError("Missing gaze columns: expected Interpolated Gaze X and Y or Gaze X and Y")
|
|
125
|
+
|
|
126
|
+
master_mask = pd.Series(False, index=df.index)
|
|
127
|
+
for x_min, x_max, y_min, y_max in boundedboxes:
|
|
128
|
+
bounds = (x_min, x_max, y_min, y_max)
|
|
129
|
+
if any(not isinstance(v, (int, float)) or isinstance(v, bool) for v in bounds):
|
|
130
|
+
raise ValueError(f"Bounding box values must be numbers: {bounds}")
|
|
131
|
+
if x_min >= x_max or y_min >= y_max:
|
|
132
|
+
raise ValueError(f"Invalid bounding box: ({x_min}, {x_max}, {y_min}, {y_max})")
|
|
133
|
+
current_mask = (
|
|
134
|
+
(df[gaze_x] >= x_min) & (df[gaze_x] <= x_max) &
|
|
135
|
+
(df[gaze_y] >= y_min) & (df[gaze_y] <= y_max)
|
|
136
|
+
)
|
|
137
|
+
master_mask |= current_mask
|
|
138
|
+
filtered_df = df[master_mask].copy()
|
|
139
|
+
else:
|
|
140
|
+
filtered_df = df.copy()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
listOfQuestions = (
|
|
144
|
+
df.loc[
|
|
145
|
+
df['MarkerName'].notna()
|
|
146
|
+
& (df['MarkerName'].str.strip() != '')
|
|
147
|
+
& (~df['MarkerName'].str.endswith('_end', na=False)),
|
|
148
|
+
['Timestamp', 'MarkerName']
|
|
149
|
+
]
|
|
150
|
+
.drop_duplicates(subset=['MarkerName'], keep='first')
|
|
151
|
+
)
|
|
152
|
+
listOfQuestionEnds = (
|
|
153
|
+
df.loc[
|
|
154
|
+
df['MarkerName'].notna()
|
|
155
|
+
& (df['MarkerName'].str.strip() != '')
|
|
156
|
+
& (df['MarkerName'].str.endswith('_end', na=False)),
|
|
157
|
+
['Timestamp', 'MarkerName']
|
|
158
|
+
]
|
|
159
|
+
.drop_duplicates(subset=['MarkerName'], keep='last')
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
starts = listOfQuestions.copy()
|
|
163
|
+
starts['question'] = starts['MarkerName'].str.strip()
|
|
164
|
+
|
|
165
|
+
ends = listOfQuestionEnds.copy()
|
|
166
|
+
ends['question'] = ends['MarkerName'].str.strip().str.replace(r'_end$', '', regex=True)
|
|
167
|
+
|
|
168
|
+
listOfQuestions = (
|
|
169
|
+
starts.merge(
|
|
170
|
+
ends[['Timestamp', 'question']],
|
|
171
|
+
on='question',
|
|
172
|
+
how='inner',
|
|
173
|
+
suffixes=('_start', '_end')
|
|
174
|
+
)
|
|
175
|
+
.rename(columns={'Timestamp_start': 'start_time', 'Timestamp_end': 'end_time'})
|
|
176
|
+
[['question', 'start_time', 'end_time']]
|
|
177
|
+
.sort_values('start_time')
|
|
178
|
+
.reset_index(drop=True)
|
|
179
|
+
)
|
|
180
|
+
for _, q in listOfQuestions.iterrows():
|
|
181
|
+
question = q['question']
|
|
182
|
+
if questionIdtoQuestionName and question in questionIdtoQuestionName:
|
|
183
|
+
question_name = questionIdtoQuestionName[question]
|
|
184
|
+
else:
|
|
185
|
+
question_name = str(question).strip()
|
|
186
|
+
start_time = q['start_time']
|
|
187
|
+
end_time = q['end_time']
|
|
188
|
+
|
|
189
|
+
df2 = filtered_df.loc[
|
|
190
|
+
(filtered_df['Timestamp'] >= start_time) & (filtered_df['Timestamp'] <= end_time),
|
|
191
|
+
['ET_PupilLeft', 'ET_PupilRight']
|
|
192
|
+
].copy()
|
|
193
|
+
|
|
194
|
+
df3 = filtered_df.loc[
|
|
195
|
+
(filtered_df['Timestamp'] >= start_time) & (filtered_df['Timestamp'] <= end_time),
|
|
196
|
+
['Fixation Duration']
|
|
197
|
+
].copy()
|
|
198
|
+
for col in InstrumentColumns:
|
|
199
|
+
if col in filtered_df.columns:
|
|
200
|
+
dfTemp = filtered_df.loc[
|
|
201
|
+
(filtered_df['Timestamp'] >= start_time) & (filtered_df['Timestamp'] <= end_time),
|
|
202
|
+
col
|
|
203
|
+
].copy()
|
|
204
|
+
featureMean = dfTemp.mean()
|
|
205
|
+
if not pd.isna(featureMean):
|
|
206
|
+
row = (respondentid, question_name, col, featureMean, tag)
|
|
207
|
+
insertFaceRow(con, row)
|
|
208
|
+
|
|
209
|
+
df2['ET_PupilLeft'] = pd.to_numeric(df2['ET_PupilLeft'], errors='coerce')
|
|
210
|
+
df2['ET_PupilRight'] = pd.to_numeric(df2['ET_PupilRight'], errors='coerce')
|
|
211
|
+
df2 = df2.dropna(subset=['ET_PupilLeft', 'ET_PupilRight'])
|
|
212
|
+
pupil = (df2['ET_PupilLeft'].mean() + df2['ET_PupilRight'].mean()) / 2
|
|
213
|
+
duration = df3['Fixation Duration'].mean()
|
|
214
|
+
row = (respondentid, question_name, float(pupil), float(duration), tag)
|
|
215
|
+
insertSensorRow(con, row)
|
|
216
|
+
|
|
217
|
+
def LoadSensorData(databaseName = "data.db", sensorFolder = "./sensorData/", boundedboxes = None, tag = "default", rebuild = False, low_memory = False, questionIdtoQuestionName = None):
|
|
218
|
+
"""
|
|
219
|
+
Load sensor data from a folder and insert it into a SQLite database.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
databaseName (str; optional): The name of the SQLite database file.
|
|
223
|
+
sensorFolder (str; optional): The path to the folder containing the sensor data.
|
|
224
|
+
boundedboxes (list; optional): A list of bounded boxes to filter the data.
|
|
225
|
+
tag (str; optional): A tag to associate with the data.
|
|
226
|
+
rebuild (bool; optional): Whether to rebuild the database.
|
|
227
|
+
low_memory (bool; optional): Whether to use low memory mode when reading CSV files.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
None
|
|
231
|
+
"""
|
|
232
|
+
con = sqlite3.connect(databaseName)
|
|
233
|
+
cur = con.cursor()
|
|
234
|
+
if rebuild:
|
|
235
|
+
cur.execute("DROP TABLE IF EXISTS SensorData")
|
|
236
|
+
cur.execute("DROP TABLE IF EXISTS InstrumentData")
|
|
237
|
+
cur.execute("CREATE TABLE IF NOT EXISTS SensorData (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, respondent_id INTEGER NOT NULL, question TEXT NOT NULL, pupil REAL, duration REAL, tag TEXT, UNIQUE(respondent_id, question, tag) ON CONFLICT REPLACE)")
|
|
238
|
+
cur.execute("CREATE TABLE IF NOT EXISTS InstrumentData (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, respondent_id INTEGER NOT NULL, question TEXT NOT NULL, property TEXT NOT NULL, value REAL, tag TEXT, UNIQUE(respondent_id, question, property, tag) ON CONFLICT REPLACE)")
|
|
239
|
+
con.commit()
|
|
240
|
+
cur.close()
|
|
241
|
+
folder = sensorFolder
|
|
242
|
+
if not os.path.isdir(folder):
|
|
243
|
+
raise FileNotFoundError(f"Sensor folder does not exist: {folder}")
|
|
244
|
+
|
|
245
|
+
f = os.listdir(folder)
|
|
246
|
+
for file in f:
|
|
247
|
+
filename, file_extension = os.path.splitext(file)
|
|
248
|
+
if file_extension == '.csv':
|
|
249
|
+
_, respondentid = filename.split('_')
|
|
250
|
+
df = pd.read_csv(folder+file, comment='#', low_memory=low_memory)
|
|
251
|
+
ExtractData(con, df, int(respondentid), boundedboxes, tag, questionIdtoQuestionName)
|
CBACABLab/LoadSQLData.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import sqlite3
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from .LoadRawData import LoadSettings
|
|
5
|
+
|
|
6
|
+
def _base_question(q):
|
|
7
|
+
# Strip a trailing _<digits> only if something remains before it
|
|
8
|
+
return re.sub(r"^(.+)_\d+$", r"\1", q)
|
|
9
|
+
|
|
10
|
+
def ListInstrumentColumns(databaseName="data.db"):
|
|
11
|
+
"""
|
|
12
|
+
Return a list of all distinct instrument columns in the InstrumentData table.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
databaseName (str or sqlite3.Connection; optional): The path to the SQLite database file or an open connection.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
list: The instrument columns, sorted alphabetically.
|
|
19
|
+
"""
|
|
20
|
+
owns_connection = not isinstance(databaseName, sqlite3.Connection)
|
|
21
|
+
con = sqlite3.connect(databaseName) if owns_connection else databaseName
|
|
22
|
+
try:
|
|
23
|
+
df = pd.read_sql_query(
|
|
24
|
+
"SELECT DISTINCT property FROM InstrumentData ORDER BY property", con
|
|
25
|
+
)
|
|
26
|
+
finally:
|
|
27
|
+
if owns_connection:
|
|
28
|
+
con.close()
|
|
29
|
+
return df["property"].tolist()
|
|
30
|
+
|
|
31
|
+
def ListLikertScales(settings=None, show_values=False):
|
|
32
|
+
"""
|
|
33
|
+
Return a list of all Likert scales defined in settings.json.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
settings (dict): Optional pre-loaded settings.
|
|
37
|
+
show_values (bool): If True, return a list of (scale_name, scale_values) tuples.
|
|
38
|
+
"""
|
|
39
|
+
scales = (settings or LoadSettings()).get("LikertScales", {})
|
|
40
|
+
if show_values:
|
|
41
|
+
return sorted(scales.items())
|
|
42
|
+
return sorted(scales.keys())
|
|
43
|
+
|
|
44
|
+
def ListQuestions(databaseName="data.db"):
|
|
45
|
+
"""
|
|
46
|
+
Return a list of all distinct questions in the FullSurvey table.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
databaseName (str or sqlite3.Connection; optional): The path to the SQLite database file or an open connection.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
list: The questions, sorted alphabetically.
|
|
53
|
+
"""
|
|
54
|
+
owns_connection = not isinstance(databaseName, sqlite3.Connection)
|
|
55
|
+
con = sqlite3.connect(databaseName) if owns_connection else databaseName
|
|
56
|
+
try:
|
|
57
|
+
df = pd.read_sql_query(
|
|
58
|
+
"SELECT DISTINCT question FROM FullSurvey ORDER BY question", con
|
|
59
|
+
)
|
|
60
|
+
finally:
|
|
61
|
+
if owns_connection:
|
|
62
|
+
con.close()
|
|
63
|
+
return df["question"].tolist()
|
|
64
|
+
|
|
65
|
+
def LoadQuestionsWide(questions, databaseName="data.db"):
|
|
66
|
+
"""
|
|
67
|
+
Load survey responses in wide format, with one row per respondent and one column per question.
|
|
68
|
+
|
|
69
|
+
Pivots the long-format FullSurvey table using one LEFT JOIN per question. Missing answers
|
|
70
|
+
become NaN. Columns whose non-missing values all parse as numbers are converted to numeric dtypes.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
questions (list): The questions to include as columns. Duplicates are ignored.
|
|
74
|
+
databaseName (str or sqlite3.Connection; optional): The path to the SQLite database file or an open connection.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
pandas.DataFrame: A respondent_id column followed by one column per question.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ValueError: If questions is empty.
|
|
81
|
+
"""
|
|
82
|
+
questions = list(dict.fromkeys(questions))
|
|
83
|
+
if not questions:
|
|
84
|
+
raise ValueError("questions must contain at least one question")
|
|
85
|
+
|
|
86
|
+
selects = ["r.respondent_id AS respondent_id"]
|
|
87
|
+
joins = []
|
|
88
|
+
for i in range(len(questions)):
|
|
89
|
+
selects.append(f"q{i}.response AS q{i}")
|
|
90
|
+
joins.append(
|
|
91
|
+
f"LEFT JOIN FullSurvey q{i} "
|
|
92
|
+
f"ON q{i}.respondent_id = r.respondent_id AND q{i}.question = ?"
|
|
93
|
+
)
|
|
94
|
+
sql = (
|
|
95
|
+
f"SELECT {', '.join(selects)} "
|
|
96
|
+
"FROM (SELECT DISTINCT respondent_id FROM FullSurvey) r "
|
|
97
|
+
f"{' '.join(joins)} "
|
|
98
|
+
"ORDER BY r.respondent_id"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
owns_connection = not isinstance(databaseName, sqlite3.Connection)
|
|
102
|
+
con = sqlite3.connect(databaseName) if owns_connection else databaseName
|
|
103
|
+
try:
|
|
104
|
+
df = pd.read_sql_query(sql, con, params=questions)
|
|
105
|
+
finally:
|
|
106
|
+
if owns_connection:
|
|
107
|
+
con.close()
|
|
108
|
+
|
|
109
|
+
df.columns = ["respondent_id"] + questions
|
|
110
|
+
for column in questions:
|
|
111
|
+
converted = pd.to_numeric(df[column], errors="coerce")
|
|
112
|
+
if converted.notna().sum() == df[column].notna().sum():
|
|
113
|
+
df[column] = converted
|
|
114
|
+
return df
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def LoadSurveyWithSensorDataWide(questions, databaseName="data.db", tagFilter=None, instrumentProperties=None):
|
|
120
|
+
"""
|
|
121
|
+
Join survey responses to SensorData and InstrumentData in wide format, with one row per respondent and question.
|
|
122
|
+
|
|
123
|
+
The survey is joined to SensorData and InstrumentData on respondent_id and question. Each tag
|
|
124
|
+
gets its own columns: pupil_<tag> and duration_<tag> for SensorData, and <property>_<tag>
|
|
125
|
+
for InstrumentData. When no tags are specified, a tag is not included in the column names.
|
|
126
|
+
Missing sensor or instrument data becomes NaN.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
questions (list): The questions to include. Duplicates are ignored.
|
|
130
|
+
tagFilter (str, optional): Filter for specific tags in the sensor data.
|
|
131
|
+
instrumentProperties (list, optional): List of instrument properties to include from the instrument data.
|
|
132
|
+
databaseName (str or sqlite3.Connection; optional): The path to the SQLite database file or an open connection.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
pandas.DataFrame: One row per (respondent_id, question) with the survey response
|
|
136
|
+
and the sensor and instrument columns described above.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
ValueError: If questions is empty.
|
|
140
|
+
"""
|
|
141
|
+
questions = list(dict.fromkeys(questions))
|
|
142
|
+
if not questions:
|
|
143
|
+
raise ValueError("questions must contain at least one question")
|
|
144
|
+
marks = ", ".join("?" * len(questions))
|
|
145
|
+
baseQuestions = list(dict.fromkeys(_base_question(q) for q in questions))
|
|
146
|
+
baseMarks = ", ".join("?" * len(baseQuestions))
|
|
147
|
+
|
|
148
|
+
if tagFilter is None:
|
|
149
|
+
tagFilter = ["default",]
|
|
150
|
+
|
|
151
|
+
if not isinstance(tagFilter, (list, tuple, set)):
|
|
152
|
+
raise ValueError("tagFilter must be a list, tuple, or set")
|
|
153
|
+
tagFilter = set(tagFilter)
|
|
154
|
+
if not tagFilter:
|
|
155
|
+
raise ValueError("tagFilter must contain at least one tag")
|
|
156
|
+
tagCondition = f"AND tag IN ({', '.join('?' * len(tagFilter))})"
|
|
157
|
+
tagParams = list(tagFilter)
|
|
158
|
+
|
|
159
|
+
if instrumentProperties is not None:
|
|
160
|
+
if not isinstance(instrumentProperties, (list, tuple, set)):
|
|
161
|
+
raise ValueError("instrumentProperties must be a list, tuple, or set")
|
|
162
|
+
instrumentProperties = set(instrumentProperties)
|
|
163
|
+
if not instrumentProperties:
|
|
164
|
+
raise ValueError("instrumentProperties must contain at least one property")
|
|
165
|
+
propertyCondition = f"AND property IN ({', '.join('?' * len(instrumentProperties))})"
|
|
166
|
+
propertyParams = list(instrumentProperties)
|
|
167
|
+
else:
|
|
168
|
+
propertyCondition = ""
|
|
169
|
+
propertyParams = []
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
owns_connection = not isinstance(databaseName, sqlite3.Connection)
|
|
173
|
+
con = sqlite3.connect(databaseName) if owns_connection else databaseName
|
|
174
|
+
try:
|
|
175
|
+
survey = pd.read_sql_query(
|
|
176
|
+
"SELECT respondent_id, question, response FROM FullSurvey "
|
|
177
|
+
f"WHERE question IN ({marks})", con, params=questions)
|
|
178
|
+
sensor = pd.read_sql_query(
|
|
179
|
+
"SELECT respondent_id, question, tag, pupil, duration FROM SensorData "
|
|
180
|
+
f"WHERE question IN ({baseMarks}) {tagCondition}", con, params=baseQuestions + tagParams)
|
|
181
|
+
face = pd.read_sql_query(
|
|
182
|
+
"SELECT respondent_id, question, tag, property, value FROM InstrumentData "
|
|
183
|
+
f"WHERE question IN ({baseMarks}) {tagCondition} {propertyCondition}", con, params=baseQuestions + tagParams + propertyParams)
|
|
184
|
+
finally:
|
|
185
|
+
if owns_connection:
|
|
186
|
+
con.close()
|
|
187
|
+
|
|
188
|
+
key = ["respondent_id", "question"]
|
|
189
|
+
mkey = ["respondent_id", "base"]
|
|
190
|
+
|
|
191
|
+
def widen(df, columns, values):
|
|
192
|
+
if df.empty:
|
|
193
|
+
return df[key].copy().rename(columns={"question": "base"})
|
|
194
|
+
wide = df.pivot_table(index=key, columns=columns, values=values, aggfunc="mean")
|
|
195
|
+
|
|
196
|
+
def column_name(measure, tag):
|
|
197
|
+
return measure if tag == "default" else f"{measure}_{tag}"
|
|
198
|
+
|
|
199
|
+
if isinstance(wide.columns, pd.MultiIndex) and len(values) == 1:
|
|
200
|
+
wide.columns = [column_name(c[-2], c[-1]) for c in wide.columns]
|
|
201
|
+
else:
|
|
202
|
+
wide.columns = [column_name(measure, tag) for measure, tag in wide.columns]
|
|
203
|
+
return wide.reset_index().rename(columns={"question": "base"})
|
|
204
|
+
|
|
205
|
+
sensor = widen(sensor.melt(id_vars=key + ["tag"], value_vars=["pupil", "duration"],
|
|
206
|
+
var_name="measure", value_name="value"),
|
|
207
|
+
["measure", "tag"], ["value"])
|
|
208
|
+
face = widen(face.rename(columns={"property": "measure"}),
|
|
209
|
+
["measure", "tag"], ["value"])
|
|
210
|
+
|
|
211
|
+
survey["base"] = survey["question"].map(_base_question)
|
|
212
|
+
result = (survey.merge(sensor, on=mkey, how="left")
|
|
213
|
+
.merge(face, on=mkey, how="left")
|
|
214
|
+
.drop(columns="base"))
|
|
215
|
+
result["question"] = pd.Categorical(result["question"], categories=questions, ordered=True)
|
|
216
|
+
result = result.sort_values(key).reset_index(drop=True)
|
|
217
|
+
result["question"] = result["question"].astype(str)
|
|
218
|
+
|
|
219
|
+
for column in result.columns:
|
|
220
|
+
if column in key:
|
|
221
|
+
continue
|
|
222
|
+
converted = pd.to_numeric(result[column], errors="coerce")
|
|
223
|
+
if converted.notna().sum() == result[column].notna().sum():
|
|
224
|
+
result[column] = converted
|
|
225
|
+
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
def LoadSurveyWithSensorDataLong(questions, databaseName="data.db", tagFilter=None, instrumentProperties=None):
|
|
229
|
+
"""
|
|
230
|
+
Join survey responses to SensorData and InstrumentData in long format, with one row per tag and instrument property.
|
|
231
|
+
|
|
232
|
+
The survey is joined to SensorData on respondent_id and question, and then to InstrumentData on
|
|
233
|
+
respondent_id, question and tag. Missing sensor or instrument data becomes NaN.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
questions (list): The questions to include. Duplicates are ignored.
|
|
237
|
+
databaseName (str or sqlite3.Connection; optional): The path to the SQLite database file or an open connection.
|
|
238
|
+
tagFilter (list, tuple, or set; optional): If provided, only include rows with a tag in this set.
|
|
239
|
+
instrumentProperties (list; optional): A list of instrument properties to include.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
pandas.DataFrame: One row per (respondent_id, question, tag, property) with the columns
|
|
243
|
+
response, pupil, duration, property, and value.
|
|
244
|
+
|
|
245
|
+
Raises:
|
|
246
|
+
ValueError: If questions is empty.
|
|
247
|
+
"""
|
|
248
|
+
questions = list(dict.fromkeys(questions))
|
|
249
|
+
if not questions:
|
|
250
|
+
raise ValueError("questions must contain at least one question")
|
|
251
|
+
marks = ", ".join("?" * len(questions))
|
|
252
|
+
baseQuestions = list(dict.fromkeys(_base_question(q) for q in questions))
|
|
253
|
+
baseMarks = ", ".join("?" * len(baseQuestions))
|
|
254
|
+
|
|
255
|
+
if tagFilter is None:
|
|
256
|
+
tagFilter = ["default",]
|
|
257
|
+
|
|
258
|
+
if not isinstance(tagFilter, (list, tuple, set)):
|
|
259
|
+
raise ValueError("tagFilter must be a list, tuple, or set")
|
|
260
|
+
tagFilter = set(tagFilter)
|
|
261
|
+
if not tagFilter:
|
|
262
|
+
raise ValueError("tagFilter must contain at least one tag")
|
|
263
|
+
tagCondition = f"AND tag IN ({', '.join('?' * len(tagFilter))})"
|
|
264
|
+
tagParams = list(tagFilter)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
if instrumentProperties is not None:
|
|
268
|
+
if not isinstance(instrumentProperties, (list, tuple, set)):
|
|
269
|
+
raise ValueError("instrumentProperties must be a list, tuple, or set")
|
|
270
|
+
instrumentProperties = set(instrumentProperties)
|
|
271
|
+
if not instrumentProperties:
|
|
272
|
+
raise ValueError("instrumentProperties must contain at least one property")
|
|
273
|
+
propertyCondition = f"AND property IN ({', '.join('?' * len(instrumentProperties))})"
|
|
274
|
+
propertyParams = list(instrumentProperties)
|
|
275
|
+
else:
|
|
276
|
+
propertyCondition = ""
|
|
277
|
+
propertyParams = []
|
|
278
|
+
|
|
279
|
+
owns_connection = not isinstance(databaseName, sqlite3.Connection)
|
|
280
|
+
con = sqlite3.connect(databaseName) if owns_connection else databaseName
|
|
281
|
+
try:
|
|
282
|
+
survey = pd.read_sql_query(
|
|
283
|
+
"SELECT respondent_id, question, response FROM FullSurvey "
|
|
284
|
+
f"WHERE question IN ({marks})", con, params=questions)
|
|
285
|
+
sensor = pd.read_sql_query(
|
|
286
|
+
"SELECT respondent_id, question, tag, pupil, duration FROM SensorData "
|
|
287
|
+
f"WHERE question IN ({baseMarks}) {tagCondition}", con, params=baseQuestions + tagParams)
|
|
288
|
+
face = pd.read_sql_query(
|
|
289
|
+
"SELECT respondent_id, question, tag, property, value FROM InstrumentData "
|
|
290
|
+
f"WHERE question IN ({baseMarks}) {tagCondition} {propertyCondition}", con,
|
|
291
|
+
params=baseQuestions + tagParams + propertyParams)
|
|
292
|
+
finally:
|
|
293
|
+
if owns_connection:
|
|
294
|
+
con.close()
|
|
295
|
+
|
|
296
|
+
survey["base"] = survey["question"].map(_base_question)
|
|
297
|
+
sensor = sensor.rename(columns={"question": "base"})
|
|
298
|
+
face = face.rename(columns={"question": "base"})
|
|
299
|
+
result = survey.merge(sensor, on=["respondent_id", "base"], how="left")
|
|
300
|
+
result = result.merge(face, on=["respondent_id", "base", "tag"], how="left")
|
|
301
|
+
result = result.drop(columns="base")
|
|
302
|
+
result["question"] = pd.Categorical(result["question"], categories=questions, ordered=True)
|
|
303
|
+
result = result.sort_values(["respondent_id", "question", "tag"]).reset_index(drop=True)
|
|
304
|
+
result["question"] = result["question"].astype(str)
|
|
305
|
+
for column in result.columns:
|
|
306
|
+
if column in ["respondent_id", "question", "tag"]:
|
|
307
|
+
continue
|
|
308
|
+
converted = pd.to_numeric(result[column], errors="coerce")
|
|
309
|
+
if converted.notna().sum() == result[column].notna().sum():
|
|
310
|
+
result[column] = converted
|
|
311
|
+
return result
|