more metadata

This commit is contained in:
2026-08-03 00:46:18 -07:00
parent 7c456e31e7
commit 0b52d2b0bc
2 changed files with 174 additions and 102 deletions
+7 -1
View File
@@ -1,12 +1,18 @@
# Verison 1.6.0 # Verison 1.6.0
- This is potentially a save-changing release due to adding more data into the save file. Please update your project files to ensure compatibility - This is potentially a save-changing release due to adding more data into the save file and renaming existing data. Please update your project files to ensure compatibility
- It is still possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu, but your mileage may vary - It is still possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu, but your mileage may vary
- Optimized calculations being performed when calculating the heart rate to speed up step 5 by up to ~35% on a per-file basis - Optimized calculations being performed when calculating the heart rate to speed up step 5 by up to ~35% on a per-file basis
- Optimized calculations being performed when running the General Linear Model to speed up step 5 by ~35% on a per-file basis - Optimized calculations being performed when running the General Linear Model to speed up step 5 by ~35% on a per-file basis
- Fixed an issue where participants could be skipped when processing multiple particants at one which could prevent overall processing from completing - Fixed an issue where participants could be skipped when processing multiple particants at one which could prevent overall processing from completing
- Fixed the two Group Stats Viewer windows crashing the application once opened when JSON_LOCATION was not set - Fixed the two Group Stats Viewer windows crashing the application once opened when JSON_LOCATION was not set
- The Group Stats Viewer windows will now properly load the Right/Left or Front/Back fallback ROIs if JSON_LOCATION is not set - The Group Stats Viewer windows will now properly load the Right/Left or Front/Back fallback ROIs if JSON_LOCATION is not set
- Changed the warning message of lots of short channels to be percentage based (25%) instead of numerical based (6)
- Renames GENDER to SEX to better represent the values that the field expects and to be more BIDS compliant
- Changed the Assign Groups by AGE window to now be the Assign Groups by Metadata window and added support for grouping by other metadata values
- Added a new metadata value of HAND to allow for grouping participants based on their handiness
- Updated the layout of the participant metadata area to better accommodate current and future metadata values
- Updated the message displayed when "Why are these useful?" is clicked to better represent what the values are used for and to accomidate the new metadata values
# Version 1.5.2 # Version 1.5.2
+162 -96
View File
@@ -34,7 +34,7 @@ from PySide6.QtWidgets import (
QPushButton, QMainWindow, QFileDialog, QLabel, QLineEdit, QFrame, QSizePolicy, QGroupBox, QDialog, QMenu, QSpinBox, QTableWidget, QTableWidgetItem QPushButton, QMainWindow, QFileDialog, QLabel, QLineEdit, QFrame, QSizePolicy, QGroupBox, QDialog, QMenu, QSpinBox, QTableWidget, QTableWidgetItem
) )
from PySide6.QtCore import QThread, Signal, Qt, QTimer, QPoint from PySide6.QtCore import QThread, Signal, Qt, QTimer, QPoint
from PySide6.QtGui import QAction, QKeySequence, QIcon from PySide6.QtGui import QAction, QFontMetrics, QKeySequence, QIcon
from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen
from src.window.about import AboutWindow from src.window.about import AboutWindow
@@ -333,8 +333,11 @@ DATA_SCHEMA = [
{"key": "valid_dict", "help": "Dict[file_path, bool]: Boolean validity status per file"} {"key": "valid_dict", "help": "Dict[file_path, bool]: Boolean validity status per file"}
] ]
BIDS_FIELD_MAP = {
"BIDS - Age": "AGE",
"BIDS - Sex": "SEX",
"BIDS - Hand": "HAND",
}
@@ -378,30 +381,49 @@ class SavingOverlay(QDialog):
class GroupAssignmentDialog(QDialog): class GroupAssignmentDialog(QDialog):
"""Dialog allowing users to create groups and assign unique metadata values to them.""" """Dialog allowing users to create groups and assign unique metadata values to them."""
def __init__(self, parent=None, unique_values=None, field_name="AGE"): def __init__(self, parent=None, file_metadata: dict | None = None, field_names: list[str] | None = None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle(f"Assign Groups by {field_name}") self.setWindowTitle(f"Assign Groups by Metadata - {APP_NAME.upper()}")
self.resize(520, 420) self.resize(520, 420)
self.unique_values = unique_values or [] self.file_metadata = file_metadata or {}
self.field_name = field_name
self.field_names = [
f for f in (field_names or [])
if self._unique_values_for(f)
]
# Initial default groups
self.groups = ["Group A", "Group B"] self.groups = ["Group A", "Group B"]
self.combos = [] self.combos = []
self.unique_values = []
self._init_ui() self._init_ui()
def _unique_values_for(self, field_name: str) -> list[str]:
return sorted(
{
str(meta.get(field_name, "")).strip()
for meta in self.file_metadata.values()
if str(meta.get(field_name, "")).strip()
}
)
def _init_ui(self): def _init_ui(self):
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
# Header info # Field selector
info_label = QLabel( field_box_layout = QHBoxLayout()
f"Found <b>{len(self.unique_values)}</b> unique <i>{self.field_name}</i> value(s) " field_box_layout.addWidget(QLabel("Group by:"))
f"across loaded files.<br>Create custom group names and assign each value below:" self.field_selector = QComboBox()
) self.field_selector.addItems(self.field_names)
info_label.setWordWrap(True) self.field_selector.currentTextChanged.connect(self._on_field_changed)
layout.addWidget(info_label) field_box_layout.addWidget(self.field_selector)
layout.addLayout(field_box_layout)
# Header info (updated dynamically in _on_field_changed)
self.info_label = QLabel()
self.info_label.setWordWrap(True)
layout.addWidget(self.info_label)
# Group Creation Bar # Group Creation Bar
group_box_layout = QHBoxLayout() group_box_layout = QHBoxLayout()
@@ -419,26 +441,10 @@ class GroupAssignmentDialog(QDialog):
layout.addLayout(group_box_layout) layout.addLayout(group_box_layout)
# Mapping Table (Values -> Group Dropdown) # Mapping Table (Values -> Group Dropdown)
self.table = QTableWidget(len(self.unique_values), 2) self.table = QTableWidget(0, 2)
self.table.setHorizontalHeaderLabels(
[f"{self.field_name} Value", "Assigned Group"]
)
self.table.horizontalHeader().setSectionResizeMode( self.table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch QHeaderView.ResizeMode.Stretch
) )
for row, val in enumerate(self.unique_values):
# Metadata Value Column (Read-Only)
val_item = QTableWidgetItem(str(val))
val_item.setFlags(val_item.flags() ^ Qt.ItemFlag.ItemIsEditable)
self.table.setItem(row, 0, val_item)
# Assigned Group Column (ComboBox)
combo = QComboBox()
self.combos.append(combo)
self.table.setCellWidget(row, 1, combo)
self._refresh_combos()
layout.addWidget(self.table) layout.addWidget(self.table)
# Dialog Buttons # Dialog Buttons
@@ -450,32 +456,59 @@ class GroupAssignmentDialog(QDialog):
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
layout.addWidget(buttons) layout.addWidget(buttons)
@classmethod # Populate for whichever field is selected first
def run_for_field(cls, parent, file_metadata: dict, field_name: str = "AGE"): if self.field_names:
"""Extracts values, checks for data, presents the dialog, and returns mappings.""" self._rebuild_table(self.field_names[0])
# A. Collect unique non-empty values
unique_values = sorted( def _on_field_changed(self, field_name: str):
list( if field_name:
{ self._rebuild_table(field_name)
str(meta.get(field_name, "")).strip()
for meta in file_metadata.values() def _rebuild_table(self, field_name: str):
if str(meta.get(field_name, "")).strip() """Rebuilds the value table for the newly-selected field. Any group
} names created so far (self.groups) are kept - only the per-value
) rows and their combo selections reset, since a value from one
field has no meaningful mapping to a value from another."""
self.field_name = field_name
self.unique_values = self._unique_values_for(field_name)
self.info_label.setText(
f"Found <b>{len(self.unique_values)}</b> unique <i>{field_name}</i> value(s) "
f"across loaded files.<br>Create custom group names and assign each value below:"
) )
if not unique_values: self.table.setRowCount(len(self.unique_values))
self.table.setHorizontalHeaderLabels([f"{field_name} Value", "Assigned Group"])
self.combos = []
for row, val in enumerate(self.unique_values):
val_item = QTableWidgetItem(str(val))
val_item.setFlags(val_item.flags() ^ Qt.ItemFlag.ItemIsEditable)
self.table.setItem(row, 0, val_item)
combo = QComboBox()
self.combos.append(combo)
self.table.setCellWidget(row, 1, combo)
self._refresh_combos()
@classmethod
def run(cls, parent, file_metadata: dict, field_names: list[str] = ["AGE", "SEX", "HAND"]):
"""Checks for groupable data across the given fields, presents the
dialog with a field-selector dropdown, and returns (field_name, mappings)
for whichever field the user grouped by."""
dialog = cls(parent, file_metadata=file_metadata, field_names=field_names)
if not dialog.field_names:
QMessageBox.information( QMessageBox.information(
parent, parent,
"No Groupable Metadata", "No Groupable Metadata",
f"No '{field_name}' values were found in the metadata to group.", f"None of {field_names} had values found in the metadata to group.",
) )
return None return None
# B. Instantiate and show dialog
dialog = cls(parent, unique_values=unique_values, field_name=field_name)
if dialog.exec() == QDialog.DialogCode.Accepted: if dialog.exec() == QDialog.DialogCode.Accepted:
return dialog.get_mappings() return dialog.field_name, dialog.get_mappings()
return None return None
@@ -495,7 +528,6 @@ class GroupAssignmentDialog(QDialog):
combo.addItem("-- Select Group --") combo.addItem("-- Select Group --")
combo.addItems(self.groups) combo.addItems(self.groups)
# Preserve previous selection if it still exists
if current_selection in self.groups: if current_selection in self.groups:
combo.setCurrentText(current_selection) combo.setCurrentText(current_selection)
@@ -684,7 +716,7 @@ class MainApplication(QMainWindow):
for item in DATA_SCHEMA: for item in DATA_SCHEMA:
setattr(self, item["key"], {}) setattr(self, item["key"], {})
self.file_metadata = {} # AGE, GENDER, GROUP self.file_metadata = {} # AGE, SEX, HAND, GROUP
self.metadata_cache = {} # Internal file/path information metadata cache self.metadata_cache = {} # Internal file/path information metadata cache
self.bubble_widgets = {} # References to the UI "Bubble" objects self.bubble_widgets = {} # References to the UI "Bubble" objects
self.current_file = None # Tracks the currently selected absolute path self.current_file = None # Tracks the currently selected absolute path
@@ -778,16 +810,25 @@ class MainApplication(QMainWindow):
self.right_column_widget = QWidget() self.right_column_widget = QWidget()
right_column_layout = QVBoxLayout(self.right_column_widget) right_column_layout = QVBoxLayout(self.right_column_widget)
self.meta_fields = {"AGE": QLineEdit(), "GENDER": QLineEdit(), "GROUP": QLineEdit()} self.meta_fields = {"AGE": QLineEdit(), "SEX": QLineEdit(), "HAND": QLineEdit(), "GROUP": QLineEdit()}
font_metrics = QFontMetrics(self.font())
label_width = max(font_metrics.horizontalAdvance(key.capitalize()) for key in self.meta_fields) + 10
for key, field in self.meta_fields.items(): for key, field in self.meta_fields.items():
label = QLabel(key.capitalize()) row_layout = QHBoxLayout()
right_column_layout.addWidget(label) row_layout.setContentsMargins(0, 0, 0, 0)
right_column_layout.addWidget(field) row_layout.setSpacing(0)
label = QLabel(key.capitalize() + ":")
label.setFixedWidth(label_width)
row_layout.addWidget(label)
row_layout.addWidget(field)
right_column_layout.addLayout(row_layout)
field.textChanged.connect(self.sync_bubble_data) field.textChanged.connect(self.sync_bubble_data)
label_desc = QLabel('<a href="#">Why are these useful?</a>') label_desc = QLabel('<a href="#">Why are these useful?</a>')
label_desc.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) label_desc.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
label_desc.linkActivated.connect(lambda: QMessageBox.information(None, f"Info - {APP_NAME.upper()} ", "Age: Used in determing the participants PPF.\nGender: Not currently used or implemented.\nGroup: Used to split participants into groups for comparisons between them.")) label_desc.linkActivated.connect(lambda: QMessageBox.information(None, f"Info - {APP_NAME.upper()} ", "Age: Used in determing the participants PPF. Also used to assist in creating groups.\nGender: Used to assist in creating groups.\nHand: Used to assist in creating groups.\nGroup: Used to split participants into groups for comparisons between them."))
right_column_layout.addWidget(label_desc) right_column_layout.addWidget(label_desc)
right_column_layout.addStretch() right_column_layout.addStretch()
self.right_column_widget.hide() self.right_column_widget.hide()
@@ -1336,11 +1377,12 @@ class MainApplication(QMainWindow):
if self.regroup_metadata is None or not self.regroup_metadata.isVisible(): if self.regroup_metadata is None or not self.regroup_metadata.isVisible():
file_meta = getattr(self, "file_metadata", {}) file_meta = getattr(self, "file_metadata", {})
if any(bool(meta) for meta in file_meta.values()): if any(bool(meta) for meta in file_meta.values()):
mappings = GroupAssignmentDialog.run_for_field( result = GroupAssignmentDialog.run(
self, file_meta, field_name="AGE" self, file_meta, field_names=list(BIDS_FIELD_MAP.values())
) )
if mappings: if result:
self._apply_group_mappings(mappings, field_name="AGE") field_name, mappings = result
self._apply_group_mappings(mappings, field_name=field_name)
else: else:
QMessageBox.information( QMessageBox.information(
None, None,
@@ -1784,12 +1826,13 @@ class MainApplication(QMainWindow):
old_cfg = self.config_dict[abs_path] old_cfg = self.config_dict[abs_path]
self.file_metadata[abs_path] = { self.file_metadata[abs_path] = {
"AGE": str(old_cfg.get("AGE", "")), "AGE": str(old_cfg.get("AGE", "")),
"GENDER": str(old_cfg.get("GENDER", "")), "SEX": str(old_cfg.get("SEX", "")),
"HAND": str(old_cfg.get("HAND", "")),
"GROUP": str(old_cfg.get("GROUP", "")) "GROUP": str(old_cfg.get("GROUP", ""))
} }
else: else:
# Scenario C: Empty default # Scenario C: Empty default
self.file_metadata[abs_path] = {"AGE": "", "GENDER": "", "GROUP": ""} self.file_metadata[abs_path] = {"AGE": "", "SEX": "", "HAND": "", "GROUP": ""}
self.show_files_as_bubbles_from_list(file_list, progress_states, filename) self.show_files_as_bubbles_from_list(file_list, progress_states, filename)
@@ -1968,7 +2011,7 @@ class MainApplication(QMainWindow):
meta = self.file_metadata[file_path] meta = self.file_metadata[file_path]
parts = [] parts = []
for key in ["AGE", "GENDER", "GROUP"]: for key in ["AGE", "SEX", "HAND", "GROUP"]:
value = meta.get(key, "").strip() value = meta.get(key, "").strip()
if value: if value:
parts.append(f"{key}: {value}") parts.append(f"{key}: {value}")
@@ -2027,7 +2070,7 @@ class MainApplication(QMainWindow):
self.last_clicked_bubble = bubble self.last_clicked_bubble = bubble
# show age / gender / group # show age / sex / hand / group
self.right_column_widget.show() self.right_column_widget.show()
file_path = bubble.file_path file_path = bubble.file_path
@@ -2609,14 +2652,12 @@ class MainApplication(QMainWindow):
# If it's a successful extraction, it won't have 'status' set yet # If it's a successful extraction, it won't have 'status' set yet
elif 'status' not in result: elif 'status' not in result:
# Wrap the raw extraction dictionary into our unified UI format
extracted_age = result.get('age', '')
# Save AGE directly into file_metadata tracking
if file_path not in self.file_metadata: if file_path not in self.file_metadata:
self.file_metadata[file_path] = {} self.file_metadata[file_path] = {}
if extracted_age: for display_field, internal_key in BIDS_FIELD_MAP.items():
self.file_metadata[file_path]['AGE'] = extracted_age val = result.get(display_field, '')
if val:
self.file_metadata[file_path][internal_key] = val
result = {'status': 'success', 'data': result} result = {'status': 'success', 'data': result}
@@ -2646,16 +2687,17 @@ class MainApplication(QMainWindow):
norm_path = os.path.normpath(file_path) norm_path = os.path.normpath(file_path)
self.metadata_cache[norm_path] = data self.metadata_cache[norm_path] = data
# 3. Store extracted BIDS age into file_metadata store # 3. Store extracted BIDS data into file_metadata store
if not hasattr(self, 'file_metadata'): if not hasattr(self, 'file_metadata'):
self.file_metadata = {} self.file_metadata = {}
if norm_path not in self.file_metadata: if norm_path not in self.file_metadata:
self.file_metadata[norm_path] = {} self.file_metadata[norm_path] = {}
# 1. Store extracted BIDS metadata into file_metadata map # 1. Store extracted BIDS metadata into file_metadata map
extracted_age = data.get('age', '') for display_field, internal_key in BIDS_FIELD_MAP.items():
if extracted_age: val = data.get(display_field, '')
self.file_metadata[norm_path]['AGE'] = str(extracted_age) if val:
self.file_metadata[norm_path][internal_key] = str(val)
# 4. Update ONLY the text suffix on the bubble (spinner stays active!) # 4. Update ONLY the text suffix on the bubble (spinner stays active!)
bubble = self.bubble_widgets.get(norm_path) or self.bubble_widgets.get(file_path) bubble = self.bubble_widgets.get(norm_path) or self.bubble_widgets.get(file_path)
@@ -2687,7 +2729,7 @@ class MainApplication(QMainWindow):
norm_path, self.file_metadata.get(file_path, {}) norm_path, self.file_metadata.get(file_path, {})
) )
# Loop through all dynamically created fields (AGE, GENDER, GROUP, etc.) # Loop through all dynamically created fields (AGE, SEX, HAND, GROUP, etc.)
for key, line_edit in self.meta_fields.items(): for key, line_edit in self.meta_fields.items():
val = str(meta.get(key, "")).strip() val = str(meta.get(key, "")).strip()
@@ -2747,14 +2789,13 @@ class MainApplication(QMainWindow):
) )
if reply == QMessageBox.StandardButton.Yes: if reply == QMessageBox.StandardButton.Yes:
mappings = GroupAssignmentDialog.run_for_field( result = GroupAssignmentDialog.run(self, file_meta, field_names=list(BIDS_FIELD_MAP.values()))
self, file_meta, field_name="AGE" if result:
) field_name, mappings = result
if mappings: self._apply_group_mappings(mappings, field_name=field_name)
self._apply_group_mappings(mappings, field_name="AGE")
def _apply_group_mappings(self, mappings: dict, field_name: str = "AGE"): def _apply_group_mappings(self, mappings: dict, field_name: str = ""):
"""Applies group mappings to metadata and updates all UI widgets.""" """Applies group mappings to metadata and updates all UI widgets."""
# C. Update 'GROUP' in self.file_metadata for matching files # C. Update 'GROUP' in self.file_metadata for matching files
for path_key, meta in self.file_metadata.items(): for path_key, meta in self.file_metadata.items():
@@ -2784,16 +2825,34 @@ class MainApplication(QMainWindow):
print("[System] Background worker dismissed. RAM reclaimed.") print("[System] Background worker dismissed. RAM reclaimed.")
def _get_bids_age(snirf_path: str) -> str: def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
"""Traverses the path of a SNIRF file to extract age from BIDS TSV files.""" """Traverses the path of a SNIRF file to extract age/sex/hand from BIDS TSV files.
'hand' is only included if a value is present and isn't 'n/a' (case-insensitive) -
many datasets leave it unset/inapplicable, so surfacing 'n/a' explicitly just adds noise.
"""
path = Path(snirf_path) path = Path(snirf_path)
fields = ["age", "sex", "hand"]
# Extract sub-XX and ses-YY labels from the path # Extract sub-XX and ses-YY labels from the path
sub_id = next((part for part in path.parts if part.startswith("sub-")), None) sub_id = next((part for part in path.parts if part.startswith("sub-")), None)
ses_id = next((part for part in path.parts if part.startswith("ses-")), None) ses_id = next((part for part in path.parts if part.startswith("ses-")), None)
if not sub_id: if not sub_id:
return "" return {}
def _row_to_dict(row) -> dict[str, str]:
result = {}
for field in fields:
if field not in row:
continue
val = row[field]
if pd.isna(val):
continue
val_str = str(val).strip()
if field == "hand" and val_str.lower() in ("n/a", "na", ""):
continue
result[field] = val_str
return result
# 1. Look for sub-<id>/sub-<id>_sessions.tsv # 1. Look for sub-<id>/sub-<id>_sessions.tsv
sub_dir = next((p for p in path.parents if p.name == sub_id), None) sub_dir = next((p for p in path.parents if p.name == sub_id), None)
@@ -2802,12 +2861,11 @@ def _get_bids_age(snirf_path: str) -> str:
if sessions_tsv.exists(): if sessions_tsv.exists():
try: try:
df = pd.read_csv(sessions_tsv, sep="\t") df = pd.read_csv(sessions_tsv, sep="\t")
# Handle session_id matching whether it includes 'ses-' or not
matching = df[df["session_id"].astype(str).str.replace("ses-", "") == ses_id.replace("ses-", "")] matching = df[df["session_id"].astype(str).str.replace("ses-", "") == ses_id.replace("ses-", "")]
if not matching.empty and "age" in matching.columns: if not matching.empty:
val = matching.iloc[0]["age"] result = _row_to_dict(matching.iloc[0])
if pd.notna(val): if result:
return str(val) return result
except Exception: except Exception:
pass pass
@@ -2819,14 +2877,14 @@ def _get_bids_age(snirf_path: str) -> str:
try: try:
df = pd.read_csv(participants_tsv, sep="\t") df = pd.read_csv(participants_tsv, sep="\t")
matching = df[df["participant_id"].astype(str).str.replace("sub-", "") == sub_id.replace("sub-", "")] matching = df[df["participant_id"].astype(str).str.replace("sub-", "") == sub_id.replace("sub-", "")]
if not matching.empty and "age" in matching.columns: if not matching.empty:
val = matching.iloc[0]["age"] result = _row_to_dict(matching.iloc[0])
if pd.notna(val): if result:
return str(val) return result
except Exception: except Exception:
pass pass
return "" return {}
def _extract_metadata_worker(file_name): def _extract_metadata_worker(file_name):
@@ -2847,7 +2905,9 @@ def _extract_metadata_worker(file_name):
short_chans = get_short_channels(raw, max_dist=0.015) short_chans = get_short_channels(raw, max_dist=0.015)
names = list(short_chans.ch_names) names = list(short_chans.ch_names)
snirf_info['Short Channels'] = f"Likely - {names}" snirf_info['Short Channels'] = f"Likely - {names}"
if len(names) > 6: total_chans = len(raw.ch_names)
pct_short = (len(names) / total_chans * 100) if total_chans else 0
if pct_short > 25:
snirf_info['Short Channels'] += "\n There are a lot of short channels. Optode distances are likely incorrect!" snirf_info['Short Channels'] += "\n There are a lot of short channels. Optode distances are likely incorrect!"
except: except:
snirf_info['Short Channels'] = "Unlikely" snirf_info['Short Channels'] = "Unlikely"
@@ -2876,7 +2936,13 @@ def _extract_metadata_worker(file_name):
else: else:
snirf_info['Annotations'] = "No annotations found" snirf_info['Annotations'] = "No annotations found"
snirf_info["age"] = _get_bids_age(file_name) demographics = _get_bids_demographics(file_name)
if "age" in demographics:
snirf_info["BIDS - Age"] = demographics["age"]
if "sex" in demographics:
snirf_info["BIDS - Sex"] = demographics["sex"]
if "hand" in demographics:
snirf_info["BIDS - Handedness"] = demographics["hand"]
return snirf_info return snirf_info
except Exception as e: except Exception as e: