new preferences and bug fixes

This commit is contained in:
2026-07-30 14:18:26 -07:00
parent f15a5d9433
commit ebd13927e2
6 changed files with 325 additions and 247 deletions
+9
View File
@@ -4,18 +4,27 @@
- Currently only the AGE metadata is grabbed and is auto populated into the participants AGE field and displayed on their bubble - Currently only the AGE metadata is grabbed and is auto populated into the participants AGE field and displayed on their bubble
- If the participants have metadata, a popup will be displayed asking if the user wants to group the participants by their metadata - If the participants have metadata, a popup will be displayed asking if the user wants to group the participants by their metadata
- If the user wants to create groups, a dialog box is presented allowing them to do so. Once completed, the GROUP value will be automatically applied to all applicable participants - If the user wants to create groups, a dialog box is presented allowing them to do so. Once completed, the GROUP value will be automatically applied to all applicable participants
- A new options menu item has been added: Regroup Files from Metadata. This will allow the dialog to be opened at a later time with a popup appearing if there is no metadata present
- Added two new options to the Export to CSV Viewer: Export Configuration to CSV and Paragraph of Configuration - Added two new options to the Export to CSV Viewer: Export Configuration to CSV and Paragraph of Configuration
- Export Configuration to CSV will export the parameters utilized for each file into a CSV formatted file to provide additional validation of what parameters were used - Export Configuration to CSV will export the parameters utilized for each file into a CSV formatted file to provide additional validation of what parameters were used
- Paragraph of Configuration will generate a ready-to-go paragraph explaining in a more friendly and easy to follow manner of what parameters were used - Paragraph of Configuration will generate a ready-to-go paragraph explaining in a more friendly and easy to follow manner of what parameters were used
- Chunked the loading of folders to provide more feedback to the user instead of hanging the application waiting for all files to load - Chunked the loading of folders to provide more feedback to the user instead of hanging the application waiting for all files to load
- New parameters have been added to the right side of the screen! This allows for more flexibility and customizability when processing
- A new Preference Menu option has been added: Show Advanced Parameters. This keeps some of the parameters hidden when not checked. Since this is a preference, it will be saved when reopening the application
- Advanced parameters should only be changed if you know what you are doing, and will have a yellow warning symbol next to them to avoid potential confusion on what parameters are advanced
- Fixed an issue with the build script not properly updating the version string causing the application to falsely think that an update was always available - Fixed an issue with the build script not properly updating the version string causing the application to falsely think that an update was always available
- Fixed an issue where parameters that were dependent on SHORT_CHANNELS were not properly being updated - Fixed an issue where parameters that were dependent on SHORT_CHANNELS were not properly being updated
- Fixed an issue where files that failed processing were not having their progress bar turn red at the step that failed - Fixed an issue where files that failed processing were not having their progress bar turn red at the step that failed
- Fixed an issue where progress bars were not updating when MAX_WORKERS was set to a value higher than default - Fixed an issue where progress bars were not updating when MAX_WORKERS was set to a value higher than default
- Fixed an issue where a high value of MAX_WORKERS was never hit the targeted value due to not being able to spawn the workers fast enough - Fixed an issue where a high value of MAX_WORKERS was never hit the targeted value due to not being able to spawn the workers fast enough
- Fixed an issue causing numerous 'Invalid File' popups to appear upon closing the application while files were still having their initial metadata being grabbed - Fixed an issue causing numerous 'Invalid File' popups to appear upon closing the application while files were still having their initial metadata being grabbed
- Fixed an issue where both 'Update events in snirf file...' windows could not be open at the same time
- Fixed an issue where externally updating a participant's metadata when they were selected would cause the application to crash
- Fixed an issue where there was a redundant checking of dependencies when the parameters on the right side of the screen were being calculated
- Fixed an issue in which clicking menu options too fast would cause the application to hard crash
- Added a time elapsed message to the status bar denoting how long processing has been going on for. When processing is complete, it will change to be the time elapsed - Added a time elapsed message to the status bar denoting how long processing has been going on for. When processing is complete, it will change to be the time elapsed
- Added a speedup multiplier to show how much time was saved by having MAX_WORKERS not set to 1. This multiplier is calculated using a naive approach and may not be accurate - Added a speedup multiplier to show how much time was saved by having MAX_WORKERS not set to 1. This multiplier is calculated using a naive approach and may not be accurate
- Removed redundant font styling when parameters were changed from their default values
# Version 1.5.1 # Version 1.5.1
+9
View File
@@ -4,18 +4,27 @@
- Currently only the AGE metadata is grabbed and is auto populated into the participants AGE field and displayed on their bubble - Currently only the AGE metadata is grabbed and is auto populated into the participants AGE field and displayed on their bubble
- If the participants have metadata, a popup will be displayed asking if the user wants to group the participants by their metadata - If the participants have metadata, a popup will be displayed asking if the user wants to group the participants by their metadata
- If the user wants to create groups, a dialog box is presented allowing them to do so. Once completed, the GROUP value will be automatically applied to all applicable participants - If the user wants to create groups, a dialog box is presented allowing them to do so. Once completed, the GROUP value will be automatically applied to all applicable participants
- A new options menu item has been added: Regroup Files from Metadata. This will allow the dialog to be opened at a later time with a popup appearing if there is no metadata present
- Added two new options to the Export to CSV Viewer: Export Configuration to CSV and Paragraph of Configuration - Added two new options to the Export to CSV Viewer: Export Configuration to CSV and Paragraph of Configuration
- Export Configuration to CSV will export the parameters utilized for each file into a CSV formatted file to provide additional validation of what parameters were used - Export Configuration to CSV will export the parameters utilized for each file into a CSV formatted file to provide additional validation of what parameters were used
- Paragraph of Configuration will generate a ready-to-go paragraph explaining in a more friendly and easy to follow manner of what parameters were used - Paragraph of Configuration will generate a ready-to-go paragraph explaining in a more friendly and easy to follow manner of what parameters were used
- Chunked the loading of folders to provide more feedback to the user instead of hanging the application waiting for all files to load - Chunked the loading of folders to provide more feedback to the user instead of hanging the application waiting for all files to load
- New parameters have been added to the right side of the screen! This allows for more flexibility and customizability when processing
- A new Preference Menu option has been added: Show Advanced Parameters. This keeps some of the parameters hidden when not checked. Since this is a preference, it will be saved when reopening the application
- Advanced parameters should only be changed if you know what you are doing, and will have a yellow warning symbol next to them to avoid potential confusion on what parameters are advanced
- Fixed an issue with the build script not properly updating the version string causing the application to falsely think that an update was always available - Fixed an issue with the build script not properly updating the version string causing the application to falsely think that an update was always available
- Fixed an issue where parameters that were dependent on SHORT_CHANNELS were not properly being updated - Fixed an issue where parameters that were dependent on SHORT_CHANNELS were not properly being updated
- Fixed an issue where files that failed processing were not having their progress bar turn red at the step that failed - Fixed an issue where files that failed processing were not having their progress bar turn red at the step that failed
- Fixed an issue where progress bars were not updating when MAX_WORKERS was set to a value higher than default - Fixed an issue where progress bars were not updating when MAX_WORKERS was set to a value higher than default
- Fixed an issue where a high value of MAX_WORKERS was never hit the targeted value due to not being able to spawn the workers fast enough - Fixed an issue where a high value of MAX_WORKERS was never hit the targeted value due to not being able to spawn the workers fast enough
- Fixed an issue causing numerous 'Invalid File' popups to appear upon closing the application while files were still having their initial metadata being grabbed - Fixed an issue causing numerous 'Invalid File' popups to appear upon closing the application while files were still having their initial metadata being grabbed
- Fixed an issue where both 'Update events in snirf file...' windows could not be open at the same time
- Fixed an issue where externally updating a participant's metadata when they were selected would cause the application to crash
- Fixed an issue where there was a redundant checking of dependencies when the parameters on the right side of the screen were being calculated
- Fixed an issue in which clicking menu options too fast would cause the application to hard crash
- Added a time elapsed message to the status bar denoting how long processing has been going on for. When processing is complete, it will change to be the time elapsed - Added a time elapsed message to the status bar denoting how long processing has been going on for. When processing is complete, it will change to be the time elapsed
- Added a speedup multiplier to show how much time was saved by having MAX_WORKERS not set to 1. This multiplier is calculated using a naive approach and may not be accurate - Added a speedup multiplier to show how much time was saved by having MAX_WORKERS not set to 1. This multiplier is calculated using a naive approach and may not be accurate
- Removed redundant font styling when parameters were changed from their default values
# Version 1.5.1 # Version 1.5.1
+15 -84
View File
@@ -182,6 +182,10 @@ IQR: float
WAVELET_TYPE: str WAVELET_TYPE: str
WAVELET_LEVEL: int WAVELET_LEVEL: int
OVERRIDE_PPF: bool
PPF_LOWER_WAVELENGTH: float
PPF_UPPER_WAVELENGTH: float
ENHANCE_NEGATIVE_CORRELATION: bool ENHANCE_NEGATIVE_CORRELATION: bool
FILTER: bool FILTER: bool
@@ -214,12 +218,10 @@ N_JOBS: int
JSON_LOCATION: str JSON_LOCATION: str
TIME_WINDOW_START: int
TIME_WINDOW_END: int
MAX_WORKERS: int MAX_WORKERS: int
VERBOSITY: bool VERBOSITY: bool
AGE: int = 25 # Assume 25 if not set from the GUI. This will result in a reasonable PPF AGE: int = 25 # Assume 25 if not set from the GUI. This will result in a reasonable PPF if calculated dynamically
GENDER: str = "" GENDER: str = ""
GROUP: str = "Default" GROUP: str = "Default"
@@ -257,8 +259,6 @@ REQUIRED_KEYS: dict[str, Any] = {
"REMOVE_EVENTS": list, "REMOVE_EVENTS": list,
"TIME_WINDOW_START": int,
"TIME_WINDOW_END": int,
"L_FREQ": float, "L_FREQ": float,
"H_FREQ": float, "H_FREQ": float,
@@ -870,71 +870,6 @@ def calculate_scalp_coupling(data, l_freq: float = 0.7, h_freq: float = 1.5):
def calculate_signal_noise_ratio(data):
"""
Calculates the signal-to-noise ratio (SNR) for each channel and identifies those below a defined threshold.
Parameters
----------
data : BaseRaw
The loaded data object to process.
Returns
-------
tuple[list[str], Figure]
- list[str]: A list of channel names that fall below the SNR threshold and are considered bad.
- Figure: A matplotlib Figure showing the channels' SNR values.
"""
print("Calculating signal to noise ratio...")
# Compute the signal-to-noise ratio values
print("Computing the signal to noise power...")
signal_band=(0.01, 0.5)
noise_band=(1.0, 10.0)
data_signal = data.copy().filter(*signal_band, verbose=False) #type: ignore
data_noise = data.copy().filter(*noise_band, verbose=False) #type: ignore
signal_power = np.mean(data_signal.get_data()**2, axis=1) #type: ignore
noise_power = np.mean(data_noise.get_data()**2, axis=1) #type: ignore
# Calculate the snr using the standard formula for dB
snr = 10 * np.log10(signal_power / (noise_power + np.finfo(float).eps))
# TODO: Understand what this does
groups: dict[str, list[str]] = {}
for ch in getattr(data, "ch_names"):
# Look for the space in the channel names and remove the characters after
# This is so we can get both oxy and deoxy to remove, as they will have the same source and detector
base = ch.rsplit(' ', 1)[0]
groups.setdefault(base, []).append(ch) # type: ignore
# If any of the channels do not meet our threshold, they will get inserted into the bad_channels set
bad_channels: set[str] = set()
for base, ch_list in groups.items():
if any(s < SNR_THRESHOLD for s, ch in zip(snr, getattr(data, "ch_names")) if ch in ch_list):
bad_channels.update(ch_list)
# Design and create the figure
print("Creating the figure...")
snr_fig, ax = plt.subplots(figsize=(12, 4), layout="constrained") # type: ignore
colors = [(0/20, 'red'), (SNR_THRESHOLD/20, 'red'), ((SNR_THRESHOLD+.5)/20, 'yellow'), ((SNR_THRESHOLD+1)/20, 'green'), (20/20, 'green')]
cmap = LinearSegmentedColormap.from_list('custom_snr_cmap', colors)
norm = mcolors.Normalize(vmin=0, vmax=20)
scatter = ax.scatter(range(len(snr)), snr, c=snr, cmap=cmap, alpha=0.8, s=100, norm=norm) # type: ignore
ax.set(xlabel="Channel Number", ylabel="Signal-to-Noise Ratio (dB)", xlim=[0, len(snr)], ylim=[0, 20])
ax.axhline(SNR_THRESHOLD, color='black', linestyle='--', alpha=0.3, linewidth=1) # type: ignore
cbar = snr_fig.colorbar(scatter, ax=ax, label="SNR Thresholds (dB)") # type: ignore
cbar.set_ticks([0, SNR_THRESHOLD, SNR_THRESHOLD+1, 20]) # type: ignore
cbar.set_ticklabels(['0', str(SNR_THRESHOLD), str(SNR_THRESHOLD+1), '20']) # type: ignore
plt.close()
print("Successfully calculated signal to noise ratio.")
return list(bad_channels), snr_fig
def build_fnirs_adjacency(raw, threshold_meters=0.03): def build_fnirs_adjacency(raw, threshold_meters=0.03):
"""Build an adjacency dictionary for fNIRS channels using 3D distance.""" """Build an adjacency dictionary for fNIRS channels using 3D distance."""
# Extract channel positions # Extract channel positions
@@ -2468,16 +2403,6 @@ def brain_landmarks_3d(raw_haemo: BaseRaw, show_optodes: Literal['sensors', 'lab
return brain return brain
def verify_channel_positions(data: BaseRaw) -> None:
"""
Visualizes the sensor/channel positions of the raw data for verification.
Parameters
----------
data : BaseRaw
The loaded data object to process.
"""
def convert_fig_dict_to_png_bytes(fig_dict: dict[str, Figure]) -> dict[str, bytes]: def convert_fig_dict_to_png_bytes(fig_dict: dict[str, Figure]) -> dict[str, bytes]:
png_dict = {} png_dict = {}
for label, fig in fig_dict.items(): for label, fig in fig_dict.items():
@@ -5515,6 +5440,14 @@ def generate_contrast_results(df_design_matrix, glm_est, file_path):
return contrast_results_dict return contrast_results_dict
def haemoglobin_concentration(raw_od, file_path, override_ppf=False, ppf_lower_wavelength=6.0, ppf_upper_wavelength=6.0):
if override_ppf:
raw_haemo = beer_lambert_law(raw_od, ppf=(ppf_lower_wavelength, ppf_upper_wavelength))
else:
raw_haemo = beer_lambert_law(raw_od, ppf=calculate_dpf(file_path))
return raw_haemo
def process_participant(file_path, progress_callback=None): def process_participant(file_path, progress_callback=None):
# Step 0: Setting up # Step 0: Setting up
@@ -5525,8 +5458,6 @@ def process_participant(file_path, progress_callback=None):
if k in globals() and k != "REQUIRED_KEYS" if k in globals() and k != "REQUIRED_KEYS"
} }
print(config_dict)
# Step 1: Preprocessing # Step 1: Preprocessing
raw = load_snirf(file_path) raw = load_snirf(file_path)
fig_raw = raw.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="Loaded Raw", show=False) fig_raw = raw.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="Loaded Raw", show=False)
@@ -5667,7 +5598,7 @@ def process_participant(file_path, progress_callback=None):
logger.info("Step 16 Completed.") logger.info("Step 16 Completed.")
# Step 17: Haemoglobin Concentration # Step 17: Haemoglobin Concentration
raw_haemo = beer_lambert_law(raw_od, ppf=calculate_dpf(file_path)) raw_haemo = haemoglobin_concentration(raw_od, file_path, OVERRIDE_PPF, PPF_LOWER_WAVELENGTH, PPF_UPPER_WAVELENGTH)
fig_raw_haemo_bll = raw_haemo.plot(duration=raw_haemo.times[-1], n_channels=raw_haemo.info['nchan'], title="HbO and HbR Signals", show=False) fig_raw_haemo_bll = raw_haemo.plot(duration=raw_haemo.times[-1], n_channels=raw_haemo.info['nchan'], title="HbO and HbR Signals", show=False)
fig_individual["Modified Beer Lambert Law"] = fig_raw_haemo_bll fig_individual["Modified Beer Lambert Law"] = fig_raw_haemo_bll
if progress_callback: progress_callback(17) if progress_callback: progress_callback(17)
@@ -5711,7 +5642,7 @@ def process_participant(file_path, progress_callback=None):
if progress_callback: progress_callback(22) if progress_callback: progress_callback(22)
logger.info("Step 22 Completed.") logger.info("Step 22 Completed.")
# Step 23: Run GLM # Step 23: General Linear Model
glm_est, fig_glm_topo = make_and_run_glm(raw_haemo, df_design_matrix) glm_est, fig_glm_topo = make_and_run_glm(raw_haemo, df_design_matrix)
fig_individual["GLM Topography"] = fig_glm_topo fig_individual["GLM Topography"] = fig_glm_topo
if progress_callback: progress_callback(23) if progress_callback: progress_callback(23)
+229 -134
View File
@@ -72,6 +72,7 @@ incompatible_save_bypass = false
missing_events_bypass = false missing_events_bypass = false
analysis_clearing_bypass = false analysis_clearing_bypass = false
folding_bypass = false folding_bypass = false
advanced_parameters = false
[Terminal] [Terminal]
@@ -84,102 +85,119 @@ SECTIONS = [
{ {
"title": "Preprocessing", "title": "Preprocessing",
"params": [ "params": [
{"name": "DOWNSAMPLE", "default": True, "type": bool, "help": "Should the snirf files be downsampled? If this is set to True, DOWNSAMPLE_FREQUENCY will be used as the target frequency to downsample to."}, {"name": "DOWNSAMPLE", "default": True, "type": bool, "advanced": False, "help": "Should the snirf files be downsampled? If this is set to True, DOWNSAMPLE_FREQUENCY will be used as the target frequency to downsample to."},
{"name": "DOWNSAMPLE_FREQUENCY", "default": 25, "type": int, "depends_on": "DOWNSAMPLE", "help": "Frequency (Hz) to downsample to. If this is set higher than the input data, new data will be interpolated."}, {"name": "DOWNSAMPLE_FREQUENCY", "default": 25, "type": int, "depends_on": "DOWNSAMPLE", "advanced": False, "help": "Frequency (Hz) to downsample to. If this is set higher than the input data, new data will be interpolated."},
] ]
}, },
{ {
"title": "Trimming", "title": "Trimming",
"params": [ "params": [
{"name": "TRIM", "default": True, "type": bool, "help": "Should the start of the files be trimmed?"}, {"name": "TRIM", "default": True, "type": bool, "advanced": False, "help": "Should the start of the files be trimmed?"},
{"name": "SECONDS_TO_KEEP", "default": 5.0, "type": float, "depends_on": "TRIM", "help": "Seconds to keep at the beginning of all loaded snirf files before the first annotation/event occurs. Calculation is done seperatly on all loaded snirf files. Setting this to 0 will have the first annotation/event be at time point 0. Only used if TRIM is set to True."}, {"name": "SECONDS_TO_KEEP", "default": 5.0, "type": float, "advanced": False, "depends_on": "TRIM", "help": "Seconds to keep at the beginning of all loaded snirf files before the first annotation/event occurs. Calculation is done seperatly on all loaded snirf files. Setting this to 0 will have the first annotation/event be at time point 0. Only used if TRIM is set to True."},
] ]
}, },
{ {
"title": "Verify Optode Placement", "title": "Verify Optode Placement",
"params": [ "params": [
{"name": "OPTODE_PLACEMENT", "default": True, "type": bool, "help": "Should an image be generated for each participant outlining their optode placement on a head?"}, {"name": "OPTODE_PLACEMENT", "default": True, "type": bool, "advanced": False, "help": "Should an image be generated for each participant outlining their optode placement on a head?"},
{"name": "SHOW_OPTODE_NAMES", "default": True, "type": bool, "depends_on": "OPTODE_PLACEMENT", "help": "Should the optode names be written next to their location in the image?"}, {"name": "SHOW_OPTODE_NAMES", "default": True, "type": bool, "advanced": False, "depends_on": "OPTODE_PLACEMENT", "help": "Should the optode names be written next to their location in the image?"},
] ]
}, },
{ {
"title": "Short/Long Channels", "title": "Short/Long Channels",
"params": [ "params": [
{"name": "SHORT_CHANNELS", "default": True, "type": bool, "help": "This should be set to True if the data has a short channel present in the data. For more information about short channels, please visit the Wiki."}, {"name": "SHORT_CHANNELS", "default": True, "type": bool, "advanced": False, "help": "This should be set to True if the data has a short channel present in the data. For more information about short channels, please visit the Wiki."},
{"name": "SHORT_CHANNELS_THRESHOLD", "default": 0.015, "type": float, "depends_on": "SHORT_CHANNELS", "help": "The maximum distance the short channel can be in metres before it is no longer considered a short channel."}, {"name": "LONG_CHANNELS", "default": True, "type": bool, "advanced": True, "help": "Should channels exceeding the maximum allowed distance be removed?"},
{"name": "LONG_CHANNELS_THRESHOLD", "default": 0.045, "type": float, "help": "The maximum distance channels can be in metres. Any channel longer than this distance will be discarded."}, {"name": "SHORT_CHANNELS_THRESHOLD", "default": 0.015, "type": float, "depends_on": "SHORT_CHANNELS", "advanced": False, "help": "The maximum distance the short channel can be in metres before it is no longer considered a short channel."},
{"name": "LONG_CHANNELS_THRESHOLD", "default": 0.045, "type": float, "advanced": False, "help": "The maximum distance channels can be in metres. Any channel longer than this distance will be discarded."},
] ]
}, },
{ {
"title": "Heart Rate", "title": "Heart Rate",
"params": [ "params": [
{"name": "HEART_RATE", "default": True, "type": bool, "help": "Should an attempt be made to calculate the participants heart rate?"}, {"name": "HEART_RATE", "default": True, "type": bool, "advanced": False, "help": "Should an attempt be made to calculate the participants heart rate?"},
{"name": "SECONDS_TO_STRIP_HR", "default": 5, "type": int, "depends_on": "HEART_RATE", "help": "Will remove this many seconds from the start and end of the file. Useful if recording before cap is firmly placed, or participant removes cap while still recording."}, {"name": "SECONDS_TO_STRIP_HR", "default": 5, "type": int, "depends_on": "HEART_RATE", "advanced": False, "help": "Will remove this many seconds from the start and end of the file. Useful if recording before cap is firmly placed, or participant removes cap while still recording."},
{"name": "MAX_LOW_HR", "default": 40, "type": int, "depends_on": "HEART_RATE", "help": "Any heart rate windows that average below this value will be rounded up to this value."}, {"name": "HR_LOW_FREQ", "default": 0.8, "type": float, "depends_on": "HEART_RATE", "advanced": True, "help": "Lower frequency bound for heart rate detection (Hz). Used to isolate cardiac frequencies before peak detection."},
{"name": "MAX_HIGH_HR", "default": 200, "type": int, "depends_on": "HEART_RATE", "help": "Any heart rate windows that average above this value will be rounded down to this value."}, {"name": "HR_HIGH_FREQ", "default": 2.5, "type": float, "depends_on": "HEART_RATE", "advanced": True, "help": "Upper frequency bound for heart rate detection (Hz). Used to isolate cardiac frequencies before peak detection."},
{"name": "SMOOTHING_WINDOW_HR", "default": 100, "type": int, "depends_on": "HEART_RATE", "help": "How many individual data points to be used to create a single data point/window."}, {"name": "HR_SEARCH_MIN", "default": 30, "type": int, "depends_on": "HEART_RATE", "advanced": True, "help": "Minimum heart rate considered during spectral analysis (BPM)."},
{"name": "HEART_RATE_WINDOW", "default": 25, "type": int, "depends_on": "HEART_RATE", "help": "Only used for visualization. Shows the 'range' of the calculated heart rate, which is just the average +- this value."}, {"name": "HR_SEARCH_MAX", "default": 200, "type": int, "depends_on": "HEART_RATE", "advanced": True, "help": "Maximum heart rate considered during spectral analysis (BPM)."},
{"name": "MAX_LOW_HR", "default": 40, "type": int, "depends_on": "HEART_RATE", "advanced": False, "help": "Any heart rate windows that average below this value will be rounded up to this value."},
{"name": "MAX_HIGH_HR", "default": 200, "type": int, "depends_on": "HEART_RATE", "advanced": False, "help": "Any heart rate windows that average above this value will be rounded down to this value."},
{"name": "SMOOTHING_WINDOW_HR", "default": 100, "type": int, "depends_on": "HEART_RATE", "advanced": True, "help": "Number of individual heart rate samples used to create each smoothed value."},
{"name": "HEART_RATE_WINDOW", "default": 25, "type": int, "depends_on": "HEART_RATE", "advanced": False, "help": "Visualization window around the estimated heart rate (BPM)."},
] ]
}, },
{ {
"title": "Scalp Coupling Index", "title": "Scalp Coupling Index",
"params": [ "params": [
{"name": "SCI", "default": True, "type": bool, "help": "Calculate and mark channels bad based on their Scalp Coupling Index. This metric calculates the quality of the connection between the optode and the scalp."}, {"name": "SCI", "default": True, "type": bool, "advanced": False, "help": "Calculate and mark channels bad based on their Scalp Coupling Index. This metric calculates the quality of the connection between the optode and the scalp."},
{"name": "SCI_TIME_WINDOW", "default": 3, "type": int, "depends_on": "SCI", "help": "Independent SCI calculations will be perfomed in a time window for the duration of the value provided, until the end of the file is reached."}, {"name": "SCI_USE_HEART_RATE_BAND", "default": True, "type": bool, "depends_on": "SCI", "advanced": False, "help": "Adjust the SCI frequency band using the participant's estimated heart rate."},
{"name": "SCI_THRESHOLD", "default": 0.6, "type": float, "depends_on": "SCI", "help": "SCI threshold on a scale of 0-1. A value of 0 is bad coupling while a value of 1 is perfect coupling. Any channels lower than this value will be marked as bad."}, {"name": "SCI_LOW_FREQ", "default": 0.7, "type": float, "depends_on": "SCI_USE_HEART_RATE_BAND", "depends_value": False,"advanced": True, "help": "Lower frequency cutoff for SCI bandpass filtering (Hz)."},
{"name": "SCI_HIGH_FREQ", "default": 1.5, "type": float, "depends_on": "SCI_USE_HEART_RATE_BAND", "depends_value": False, "advanced": True, "help": "Upper frequency cutoff for SCI bandpass filtering (Hz)."},
{"name": "SCI_TIME_WINDOW", "default": 3, "type": int, "depends_on": "SCI", "advanced": False, "help": "Duration of each independent SCI calculation window in seconds."},
{"name": "SCI_THRESHOLD", "default": 0.6, "type": float, "depends_on": "SCI", "advanced": False, "help": "SCI threshold on a scale of 0-1. Channels below this value are marked bad."},
] ]
}, },
{ {
"title": "Signal to Noise Ratio", "title": "Signal to Noise Ratio",
"params": [ "params": [
{"name": "SNR", "default": True, "type": bool, "help": "Calculate and mark channels bad based on their Signal to Noise Ratio. This metric calculates how much of the observed signal was noise versus how much of it was a useful signal."}, {"name": "SNR", "default": True, "type": bool, "advanced": False, "help": "Calculate and mark channels bad based on their Signal to Noise Ratio. This metric calculates how much of the observed signal was noise versus how much of it was a useful signal."},
{"name": "SNR_THRESHOLD", "default": 5.0, "type": float, "depends_on": "SNR", "help": "SNR threshold (dB). A typical scale would be 0-25, but it is possible for values to be both above and below this range. Higher values correspond to a better signal. If SNR is True, any channels lower than this value will be marked as bad."}, {"name": "SNR_THRESHOLD", "default": 5.0, "type": float, "depends_on": "SNR", "advanced": False, "help": "SNR threshold (dB). Channels below this value will be marked as bad."},
{"name": "SNR_SIGNAL_LOW_FREQ", "default": 0.01, "type": float, "depends_on": "SNR", "advanced": True, "help": "Lower frequency bound for the signal band used in SNR calculation (Hz)."},
{"name": "SNR_SIGNAL_HIGH_FREQ", "default": 0.5, "type": float, "depends_on": "SNR", "advanced": True, "help": "Upper frequency bound for the signal band used in SNR calculation (Hz)."},
{"name": "SNR_NOISE_LOW_FREQ", "default": 1.0, "type": float, "depends_on": "SNR", "advanced": True, "help": "Lower frequency bound for the noise band used in SNR calculation (Hz)."},
{"name": "SNR_NOISE_HIGH_FREQ", "default": 10.0, "type": float, "depends_on": "SNR", "advanced": True, "help": "Upper frequency bound for the noise band used in SNR calculation (Hz)."},
{"name": "SNR_PLOT_MAX", "default": 20.0, "type": float, "depends_on": "SNR", "advanced": True, "help": "Maximum SNR value displayed on the plot scale (dB)."},
] ]
}, },
{ {
"title": "Peak Spectral Power", "title": "Peak Spectral Power",
"params": [ "params": [
{"name": "PSP", "default": True, "type": bool, "help": "Calculate and mark channels bad based on their Peak Spectral Power. This metric calculates the amplitude or strength of a frequency component that is most prominent in a particular frequency range or spectrum."}, {"name": "PSP", "default": True, "type": bool, "advanced": False, "help": "Calculate and mark channels bad based on their Peak Spectral Power. This metric calculates the amplitude or strength of the most prominent frequency component in a specified spectral range."},
{"name": "PSP_TIME_WINDOW", "default": 3, "type": int, "depends_on": "PSP", "help": "Independent PSP calculations will be perfomed in a time window for the duration of the value provided, until the end of the file is reached."}, {"name": "PSP_TIME_WINDOW", "default": 3, "type": int, "depends_on": "PSP", "advanced": False, "help": "Length of each independent PSP calculation window in seconds."},
{"name": "PSP_THRESHOLD", "default": 0.1, "type": float, "depends_on": "PSP", "help": "PSP threshold. A typical scale would be 0-0.5, but it is possible for values to be above this range. Higher values correspond to a better signal. If PSP is True, any channels lower than this value will be marked as bad."}, {"name": "PSP_THRESHOLD", "default": 0.1, "type": float, "depends_on": "PSP", "advanced": False, "help": "Channels with average PSP values below this threshold will be marked as bad."},
{"name": "PSP_LOW_FREQ", "default": 0.7, "type": float, "depends_on": "PSP", "advanced": True, "help": "Lower frequency cutoff for PSP bandpass filtering (Hz)."},
{"name": "PSP_HIGH_FREQ", "default": 1.5, "type": float, "depends_on": "PSP", "advanced": True, "help": "Upper frequency cutoff for PSP bandpass filtering (Hz)."},
] ]
}, },
{ {
"title": "Coefficient of Variation", "title": "Coefficient of Variation",
"params": [ "params": [
{"name": "COEFF_VAR", "default": True, "type": bool, "help": "Identifies bad channels using the Coefficient of Variation."}, {"name": "COEFF_VAR", "default": True, "type": bool, "advanced": False, "help": "Identifies bad channels using the Coefficient of Variation."},
{"name": "COEFF_VAR_THRESHOLD", "default": 20, "type": int, "depends_on": "COEFF_VAR", "help": "Noise threshold (%)."}, {"name": "COEFF_VAR_THRESHOLD", "default": 20, "type": int, "depends_on": "COEFF_VAR", "advanced": False, "help": "Noise threshold (%)."},
] ]
}, },
{ {
"title": "Median Absolute Deviation", "title": "Median Absolute Deviation",
"params": [ "params": [
{"name": "MAD", "default": True, "type": bool, "help": "Identifies bad channels using Median Absolute Deviation."}, {"name": "MAD", "default": True, "type": bool, "advanced": False, "help": "Identifies bad channels using Median Absolute Deviation."},
{"name": "MAD_THRESHOLD", "default": 4, "type": int, "depends_on": "MAD", "help": "Amount of deviations before the channel is flagged bad."}, {"name": "MAD_THRESHOLD", "default": 4, "type": int, "depends_on": "MAD", "advanced": False, "help": "Amount of deviations before the channel is flagged bad."},
] ]
}, },
{ {
"title": "Power Spectral Density Noise", "title": "Power Spectral Density Noise",
"params": [ "params": [
{"name": "PSD_NOISE", "default": True, "type": bool, "help": "Identifies bad channels based on their excessive power at high frequencies."}, {"name": "PSD_NOISE", "default": True, "type": bool, "advanced": False, "help": "Identifies bad channels based on excessive power at high frequencies."},
{"name": "TARGET_FREQ_DIV", "default": 4, "type": int, "depends_on": "PSD_NOISE", "help": "What frequency to check for excessive power. Will take the recording frequency and divide by this number. Has to be greater than 2."}, {"name": "TARGET_FREQ_DIV", "default": 4, "type": int, "depends_on": "PSD_NOISE", "advanced": False, "help": "Target frequency is calculated by dividing the recording frequency by this value. Must be greater than 2."},
{"name": "DB_LIMIT", "default": -60, "type": int, "depends_on": "PSD_NOISE", "help": "What db level the power level needs to be below at the target frequency."}, {"name": "DB_LIMIT", "default": -60, "type": float, "depends_on": "PSD_NOISE", "advanced": False, "help": "Power threshold in dB. Channels exceeding this value near the target frequency are marked bad."},
{"name": "PSD_MIN_FREQ", "default": 0.1, "type": float, "depends_on": "PSD_NOISE", "advanced": True, "help": "Minimum frequency included when calculating the PSD (Hz)."},
{"name": "PSD_TARGET_BANDWIDTH", "default": 0.2, "type": float, "depends_on": "PSD_NOISE", "advanced": True, "help": "Frequency window around the target frequency used when averaging PSD power (Hz)."},
] ]
}, },
{ {
"title": "Sensor Dropout", "title": "Sensor Dropout",
"params": [ "params": [
{"name": "SENSOR_DROPOUT", "default": True, "type": bool, "help": "Identifies bad channels based on comparing the variance of the first 25% of the data to the last 25%."}, {"name": "SENSOR_DROPOUT", "default": True, "type": bool, "advanced": False, "help": "Identifies bad channels based on comparing the variance of the first 25% of the data to the last 25%."},
{"name": "SENSOR_DROPOUT_VARIANCE_THRESHOLD", "default": 0.05, "type": float, "depends_on": "SENSOR_DROPOUT", "help": "If the end variance is less than this % of the start variance, the channel will be marked as bad."}, {"name": "SENSOR_DROPOUT_VARIANCE_THRESHOLD", "default": 0.05, "type": float, "depends_on": "SENSOR_DROPOUT", "advanced": False, "help": "If the end variance is less than this % of the start variance, the channel will be marked as bad."},
] ]
}, },
{ {
"title": "Bad Channels Handling", "title": "Bad Channels Handling",
"params": [ "params": [
{"name": "BAD_CHANNELS_HANDLING", "default": ["Interpolate"], "type": list, "options": ["Interpolate", "Remove", "None"], "exclusive": True, "help": "How should we deal with the bad channels that occurred? Note: Some analysis options will only work when this is set to 'Interpolate'."}, {"name": "BAD_CHANNELS_HANDLING", "default": ["Interpolate"], "type": list, "options": ["Interpolate", "Remove", "None"], "exclusive": True, "advanced": False, "help": "How should we deal with the bad channels that occurred? Note: Some analysis options will only work when this is set to 'Interpolate'."},
{"name": "MAX_DIST", "default": 0.03, "type": float, "depends_on": "BAD_CHANNELS_HANDLING", "depends_value": "Interpolate", "help": "The maximum distance to look for neighbours when interpolating. Used only when BAD_CHANNELS_HANDLING is set to 'Interpolate'."}, {"name": "MAX_DIST", "default": 0.03, "type": float, "depends_on": "BAD_CHANNELS_HANDLING", "depends_value": "Interpolate", "advanced": True, "help": "The maximum distance to look for neighbours when interpolating. Used only when BAD_CHANNELS_HANDLING is set to 'Interpolate'."},
{"name": "MIN_NEIGHBORS", "default": 2, "type": int, "depends_on": "BAD_CHANNELS_HANDLING", "depends_value": "Interpolate", "help": "The minimumn amount of neighbours needed within the MAX_DIST parameter. Used only when BAD_CHANNELS_HANDLING is set to 'Interpolate'."}, {"name": "MIN_NEIGHBORS", "default": 2, "type": int, "depends_on": "BAD_CHANNELS_HANDLING", "depends_value": "Interpolate", "advanced": True, "help": "The minimumn amount of neighbours needed within the MAX_DIST parameter. Used only when BAD_CHANNELS_HANDLING is set to 'Interpolate'."},
{"name": "MAX_BAD_CHANNELS", "default": 12, "type": int, "depends_on": "BAD_CHANNELS_HANDLING", "depends_value": "Remove", "help": "Maximum amount of bad channels before the participant as a whole is marked as bad (exclusive). If this occurs, the participant will be prevented from processing any further. Used only when BAD_CHANNELS_HANDLING is set to 'Remove'."}, {"name": "MAX_BAD_CHANNELS", "default": 12, "type": int, "depends_on": "BAD_CHANNELS_HANDLING", "depends_value": "Remove", "advanced": False, "help": "Maximum amount of bad channels before the participant as a whole is marked as bad (exclusive). If this occurs, the participant will be prevented from processing any further. Used only when BAD_CHANNELS_HANDLING is set to 'Remove'."},
] ]
}, },
{ {
@@ -191,105 +209,119 @@ SECTIONS = [
{ {
"title": "Temporal Derivative Distribution Repair filtering", "title": "Temporal Derivative Distribution Repair filtering",
"params": [ "params": [
{"name": "TDDR", "default": True, "type": bool, "help": "Apply Temporal Derivitave Distribution Repair filtering - a method that removes baseline shift and spike artifacts from the data."}, {"name": "TDDR", "default": True, "type": bool, "advanced": False, "help": "Apply Temporal Derivitave Distribution Repair filtering - a method that removes baseline shift and spike artifacts from the data."},
] ]
}, },
{ {
"title": "Wavelet filtering", "title": "Wavelet filtering",
"params": [ "params": [
{"name": "WAVELET", "default": True, "type": bool, "help": "Apply Wavelet filtering. It is a method to filter involving decomposition, threholding, and reconstruction."}, {"name": "WAVELET", "default": True, "type": bool, "advanced": False, "help": "Apply Wavelet filtering. It is a method to filter involving decomposition, threholding, and reconstruction."},
{"name": "IQR", "default": 1.5, "type": float, "depends_on": "WAVELET", "help": "Scaling factor for the Inter-Quartile Range."}, {"name": "IQR", "default": 1.5, "type": float, "depends_on": "WAVELET", "advanced": False, "help": "Scaling factor for the Inter-Quartile Range."},
{"name": "WAVELET_TYPE", "default": "db4", "type": str, "depends_on": "WAVELET", "help": "Wavelet type. Valid values are ['bior1.1', 'bior1.3', 'bior1.5', 'bior2.2', 'bior2.4', 'bior2.6', 'bior2.8', 'bior3.1', 'bior3.3', 'bior3.5', 'bior3.7', 'bior3.9', 'bior4.4', 'bior5.5', 'bior6.8', 'coif1', 'coif2', 'coif3', 'coif4', 'coif5', 'coif6', 'coif7', 'coif8', 'coif9', 'coif10', 'coif11', 'coif12', 'coif13', 'coif14', 'coif15', 'coif16', 'coif17', 'db1', 'db2', 'db3', 'db4', 'db5', 'db6', 'db7', 'db8', 'db9', 'db10', 'db11', 'db12', 'db13', 'db14', 'db15', 'db16', 'db17', 'db18', 'db19', 'db20', 'db21', 'db22', 'db23', 'db24', 'db25', 'db26', 'db27', 'db28', 'db29', 'db30', 'db31', 'db32', 'db33', 'db34', 'db35', 'db36', 'db37', 'db38', 'dmey', 'haar', 'rbio1.1', 'rbio1.3', 'rbio1.5', 'rbio2.2', 'rbio2.4', 'rbio2.6', 'rbio2.8', 'rbio3.1', 'rbio3.3', 'rbio3.5', 'rbio3.7', 'rbio3.9', 'rbio4.4', 'rbio5.5', 'rbio6.8', 'sym2', 'sym3', 'sym4', 'sym5', 'sym6', 'sym7', 'sym8', 'sym9', 'sym10', 'sym11', 'sym12', 'sym13', 'sym14', 'sym15', 'sym16', 'sym17', 'sym18', 'sym19', 'sym20']"}, {"name": "WAVELET_TYPE", "default": "db4", "type": str, "depends_on": "WAVELET", "advanced": False, "help": "Wavelet type. Valid values are ['bior1.1', 'bior1.3', 'bior1.5', 'bior2.2', 'bior2.4', 'bior2.6', 'bior2.8', 'bior3.1', 'bior3.3', 'bior3.5', 'bior3.7', 'bior3.9', 'bior4.4', 'bior5.5', 'bior6.8', 'coif1', 'coif2', 'coif3', 'coif4', 'coif5', 'coif6', 'coif7', 'coif8', 'coif9', 'coif10', 'coif11', 'coif12', 'coif13', 'coif14', 'coif15', 'coif16', 'coif17', 'db1', 'db2', 'db3', 'db4', 'db5', 'db6', 'db7', 'db8', 'db9', 'db10', 'db11', 'db12', 'db13', 'db14', 'db15', 'db16', 'db17', 'db18', 'db19', 'db20', 'db21', 'db22', 'db23', 'db24', 'db25', 'db26', 'db27', 'db28', 'db29', 'db30', 'db31', 'db32', 'db33', 'db34', 'db35', 'db36', 'db37', 'db38', 'dmey', 'haar', 'rbio1.1', 'rbio1.3', 'rbio1.5', 'rbio2.2', 'rbio2.4', 'rbio2.6', 'rbio2.8', 'rbio3.1', 'rbio3.3', 'rbio3.5', 'rbio3.7', 'rbio3.9', 'rbio4.4', 'rbio5.5', 'rbio6.8', 'sym2', 'sym3', 'sym4', 'sym5', 'sym6', 'sym7', 'sym8', 'sym9', 'sym10', 'sym11', 'sym12', 'sym13', 'sym14', 'sym15', 'sym16', 'sym17', 'sym18', 'sym19', 'sym20']"},
{"name": "WAVELET_LEVEL", "default": 3, "type": int, "depends_on": "WAVELET", "help": "Wavelet Decomposition level (must be >= 0)."}, {"name": "WAVELET_LEVEL", "default": 3, "type": int, "depends_on": "WAVELET", "advanced": False, "help": "Wavelet Decomposition level (must be >= 0)."},
] ]
}, },
{ {
"title": "Haemoglobin Concentration", "title": "Haemoglobin Concentration",
"params": [ "params": [
# NOTE: Intentionally empty {"name": "OVERRIDE_PPF", "default": False, "type": bool, "advanced": True, "help": "Override the dynamic PPF calculation based on age to instead use the same values for all participants."},
# TODO: Manual override of PPF? {"name": "PPF_LOWER_WAVELENGTH", "default": 6.0, "type": float, "advanced": True, "depends_on": "OVERRIDE_PPF", "help": "PPF value to use for the lower wavelength."},
{"name": "PPF_UPPER_WAVELENGTH", "default": 6.0, "type": float, "advanced": True, "depends_on": "OVERRIDE_PPF", "help": "PPF value to use for the upper wavelength."}
] ]
}, },
{ {
"title": "Enhance Negative Correlation", "title": "Enhance Negative Correlation",
"params": [ "params": [
{"name": "ENHANCE_NEGATIVE_CORRELATION", "default": False, "type": bool, "help": "Apply Enhance Negative Correlation."}, {"name": "ENHANCE_NEGATIVE_CORRELATION", "default": False, "type": bool, "advanced": False, "help": "Apply Enhance Negative Correlation."},
] ]
}, },
{ {
"title": "Filtering", "title": "Filtering",
"params": [ "params": [
{"name": "FILTER", "default": True, "type": bool, "help": "Should the data be bandpass filtered?"}, {"name": "FILTER", "default": True, "type": bool, "advanced": False, "help": "Should the data be bandpass filtered?"},
{"name": "L_FREQ", "default": 0.005, "type": float, "depends_on": "FILTER", "help": "Any frequencies lower than this value will be removed."}, {"name": "FILTER_ALGORITHM", "default": ["FIR"], "type": list, "options": ["FIR", "IIR"], "exclusive": True, "advanced": False, "help": "Filtering algorithm."},
{"name": "H_FREQ", "default": 0.3, "type": float, "depends_on": "FILTER", "help": "Any frequencies higher than this value will be removed."}, {"name": "L_FREQ", "default": 0.005, "type": float, "depends_on": "FILTER", "advanced": False, "help": "Any frequencies lower than this value will be removed."},
{"name": "L_TRANS_BANDWIDTH", "default": 0.002, "type": float, "depends_on": "FILTER", "help": "How wide the transitional period should be so the data doesn't just drop off on the lower bound."}, {"name": "H_FREQ", "default": 0.3, "type": float, "depends_on": "FILTER", "advanced": False, "help": "Any frequencies higher than this value will be removed."},
{"name": "H_TRANS_BANDWIDTH", "default": 0.002, "type": float, "depends_on": "FILTER", "help": "How wide the transitional period should be so the data doesn't just drop off on the upper bound."}, {"name": "L_TRANS_BANDWIDTH", "default": 0.002, "type": float, "depends_on": "FILTER", "advanced": True, "help": "Width of the lower transition band to prevent abrupt filter cutoff."},
{"name": "H_TRANS_BANDWIDTH", "default": 0.002, "type": float, "depends_on": "FILTER", "advanced": True, "help": "Width of the upper transition band to prevent abrupt filter cutoff."},
{"name": "IIR_TYPE", "default": ["butterworth"], "type": list, "options": ["butterworth", "chebyshev1", "chebyshev2", "elliptic", "bessel"], "exclusive": True, "depends_on": "FILTER_ALGORITHM", "depends_value": "IIR", "advanced": True, "help": "IIR filter design."},
{"name": "IIR_ORDER", "default": 4, "type": int, "depends_on": "FILTER_ALGORITHM", "depends_value": "IIR", "advanced": True, "help": "Order of the IIR filter."},
{"name": "FILTER_LENGTH", "default": "auto", "type": str, "depends_on": "FILTER_ALGORITHM", "depends_value": "FIR", "advanced": True, "help": "Length of the FIR filter. 'auto' allows automatic selection."},
{"name": "FILTER_PHASE", "default": ["zero"], "type": list, "options": ["zero", "zero-double", "minimum", "minimum-half"], "exclusive": True, "depends_on": "FILTER_ALGORITHM", "depends_value": "FIR", "advanced": True, "help": "Phase response of the FIR filter."},
{"name": "FIR_WINDOW", "default": ["hamming"], "type": list, "options": ["hamming", "hann", "blackman"], "exclusive": True, "depends_on": "FILTER_ALGORITHM", "depends_value": "FIR", "advanced": True, "help": "Window function used when designing the FIR filter."},
{"name": "FIR_DESIGN", "default": ["firwin"], "type": list, "options": ["firwin", "firwin2"], "exclusive": True, "depends_on": "FILTER_ALGORITHM", "depends_value": "FIR", "advanced": True, "help": "Method used to design the FIR filter."},
{"name": "IIR_OUTPUT", "default": ["sos"], "type": list, "options": ["sos", "ba", "zpk"], "exclusive": True, "depends_on": "FILTER_ALGORITHM", "depends_value": "IIR", "advanced": True, "help": "Representation used for IIR filter coefficients."},
{"name": "PASSBAND_RIPPLE", "default": 1.0, "type": float, "depends_on": "IIR_TYPE", "depends_value": ["chebyshev1", "elliptic"], "advanced": True, "help": "Maximum allowed ripple in the passband (dB)."},
{"name": "STOPBAND_ATTENUATION", "default": 40.0, "type": float, "depends_on": "IIR_TYPE", "depends_value": ["chebyshev2", "elliptic"], "advanced": True, "help": "Minimum attenuation in the stopband (dB)."},
{"name": "FILTER_PAD", "default": ["reflect_limited"], "type": list, "options": ["reflect_limited", "reflect", "edge", "constant"], "exclusive": True, "depends_on": "FILTER", "advanced": True, "help": "Padding strategy used during filtering to reduce edge artifacts."},
{"name": "SKIP_BY_ANNOTATION", "default": ["edge", "bad_acq_skip"], "type": list, "depends_on": "FILTER", "advanced": True, "help": "Annotations that should be skipped when applying the filter."},
{"name": "FILTER_N_JOBS", "default": 1, "type": int, "advanced": True, "help": "Number of parallel jobs used during filtering. Use -1 to use all available CPUs."},
] ]
}, },
{ {
"title": "Extracting Events*", "title": "Extracting Events*",
"params": [ "params": [
#{"name": "EVENTS", "default": True, "type": bool, "help": "Calculate Peak Spectral Power."}, {"name": "EVENTS", "default": True, "type": bool, "advanced": False, "help": "Extract events from annotations for visualization and downstream event-based analysis."},
{"name": "EVENT_ID", "default": "auto", "type": str, "advanced": True, "help": "Controls how annotation descriptions are converted into event identifiers. Use 'auto' for automatic event detection."},
{"name": "EVENT_REGEX", "default": "^(?![Bb][Aa][Dd]|[Ee][Dd][Gg][Ee]).*$", "type": str, "advanced": True, "help": "Regular expression used to select which annotations are converted into events. By default, bad and edge annotations are ignored."},
{"name": "EVENT_CHUNK_DURATION", "default": None, "type": float, "advanced": True, "help": "If provided, creates repeated events at this interval within longer annotations instead of only using annotation onset times."},
] ]
}, },
{ {
"title": "Epoch Calculations", "title": "Epoch Calculations",
"params": [ "params": [
# TODO: implement drop {"name": "EPOCHS", "default": True, "type": bool, "advanced": False, "help": "Create epochs around extracted events for condition-based analysis."},
{"name": "EPOCH_HANDLING", "default": ["shift"], "type": list, "options": ["shift", "strict"], "help": "What to do if two unique events occur at the same time. Shift will automatically move one event to the first valid free index. Strict will raise an error processing the file. Drop will remove one of the events."}, {"name": "EPOCH_HANDLING", "default": ["shift"], "type": list, "options": ["shift", "strict", "drop"], "exclusive": True, "advanced": False, "help": "How to handle events occurring at the same sample. Shift moves conflicting events forward, strict raises an error, and drop removes conflicting events."},
{"name": "MAX_SHIFT", "default": 5, "type": int, "depends_on": "EPOCH_HANDLING", "depends_value": "shift", "help": "Amount of indexes to look ahead and see if there is a valid one to shift to. If none were found, will fall back to 'strict' behaviour."}, {"name": "MAX_SHIFT", "default": 5, "type": int, "depends_on": "EPOCH_HANDLING", "depends_value": "shift", "advanced": True, "help": "Maximum number of samples to shift conflicting events before failing."},
#{"name": "REJECT_BY_ANNOTATIONS", "default": True, "type": bool, "help": "Help."}, {"name": "T_MIN", "default": -5.0, "type": float, "advanced": False, "help": "Time in seconds before each event to include in the epoch."},
#{"name": "MAX_SHIFT", "default": 5, "type": int, "depends_on": "EPOCH_HANDLING", "depends_value": "shift", "help": "Amount of indexes to look ahead and see if there is a valid one to shift to. If none were found, will fall back to 'strict' behaviour."}, {"name": "T_MAX", "default": 15.0, "type": float, "advanced": False, "help": "Time in seconds after each event to include in the epoch."},
#{"name": "MAX_SHIFT", "default": 5, "type": int, "depends_on": "EPOCH_HANDLING", "depends_value": "shift", "help": "Amount of indexes to look ahead and see if there is a valid one to shift to. If none were found, will fall back to 'strict' behaviour."}, {"name": "BASELINE", "default": ["pre_event"], "type": list, "options": ["none", "pre_event"], "exclusive": True, "advanced": False, "help": "Baseline correction applied to epochs. Pre-event uses the period before the event as baseline."},
{"name": "T_MIN", "default": -5, "type": int, "help": "Seconds before the epoch to be used."}, {"name": "REJECT_EPOCHS", "default": True, "type": bool, "advanced": False, "help": "Automatically reject epochs containing excessively large haemoglobin amplitude changes."},
{"name": "T_MAX", "default": 15, "type": int, "help": "Seconds after the epoch to be used."}, {"name": "REJECT_HBO_THRESHOLD", "default": 80e-7, "type": float, "depends_on": "REJECT_EPOCHS", "advanced": True, "help": "Maximum allowed HbO amplitude before an epoch is rejected."},
] ]
}, },
{ {
"title": "Design Matrix", "title": "Design Matrix",
"params": [ "params": [
{"name": "RESAMPLE", "default": True, "type": bool, "help": "Should the data be resampled before calculating the design matrix? Downsampling is useful for speeding up calculations without losing overall data shape."}, {"name": "RESAMPLE", "default": True, "type": bool, "advanced": False, "help": "Resample the data before creating the design matrix. Lower frequencies can reduce computation time while preserving the overall signal shape."},
{"name": "RESAMPLE_FREQ", "default": 1, "type": int, "help": "The frequency the data should be resampled to."}, {"name": "RESAMPLE_FREQ", "default": 1, "type": int, "depends_on": "RESAMPLE", "advanced": False, "help": "Sampling frequency (Hz) used when resampling the data before design matrix calculation."},
{"name": "HRF_MODEL", "default": ["fir"], "type": list, "options": ["fir", "glover", "spm", "spm + derivative", "spm + derivative + dispersion", "glover + derivative", "glover + derivative + dispersion"], "exclusive": True, "help": "Specifies the haemodynamic response function."}, {"name": "HRF_MODEL", "default": ["fir"], "type": list, "options": ["fir", "glover", "spm", "spm + derivative", "spm + derivative + dispersion", "glover + derivative", "glover + derivative + dispersion"], "exclusive": True, "advanced": False, "help": "Haemodynamic response function model used to create regressors from event timings."},
{"name": "STIM_DUR", "default": 0.5, "type": float, "help": "The length of your stimulus. If your HRF_MODEL is fir, this dictates how wide a bin should be."}, {"name": "STIM_DUR", "default": 0.5, "type": float, "advanced": False, "help": "Expected duration of each stimulus/event in seconds. For FIR models, determines the width of each event bin."},
{"name": "DRIFT_MODEL", "default": ["cosine"], "type": list, "options": ["cosine", "polynomial"], "help": "Specifies the desired drift model."}, {"name": "FIR_DELAYS", "default": 15, "type": range, "depends_on": "HRF_MODEL", "depends_value": "fir", "advanced": True, "help": "Number of delayed regressors used for FIR models. Defines how long after an event the response is modelled."},
{"name": "HIGH_PASS", "default": 0.01, "type": float, "help": "High-pass frequency in case of a cosine model (in Hz)."}, {"name": "DRIFT_MODEL", "default": ["cosine"], "type": list, "options": ["cosine", "polynomial"], "exclusive": True, "advanced": True, "help": "Model used to account for slow baseline signal drift."},
{"name": "DRIFT_ORDER", "default": 1, "type": int, "help": "Order of the drift model (in case it is polynomial)"}, {"name": "HIGH_PASS", "default": 0.01, "type": float, "depends_on": "DRIFT_MODEL", "depends_value": "cosine", "advanced": True, "help": "High-pass cutoff frequency (Hz) for cosine drift removal."},
{"name": "FIR_DELAYS", "default": 15, "type": range, "depends_on": "HRF_MODEL", "depends_value": "fir", "help": "In case of FIR design, yields the array of delays used in the FIR model (in scans)."}, {"name": "DRIFT_ORDER", "default": 1, "type": int, "depends_on": "DRIFT_MODEL", "depends_value": "polynomial", "advanced": True, "help": "Polynomial order used to model slow drift."},
{"name": "MIN_ONSET", "default": -24, "type": int, "help": "Minimal onset relative to frame times (in seconds)"}, {"name": "MIN_ONSET", "default": -24, "type": int, "advanced": True, "help": "Minimum event onset relative to the sampled frame times in seconds."},
{"name": "OVERSAMPLING", "default": 50, "type": int, "help": "Oversampling factor used in temporal convolutions."}, {"name": "OVERSAMPLING", "default": 50, "type": int, "advanced": True, "help": "Temporal oversampling factor used during HRF convolution."},
# TODO: Re-implement this without causing a memory leak {"name": "SHORT_CHANNEL_REGRESSION", "default": True, "type": bool, "depends_on": "SHORT_CHANNELS", "advanced": False, "help": "Add short channel signals to the design matrix as nuisance regressors to reduce superficial physiological noise."},
# {"name": "REMOVE_EVENTS", "default": "None", "type": list, "help": "Remove events matching the names provided before generating the Design Matrix"},
{"name": "SHORT_CHANNEL_REGRESSION", "default": True, "type": bool, "depends_on": "SHORT_CHANNELS", "help": "Should short channel regression be used to create the design matrix? This will use the 'signal' from the short channel and regress it out of all other channels."},
] ]
}, },
{ {
"title": "General Linear Model", "title": "General Linear Model",
"params": [ "params": [
{"name": "NOISE_MODEL", "default": "ar1", "type": str, "help": "The temporal variance model. Defaults to first order auto regressive model 'ar1'. The AR model can be set to any integer value by modifying the value of N. E.g. use ar5 for a fifth order model. If the string auto is provided a model with order 4 times the sample rate will be used."}, {"name": "NOISE_MODEL", "default": "ar1", "type": str, "advanced": False, "help": "The temporal variance model. Defaults to first order auto regressive model 'ar1'. The AR model can be set to any integer value by modifying the value of N. E.g. use ar5 for a fifth order model. If the string auto is provided a model with order 4 times the sample rate will be used."},
{"name": "BINS", "default": 0, "type": int, "help": "Maximum number of discrete bins for the AR coef histogram/clustering. By default the value is 0, which will set the number of bins to the number of channels, effectively estimating the AR model for each channel."}, {"name": "BINS", "default": 0, "type": int, "advanced": True, "help": "Maximum number of discrete bins for the AR coef histogram/clustering. By default the value is 0, which will set the number of bins to the number of channels, effectively estimating the AR model for each channel."},
{"name": "N_JOBS", "default": 1, "type": int, "help": "The number of CPUs to use to do the GLM computation. -1 means 'all CPUs'."}, {"name": "N_JOBS", "default": 1, "type": int, "advanced": True, "help": "The number of CPUs to use to do the GLM computation. -1 means 'all CPUs'."},
] ]
}, },
{ {
"title": "Region of Interest", "title": "Region of Interest",
"params": [ "params": [
{"name": "JSON_LOCATION", "default": "", "type": "json_file", "help": "Location of the JSON file containing region of interest results for significance calculations."}, {"name": "JSON_LOCATION", "default": "", "type": "json_file", "advanced": False, "help": "Location of the JSON file containing region of interest results for significance calculations."},
] ]
}, },
{ {
"title": "Finishing Touches", "title": "Contrast",
"params": [ "params": [
# Intentionally empty (TODO) {"name": "CONTRAST_BASELINE", "default": True, "type": bool, "advanced": True, "help": "Calculate contrasts comparing each condition against zero/baseline. This may not always represent a meaningful physiological baseline in fNIRS."},
{"name": "CONTRAST_PAIRWISE", "default": True, "type": bool, "advanced": True, "help": "Calculate contrasts comparing each experimental condition against every other condition. The number of contrasts increases with the number of conditions."}
] ]
}, },
{ {
"title": "Other", "title": "Other",
"params": [ "params": [
{"name": "TIME_WINDOW_START", "default": 0, "type": int, "help": "Where to start averaging the fir model bins. Only affects the significance and contrast images."}, {"name": "MAX_WORKERS", "default": 6, "type": int, "advanced": False, "help": "Number of files to be processed at once. Setting this to a small integer value may help on underpowered systems. Remove the value to use an automatic amount."},
{"name": "TIME_WINDOW_END", "default": 15, "type": int, "help": "Where to end averaging the fir model bins. Only affects the significance and contrast images."}, {"name": "VERBOSITY", "default": False, "type": bool, "advanced": True, "help": "Setting this to True will log lots of debugging information to the log file. Setting this to False will log minimal data."},
{"name": "MAX_WORKERS", "default": 6, "type": int, "help": "Number of files to be processed at once. Setting this to a small integer value may help on underpowered systems. Remove the value to use an automatic amount."},
{"name": "VERBOSITY", "default": False, "type": bool, "help": "Setting this to True will log lots of debugging information to the log file. Setting this to False will log minimal data."},
] ]
}, },
] ]
@@ -425,6 +457,35 @@ class GroupAssignmentDialog(QDialog):
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
layout.addWidget(buttons) layout.addWidget(buttons)
@classmethod
def run_for_field(cls, parent, file_metadata: dict, field_name: str = "AGE"):
"""Extracts values, checks for data, presents the dialog, and returns mappings."""
# A. Collect unique non-empty values
unique_values = sorted(
list(
{
str(meta.get(field_name, "")).strip()
for meta in file_metadata.values()
if str(meta.get(field_name, "")).strip()
}
)
)
if not unique_values:
QMessageBox.information(
parent,
"No Groupable Metadata",
f"No '{field_name}' values were found in the metadata to group.",
)
return None
# B. Instantiate and show dialog
dialog = cls(parent, unique_values=unique_values, field_name=field_name)
if dialog.exec() == QDialog.DialogCode.Accepted:
return dialog.get_mappings()
return None
def _add_group(self): def _add_group(self):
"""Adds a new group to the available options.""" """Adds a new group to the available options."""
name = self.group_input.text().strip() name = self.group_input.text().strip()
@@ -608,8 +669,10 @@ class MainApplication(QMainWindow):
self.about = None self.about = None
self.help = None self.help = None
self.regroup_metadata = None
self.optodes = None self.optodes = None
self.events = None self.events = None
self.events_blazes = None
self.terminal = None self.terminal = None
self.bubble_widgets = {} self.bubble_widgets = {}
self.param_sections = [] self.param_sections = []
@@ -621,6 +684,7 @@ class MainApplication(QMainWindow):
self.missing_events_bypass = False self.missing_events_bypass = False
self.analysis_clearing_bypass = False self.analysis_clearing_bypass = False
self.folding_bypass = False self.folding_bypass = False
self.advanced_parameters = False
self.json_location = "" self.json_location = ""
@@ -880,6 +944,7 @@ class MainApplication(QMainWindow):
options_actions = [ options_actions = [
("User Guide", "F1", self.user_guide, resource_path("icons/help_24dp_1F1F1F.svg")), ("User Guide", "F1", self.user_guide, resource_path("icons/help_24dp_1F1F1F.svg")),
("Regroup Files from Metadata", "F3", self.group_metadata, resource_path("icons/help_24dp_1F1F1F.svg")),
("Check for Updates", "F5", self.updater.manual_check_for_updates, resource_path("icons/update_24dp_1F1F1F.svg")), ("Check for Updates", "F5", self.updater.manual_check_for_updates, resource_path("icons/update_24dp_1F1F1F.svg")),
("Show Update Changelog", "F6", self.show_update_changelog, resource_path("icons/article_shortcut_24dp_1F1F1.svg")), ("Show Update Changelog", "F6", self.show_update_changelog, resource_path("icons/article_shortcut_24dp_1F1F1.svg")),
("Update events in snirf file (BORIS)...", "F7", self.update_event_markers, resource_path("icons/upgrade_24dp_1F1F1F.svg")), ("Update events in snirf file (BORIS)...", "F7", self.update_event_markers, resource_path("icons/upgrade_24dp_1F1F1F.svg")),
@@ -891,7 +956,7 @@ class MainApplication(QMainWindow):
for i, (name, shortcut, slot, icon) in enumerate(options_actions): for i, (name, shortcut, slot, icon) in enumerate(options_actions):
options_menu.addAction(make_action(name, shortcut, slot, icon=icon)) options_menu.addAction(make_action(name, shortcut, slot, icon=icon))
if i == 2 or i == 5 or i == 6 or i == 7: if i == 3 or i == 6 or i == 7:
options_menu.addSeparator() options_menu.addSeparator()
self.pref_actions = {} self.pref_actions = {}
@@ -903,6 +968,7 @@ class MainApplication(QMainWindow):
("Missing Events Bypass", "", self.missing_events_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "missing_events_bypass"), ("Missing Events Bypass", "", self.missing_events_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "missing_events_bypass"),
("Analysis Clearing Bypass", "", self.analysis_clearing_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "analysis_clearing_bypass"), ("Analysis Clearing Bypass", "", self.analysis_clearing_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "analysis_clearing_bypass"),
("Folding Bypass", "", self.folding_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "folding_bypass"), ("Folding Bypass", "", self.folding_bypass_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "folding_bypass"),
("Show Advanced Parameters", "", self.advanced_parameters_func, resource_path("icons/warning_off_24dp_1F1F1F.svg"), "advanced_parameters"),
] ]
for name, shortcut, slot, icon, config_key in preferences_actions: for name, shortcut, slot, icon, config_key in preferences_actions:
@@ -923,23 +989,27 @@ class MainApplication(QMainWindow):
def update_sections(self, index): def update_sections(self, index):
# Clear previous sections self.current_section_index = index
for i in reversed(range(self.rows_layout.count())):
widget = self.rows_layout.itemAt(i).widget()
if widget is not None:
widget.deleteLater()
self.param_sections.clear()
# Build sections ONCE to avoid destroying/re-creating widgets in C++
if not hasattr(self, "_sections_built") or not self._sections_built:
self.param_sections.clear()
self.global_param_widgets = {} self.global_param_widgets = {}
# Add ParamSection widgets from SECTIONS
for section in SECTIONS: for section in SECTIONS:
self.section_widget = ParamSection(section, self.global_param_widgets) section_widget = ParamSection(section, self.global_param_widgets)
self.rows_layout.addWidget(self.section_widget) self.rows_layout.addWidget(section_widget)
self.param_sections.append(section_widget)
self.param_sections.append(self.section_widget) self._sections_built = True
# Defensive dictionary lookup for the preference action
pref_action = getattr(self, "pref_actions", {}).get("advanced_parameters")
show_advanced = pref_action.isChecked() if pref_action is not None else False
# Toggle visibility on all built sections
for sec in self.param_sections: for sec in self.param_sections:
sec.set_advanced_visible(show_advanced)
sec.update_dependencies() sec.update_dependencies()
@@ -1255,6 +1325,11 @@ class MainApplication(QMainWindow):
self.folding_bypass = checked self.folding_bypass = checked
self._update_config_setting("folding_bypass", checked) self._update_config_setting("folding_bypass", checked)
def advanced_parameters_func(self, checked):
self.advanced_parameters = checked
self._update_config_setting("advanced_parameters", checked)
self.update_sections(0)
def about_window(self): def about_window(self):
if self.about is None or not self.about.isVisible(): if self.about is None or not self.about.isVisible():
self.about = AboutWindow(self) self.about = AboutWindow(self)
@@ -1265,6 +1340,23 @@ class MainApplication(QMainWindow):
self.help = UserGuideWindow(self) self.help = UserGuideWindow(self)
self.help.show() self.help.show()
def group_metadata(self):
if self.regroup_metadata is None or not self.regroup_metadata.isVisible():
file_meta = getattr(self, "file_metadata", {})
if any(bool(meta) for meta in file_meta.values()):
mappings = GroupAssignmentDialog.run_for_field(
self, file_meta, field_name="AGE"
)
if mappings:
self._apply_group_mappings(mappings, field_name="AGE")
else:
QMessageBox.information(
None,
"No Data",
"This action is not available at this time.",
QMessageBox.Ok
)
def terminal_gui(self): def terminal_gui(self):
if self.terminal is None or not self.terminal.isVisible(): if self.terminal is None or not self.terminal.isVisible():
self.terminal = TerminalWindow(self) self.terminal = TerminalWindow(self)
@@ -1281,9 +1373,9 @@ class MainApplication(QMainWindow):
self.events.show() self.events.show()
def update_event_markers_blazes(self): def update_event_markers_blazes(self):
if self.events is None or not self.events.isVisible(): if self.events_blazes is None or not self.events_blazes.isVisible():
self.events = UpdateEventsBlazesWindow(self, EventUpdateMode.WRITE_SNIRF, "Manual SNIRF Edit") self.events_blazes = UpdateEventsBlazesWindow(self, EventUpdateMode.WRITE_SNIRF, "Manual SNIRF Edit")
self.events.show() self.events_blazes.show()
def show_update_changelog(self): def show_update_changelog(self):
welcome = WelcomeDialog(self, direct=False) welcome = WelcomeDialog(self, direct=False)
@@ -1340,7 +1432,7 @@ class MainApplication(QMainWindow):
self.missing_events_bypass = file_cfg.getboolean("Preferences", "missing_events_bypass", fallback=False) self.missing_events_bypass = file_cfg.getboolean("Preferences", "missing_events_bypass", fallback=False)
self.analysis_clearing_bypass = file_cfg.getboolean("Preferences", "analysis_clearing_bypass", fallback=False) self.analysis_clearing_bypass = file_cfg.getboolean("Preferences", "analysis_clearing_bypass", fallback=False)
self.folding_bypass = file_cfg.getboolean("Preferences", "folding_bypass", fallback=False) self.folding_bypass = file_cfg.getboolean("Preferences", "folding_bypass", fallback=False)
self.advanced_parameters = file_cfg.getboolean("Preferences", "advanced_parameters", fallback=False)
# 2. Sync the UI Menu checkmarks visually # 2. Sync the UI Menu checkmarks visually
if hasattr(self, 'pref_actions'): if hasattr(self, 'pref_actions'):
@@ -1349,6 +1441,10 @@ class MainApplication(QMainWindow):
self.pref_actions["missing_events_bypass"].setChecked(self.missing_events_bypass) self.pref_actions["missing_events_bypass"].setChecked(self.missing_events_bypass)
self.pref_actions["analysis_clearing_bypass"].setChecked(self.analysis_clearing_bypass) self.pref_actions["analysis_clearing_bypass"].setChecked(self.analysis_clearing_bypass)
self.pref_actions["folding_bypass"].setChecked(self.folding_bypass) self.pref_actions["folding_bypass"].setChecked(self.folding_bypass)
self.pref_actions["advanced_parameters"].setChecked(self.advanced_parameters)
if self.advanced_parameters:
self.update_sections(0)
if hasattr(self, 'recent_files_menu'): if hasattr(self, 'recent_files_menu'):
self.update_recent_files_menu() self.update_recent_files_menu()
@@ -2587,6 +2683,30 @@ class MainApplication(QMainWindow):
self.metadata_processed.emit(file_path, session_id) self.metadata_processed.emit(file_path, session_id)
def populate_metadata_fields(self, file_path: str):
"""
Populates the right-column metadata QLineEdits for the given file_path
without triggering textChanged sync events during the population.
"""
if not file_path:
return
# Normalize path to match key format in file_metadata
norm_path = os.path.normpath(file_path)
meta = self.file_metadata.get(
norm_path, self.file_metadata.get(file_path, {})
)
# Loop through all dynamically created fields (AGE, GENDER, GROUP, etc.)
for key, line_edit in self.meta_fields.items():
val = str(meta.get(key, "")).strip()
# Block textChanged signals so sync_bubble_data doesn't fire redundant loops
line_edit.blockSignals(True)
line_edit.setText(val)
line_edit.blockSignals(False)
def _remove_file_from_pipeline(self, file_path): def _remove_file_from_pipeline(self, file_path):
"""Completely cleans up and removes all references to a file that failed to load.""" """Completely cleans up and removes all references to a file that failed to load."""
# 1. Decrement pending file count # 1. Decrement pending file count
@@ -2626,12 +2746,8 @@ class MainApplication(QMainWindow):
if self.pending_files_count <= 0: if self.pending_files_count <= 0:
self._cleanup_executor() self._cleanup_executor()
self.statusbar.showMessage("All files loaded sucessfully.") self.statusbar.showMessage("All files loaded sucessfully.")
has_metadata = any( file_meta = getattr(self, "file_metadata", {})
bool(meta_dict) if any(bool(meta) for meta in file_meta.values()):
for meta_dict in getattr(self, "file_metadata", {}).values()
)
if has_metadata:
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"Metadata Detected", "Metadata Detected",
@@ -2641,42 +2757,22 @@ class MainApplication(QMainWindow):
) )
if reply == QMessageBox.StandardButton.Yes: if reply == QMessageBox.StandardButton.Yes:
# A. Collect all unique non-empty AGE values across loaded files mappings = GroupAssignmentDialog.run_for_field(
unique_ages = sorted( self, file_meta, field_name="AGE"
list(
{
str(meta.get("AGE", "")).strip()
for meta in getattr(
self, "file_metadata", {}
).values()
if str(meta.get("AGE", "")).strip()
}
)
) )
if mappings:
self._apply_group_mappings(mappings, field_name="AGE")
if not unique_ages:
QMessageBox.information(
self,
"No Groupable Metadata",
"No 'AGE' values were found in the metadata to group.",
)
return
# B. Open Group Assignment Dialog
dialog = GroupAssignmentDialog(
self, unique_values=unique_ages, field_name="AGE"
)
if dialog.exec() == QDialog.DialogCode.Accepted:
mappings = dialog.get_mappings()
def _apply_group_mappings(self, mappings: dict, field_name: str = "AGE"):
"""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():
file_age = str(meta.get("AGE", "")).strip() val = str(meta.get(field_name, "")).strip()
if file_age in mappings: if val in mappings:
meta["GROUP"] = mappings[file_age] meta["GROUP"] = mappings[val]
# D. Update the text on ALL bubbles immediately # D. Update text on ALL bubble widgets
for path_key, b_widget in self.bubble_widgets.items(): for path_key, b_widget in self.bubble_widgets.items():
normalized_k = os.path.normpath(path_key) normalized_k = os.path.normpath(path_key)
meta_dict = self.file_metadata.get( meta_dict = self.file_metadata.get(
@@ -2685,12 +2781,11 @@ class MainApplication(QMainWindow):
suffix = self.get_suffix_from_meta_fields(meta_dict) suffix = self.get_suffix_from_meta_fields(meta_dict)
b_widget.setSuffixText(suffix) b_widget.setSuffixText(suffix)
# E. Refresh form fields if a file is currently active in UI # E. Refresh form fields for currently active file
current_active = getattr(self, "current_file", None) current_active = getattr(self, "current_file", None)
if current_active: if current_active:
self.populate_metadata_fields(current_active) self.populate_metadata_fields(current_active)
def _cleanup_executor(self): def _cleanup_executor(self):
"""Safely shuts down the executor and clears the reference.""" """Safely shuts down the executor and clears the reference."""
if hasattr(self, 'file_executor') and self.file_executor is not None: if hasattr(self, 'file_executor') and self.file_executor is not None:
+43 -9
View File
@@ -319,23 +319,40 @@ class ParamSection(QWidget):
self.dependencies = [] self.dependencies = []
self.selected_path = None self.selected_path = None
self.param_rows = []
self.header_widgets = []
# Title label # Title label
title_label = QLabel(section_data["title"]) title_label = QLabel(section_data["title"])
title_label.setStyleSheet("font-weight: bold; font-size: 14px; margin-top: 10px; margin-bottom: 5px;") title_label.setStyleSheet("font-weight: bold; font-size: 14px; margin-top: 10px; margin-bottom: 5px;")
layout.addWidget(title_label) layout.addWidget(title_label)
self.header_widgets.append(title_label)
# Horizontal line # Horizontal line
line = QFrame() line = QFrame()
line.setFrameShape(QFrame.Shape.HLine) line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken) line.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line) layout.addWidget(line)
self.header_widgets.append(line)
for param in section_data["params"]: for param in section_data["params"]:
h_layout = QHBoxLayout() h_layout = QHBoxLayout()
label = QLabel(param["name"]) is_advanced = param.get("advanced", False)
# Build label text and tooltips
param_name = param["name"]
if is_advanced:
label_text = f"⚠️ {param_name}"
else:
label_text = param_name
label = QLabel(label_text)
# Set hover tooltip
if is_advanced:
label.setToolTip(f"ADVANCED: {param.get("help", "")}")
else:
label.setToolTip(param.get("help", "")) label.setToolTip(param.get("help", ""))
help_text = param.get("help", "") help_text = param.get("help", "")
@@ -346,7 +363,6 @@ class ParamSection(QWidget):
help_btn.clicked.connect(lambda _, text=help_text: self.show_help_popup(text)) help_btn.clicked.connect(lambda _, text=help_text: self.show_help_popup(text))
h_layout.addWidget(help_btn) h_layout.addWidget(help_btn)
h_layout.addWidget(label) h_layout.addWidget(label)
h_layout.setStretch(0, 1) h_layout.setStretch(0, 1)
h_layout.setStretch(1, 6) h_layout.setStretch(1, 6)
@@ -421,8 +437,32 @@ class ParamSection(QWidget):
"h_layout": h_layout "h_layout": h_layout
} }
row_widgets = [help_btn, label, widget]
is_advanced = param.get("advanced", False)
self.param_rows.append((row_widgets, h_layout, is_advanced))
self.update_dependencies() self.update_dependencies()
def set_advanced_visible(self, show_advanced: bool):
"""Shows/hides advanced parameters and adjusts header visibility dynamically."""
has_visible_rows = False
for row_widgets, h_layout, is_advanced in self.param_rows:
visible = show_advanced or not is_advanced
# Toggle visibility of all child widgets in the row
for w in row_widgets:
w.setVisible(visible)
if visible:
has_visible_rows = True
# Hide or show section header title and horizontal rule if section is completely empty
for hw in self.header_widgets:
hw.setVisible(has_visible_rows)
self.setVisible(has_visible_rows)
def has_any_changes(self): def has_any_changes(self):
"""Returns True if any parameter in this section differs from its default.""" """Returns True if any parameter in this section differs from its default."""
@@ -473,16 +513,10 @@ class ParamSection(QWidget):
else: else:
is_changed = str(current_value) != str(default) is_changed = str(current_value) != str(default)
# Update Font
font = label.font()
font.setBold(is_changed)
label.setFont(font)
# Optional: Change color to make it even more obvious
if is_changed: if is_changed:
label.setStyleSheet("color: #3498db; font-weight: bold;") # Nice Blue label.setStyleSheet("color: #3498db; font-weight: bold;") # Nice Blue
else: else:
label.setStyleSheet("color: none; font-weight: normal;") label.setStyleSheet("")
def notify_global_update(self): def notify_global_update(self):
""" """
+1 -1
View File
@@ -13,7 +13,7 @@ import sys
import platform import platform
CURRENT_VERSION = "1.5.0" CURRENT_VERSION = "1.5.2"
APP_NAME = "flares" APP_NAME = "flares"
APP_NAME_EXPANDED = "fNIRS Lightweight Analysis, Research, & Evaluation Suite" APP_NAME_EXPANDED = "fNIRS Lightweight Analysis, Research, & Evaluation Suite"
API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases" API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"