functional connectivity, pylance, and other improvements

This commit is contained in:
2026-08-22 23:38:13 -07:00
parent 19bd3f1279
commit e37275a1bb
14 changed files with 1455 additions and 841 deletions
+21 -7
View File
@@ -1,14 +1,28 @@
# Version 1.6.1
- Fixed an issue where file associations appeared to work but would not load the project on macOS
- Fixed an issue where file associations would refuse to associate on macOS
- Renamed all instances of "Inter" to properly read as "Intra" and changed "Cross" to now read as "Inter"
- Changed RESAMPLE to only apply where it is required to avoid having Functional Connectivity analysis methods running on data that has been resampled
- Added parameters that appear when attempting to generate results from the Participant and Intra-Group Functional Connectivity viewers and removed the non-functional placeholder parameters
- Modified the Participant and Intra-Group Functional Connectivity analysis options to better perform their expected tasks. This remains as a BETA feature
- Removed the existing Intra-Group Functional Connectivity option and replaced it with two new ones: Beta-Series Correlation and Spectral Coherence (epochs)
- Updated the names of the methods provided for the Participant Functional Coneectivity Viewer to better match the actions they perform
- Updated the warnings for the Functional Connectivity Viewers to better represent the challenges these analysis options now face
- Added basic unit testing to hopefully prevent any accidental processing changes from occurring in the future
- Added description text to the Inter-Group and Intra-Group Brain and Image Viewers, as well as the Functional Connectivity windows to explain what output can be expected
- Removed image index 1 (Significance) from the Intra-Group Brain and Image Viewer as it is now provided more in depth with the Stats viewers
- Modified the timeout when waiting for the application to close while performing updates down to a reasonable number
- Modified the heart rate calculation to not take only one channel in the data to use, but rather an average of channels. This still prefers short channels if they are present
- Fixed an issue that could prevent log file generation while the application was in the middle of an update
- Fixed an issue where a rare crash could occur while the application was in the middle of an update
- Fixed an issue that could have passed multiple conditions when generating an Intra-Group Stats image
- Fixed an issue that could pass NaN values when attempting to collapse channels
- Fixed an issue that was causing the OLS model to always be used for brain images with multiple participants, and not the MixedLM model
- Fixed an issue that could cause the Wavelet filtering step to crash
- Fixed an issue where file associations appeared to work as intended but would not load the project on macOS and only open the application
- Fixed an issue where file associations would refuse to associate on macOS once they have attempted to be associated
- Fixed an issue where certain parameters would not enable or disable depending on other parameters when they should've
- Fixed an issue where not all widgets would close when attempting to close the application causing the application to crash
- Renamed all instances of "Inter" to properly read as "Intra" and changed "Cross" to now read as "Inter"
- Revamped the Participant Functional Connectivity Viewer to contain descriptions of the methods like the Stats Viewers
- Modified the Participant Functional Connectivity Analysis options to better perform their tasks. This remains as a BETA feature
- Changed RESAMPLE to only apply where it is required to avoid having Functional Connectivity Analysis methods running on data that has been resampled
- Added basic unit testing to hopefully prevent any accidental processing changes from occurring in the future
- Fixed an issue where events were not created correctly after the data had been resampled by the design matrix
# Version 1.6.0
+884 -419
View File
File diff suppressed because it is too large Load Diff
+45 -26
View File
@@ -1,6 +1,7 @@
"""
Filename: flares_updater.py
Description: FLARES updater executable
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
@@ -15,8 +16,11 @@ import psutil
import shutil
import platform
import subprocess
from typing import Union
from pathlib import Path
from datetime import datetime
PLATFORM_NAME = platform.system().lower()
APP_NAME = "flares"
@@ -27,13 +31,14 @@ else:
LOG_FILE = _log_path
def log(msg):
def log(msg: str) -> None:
with open(LOG_FILE, "a", encoding="utf-8") as f:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
f.write(f"{timestamp} - {msg}\n")
def kill_all_processes_by_executable(exe_path):
def kill_all_processes_by_executable(exe_path: Union[str, Path]) -> bool:
terminated_any = False
exe_path = os.path.realpath(exe_path)
@@ -65,7 +70,7 @@ def kill_all_processes_by_executable(exe_path):
return terminated_any
def _terminate_process(proc):
def _terminate_process(proc: psutil.Process) -> None:
try:
proc.terminate()
proc.wait(timeout=10)
@@ -77,7 +82,7 @@ def _terminate_process(proc):
log(f"Process {proc.pid} killed.")
def wait_for_unlock(path, timeout=100):
def wait_for_unlock(path: Union[str, Path], timeout: Union[int, float] = 100) -> None:
start_time = time.time()
while time.time() - start_time < timeout:
try:
@@ -93,7 +98,7 @@ def wait_for_unlock(path, timeout=100):
log(f"Failed to delete after wait: {path}")
def delete_path(path):
def delete_path(path: Union[str, Path]) -> None:
if os.path.exists(path):
try:
if os.path.isdir(path):
@@ -106,7 +111,7 @@ def delete_path(path):
log(f"Error deleting {path}: {e}")
def copy_update_files(src_folder, dest_folder, updater_name):
def copy_update_files(src_folder: Union[str, Path], dest_folder: Union[str, Path], updater_name: str) -> None:
for item in os.listdir(src_folder):
if item.lower() == updater_name.lower():
log(f"Skipping updater executable: {item}")
@@ -125,7 +130,7 @@ def copy_update_files(src_folder, dest_folder, updater_name):
log(f"Error copying {s} -> {d}: {e}")
def copy_update_files_darwin(src_folder, dest_folder, updater_name):
def copy_update_files_darwin(src_folder: Union[str, Path], dest_folder: Union[str, Path], updater_name: str) -> None:
updater_name = updater_name + ".app"
@@ -147,19 +152,33 @@ def copy_update_files_darwin(src_folder, dest_folder, updater_name):
log(f"Error copying {s} -> {d}: {e}")
def remove_quarantine(app_path):
def remove_quarantine(app_path: Union[str, Path]) -> bool:
"""Removes the macOS quarantine extended attribute from an application bundle using osascript.
Returns True on success, False on error or cancellation.
"""
clean_path: str = str(app_path)
escaped_path: str = shlex.quote(clean_path)
script = f'''
do shell script "xattr -d -r com.apple.quarantine {shlex.quote(app_path)}" with administrator privileges with prompt "{APP_NAME} needs privileges to finish the update. (1/2)"
do shell script "xattr -d -r com.apple.quarantine {escaped_path}" with administrator privileges with prompt "{APP_NAME} needs privileges to finish the update. (1/2)"
'''
try:
subprocess.run(['osascript', '-e', script], check=True)
subprocess.run(["osascript", "-e", script], check=True)
print("✅ Quarantine attribute removed.")
return True
except subprocess.CalledProcessError as e:
print("❌ Failed to remove quarantine attribute.")
print(e)
return False
def main():
main_exe: str = ""
app_dir: Path = Path()
bundle_dir: Path = Path()
parent_bundle_dir: Path = Path()
try:
log(f"[Updater] sys.argv: {sys.argv}")
@@ -171,10 +190,10 @@ def main():
main_exe = sys.argv[2]
# Interesting naming convention
parent_dir = os.path.dirname(os.path.abspath(main_exe))
pparent_dir = os.path.dirname(parent_dir)
ppparent_dir = os.path.dirname(pparent_dir)
pppparent_dir = os.path.dirname(ppparent_dir)
main_exe_path = Path(main_exe).resolve()
app_dir = main_exe_path.parent
bundle_dir = main_exe_path.parents[2]
parent_bundle_dir = main_exe_path.parents[3]
updater_name = os.path.basename(sys.argv[0])
@@ -183,13 +202,13 @@ def main():
log(f"Main EXE: {main_exe}")
log(f"Updater EXE: {updater_name}")
if PLATFORM_NAME == 'darwin':
log(f"Main App Folder: {ppparent_dir}")
log(f"Main App Folder: {bundle_dir}")
# Kill all instances of main app
kill_all_processes_by_executable(main_exe)
# Wait until main_exe process is fully gone (polling)
for _ in range(20): # wait max 10 seconds
for _ in range(10): # wait max 10 seconds
running = False
for proc in psutil.process_iter(['exe', 'cmdline']):
try:
@@ -215,17 +234,17 @@ def main():
# Delete old version files
if PLATFORM_NAME == 'darwin':
log(f'Attempting to delete {ppparent_dir}')
delete_path(ppparent_dir)
log(f'Attempting to delete {bundle_dir}')
delete_path(str(bundle_dir))
update_folder = os.path.join(sys.argv[1], f"{APP_NAME}-darwin")
copy_update_files_darwin(update_folder, pppparent_dir, updater_name)
copy_update_files_darwin(update_folder, str(parent_bundle_dir), updater_name)
else:
delete_path(main_exe)
wait_for_unlock(os.path.join(parent_dir, "_internal"))
wait_for_unlock(os.path.join(str(app_dir), "_internal"))
# Copy new files excluding the updater itself
copy_update_files(update_folder, parent_dir, updater_name)
copy_update_files(update_folder, str(app_dir), updater_name)
except Exception as e:
log(f"Something went wrong: {e}")
@@ -237,13 +256,13 @@ def main():
log("Added executable bit")
if PLATFORM_NAME == 'darwin':
os.chmod(ppparent_dir, 0o755)
os.chmod(str(bundle_dir), 0o755)
log("Added executable bit")
remove_quarantine(ppparent_dir)
log(f"Removed the quarantine flag on {ppparent_dir}")
subprocess.Popen(['open', ppparent_dir, "--args", "--finish-update"])
remove_quarantine(str(bundle_dir))
log(f"Removed the quarantine flag on {bundle_dir}")
subprocess.Popen(['open', str(bundle_dir), "--args", "--finish-update"])
else:
subprocess.Popen([main_exe, "--finish-update"], cwd=parent_dir)
subprocess.Popen([main_exe, "--finish-update"], cwd=str(app_dir))
log("Relaunched main app.")
except Exception as e:
+3 -7
View File
@@ -1,11 +1,7 @@
src\analysis\participantfoldchannels.py 379
src\analysis\participantfoldchannels.py 157
src\shared\flaresbasewidget.py 1001+
src\window\updateevents.py 193
src\window\updateoptodes.py 59
src\viewerlauncher.py 71
flares_updater.py 83
src\window\updateevents.py 151
flares.py 1001+
main_unit_tests.py 153
main.py 709
project_manager.py 407
updater.py 243
project_manager.py 407
+8 -1
View File
@@ -58,6 +58,13 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
}
DESCRIPTION = """\n1. Group Contrast 2D/3D (plot_2d_3d_contrasts_between_groups)
\nCompares two participant groups' contrast results (e.g. condition-vs-baseline effects) channel-by-channel, fitting a mixed-effects model with group, channel, and chromophore as factors. Produces BOTH directions of the contrast (Group A minus Group B, and Group B minus Group A) as separate plots, so the sign convention is explicit either way you read it.
\nis_3d controls the display: True renders a 3D weighted brain map per contrast direction (same rendering as intra method 1, but showing the between-group difference rather than a single group's estimate); False renders a 2D topographic map instead, which is faster and sometimes easier to read at a glance for a whole-head pattern.
\nA channel is only included if BOTH groups have at least min_participants_per_group (default 2) contributing participants for that channel - channels present in only one group, or with too few participants in either group to estimate within-group variance, are dropped before fitting. If this drops too many channels, check that both groups have enough participants with usable data for the selected event/channels.
\nAs with other mixed-effects models in this app, small participant counts can produce convergence warnings; when that happens, the model falls back to pooled OLS, which does not account for the repeated-measures structure of the data and may understate uncertainty - treat results run this way with extra caution.
"""
class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget):
def __init__(
@@ -77,7 +84,7 @@ class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget):
self.contrast_results_dict = contrast_results_dict
self.group_dict = group_dict
self.setup_inter_group_ui(["0 (Contrast Image)"])
self.setup_inter_group_ui(["0 (Group Contrast 2D/3D)"], placeholder_text=DESCRIPTION)
def process_request(self):
+10 -44
View File
@@ -39,20 +39,6 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
}
],
1: [
{
"key": "p_value",
"label": "Significance threshold P-value (e.g. 0.05)",
"default": "0.05",
"type": float,
},
{
"key": "graph_bounds",
"label": "Graph Upper/Lower Limit",
"default": "3.0",
"type": float,
}
],
2: [
{
"key": "show_optodes",
"label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.",
@@ -81,6 +67,15 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
}
DESCRIPTION = """0. FIR Model Results (plot_fir_model_results)
\nCURRENTLY NON-FUNCTIONAL. This method requires per-FIR-delay Condition rows (e.g. "Tapping_delay_3") to plot the shape of the evoked response over time. The dataframe it receives (df_ind_dict) has already had delay information collapsed away upstream in generate_roi_results, regardless of HRF model setting - so this will always fail with an empty-data error. Needs an uncollapsed, per-delay ROI dataframe threaded through separately before it can work again.
\n1. Brain Activity Visualization (brain_3d_visualization)
\nRenders a single group's (or single participant's) channel-level GLM estimates (t or theta values) as a 3D weighted brain map. Fits a mixed-effects model across participants (falling back to OLS for a single participant) to get one estimate per channel, then displays it on a template brain surface with optional optode/sensor overlay.
\nUses collapsed (non-FIR-delay) condition data - shows the overall magnitude of the response per channel, not its time course. Geometry for multi-participant views is averaged across participants' actual optode positions where available; channels or optodes missing valid 3D coordinates for every participant are silently excluded from the map.
"""
class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
def __init__(
self,
@@ -101,7 +96,7 @@ class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
self.contrast_results_dict = contrast_results_dict
self.group_dict = group_dict
self.setup_intra_group_ui(["0 (GLM Results)", "1 (Significance)", "2 (Brain Activity Visualization)",])
self.setup_intra_group_ui(["0 (GLM Results)", "1 (Brain Activity Visualization)"], placeholder_text=DESCRIPTION)
def process_request(self):
@@ -162,35 +157,9 @@ class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
print(f"Missing parameters for index {idx}, skipping.")
continue
plot_fir_model_results(df_group, p_haemo, p_design_matrix, selected_event, lower_bound, upper_bound)
elif idx == 1:
params = param_values.get(idx, {})
p_val = params.get("p_value", None)
graph_bounds = params.get("graph_bounds", None)
if p_val is None or graph_bounds is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
all_contrasts: list[DataFrame] = []
for fp in selected_file_paths:
condition_dfs = self.contrast_results_dict.get(fp, {})
if selected_event in condition_dfs:
df = condition_dfs[selected_event].copy()
df["ID"] = fp
all_contrasts.append(df)
if not all_contrasts:
print("No contrast data found for selected participants and event.")
return
# TODO: look at intergroupstats and figure out what to do
_ = pd.concat(all_contrasts, ignore_index=True)
#flares.run_second_level_analysis(df_contrasts, p_haemo, p_val, graph_bounds)
elif idx == 2:
params = param_values.get(idx, {})
show_optodes = params.get("show_optodes", None)
t_or_theta = params.get("t_or_theta", None)
@@ -213,8 +182,5 @@ class IntraGroupBrainImageWidget(IntraGroupUIMixin, FlaresBaseWidget):
brain_3d_visualization(processed_raw, all_cha, selected_event, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds)
elif idx == 3:
pass
else:
print(f"No method defined for index {idx}")
@@ -14,70 +14,122 @@ from typing import Any, cast
# External library imports
from PySide6.QtWidgets import QMessageBox
from mne import Epochs
from mne.io.base import BaseRaw
from flares import run_group_functional_connectivity
from flares import run_group_functional_connectivity_betas, run_group_functional_connectivity_epochs
from src.shared.flaresbasewidget import IntraGroupUIMixin, FlaresBaseWidget
from src.shared.shareddata import APP_NAME
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
0: [
{
"key": "n_lines",
"label": "<Description>",
"default": "20",
"type": int,
},
{
"key": "vmin",
"label": "<Description>",
"default": "0.9",
"type": float,
},
0: [ # Beta-Series Correlation
{"key": "n_lines", "label": "Number of strongest connections to draw", "default": "20", "type": int},
{"key": "vmin", "label": "Minimum |r| to display (group average)", "default": "0.5", "type": float},
{"key": "drift_model", "label": "Drift model", "default": "cosine", "type": list, "options": ["cosine", "polynomial"]},
{"key": "drift_order", "label": "Drift order", "default": "1", "type": int},
{"key": "hrf_model", "label": "HRF model", "default": "glover", "type": list, "options": ["glover", "spm", "fir"]},
{"key": "apply_gsr", "label": "Apply Global Signal Regression", "default": "True", "type": bool},
{"key": "resample_freq", "label": "Resample rate before GLM fit (Hz) - lower is much faster", "default": "4.0", "type": float},
{"key": "alpha", "label": "FDR significance threshold (group-level)", "default": "0.05", "type": float},
{"key": "min_participants", "label": "Minimum participants required to run the group test", "default": "3", "type": int},
],
1: [ # Spectral Coherence
{"key": "method", "label": "Connectivity method", "default": "wpli2_debiased", "type": list, "options": ["coh", "pli", "wpli2_debiased"]},
{"key": "n_lines", "label": "Number of strongest connections to draw", "default": "20", "type": int},
{"key": "vmin", "label": "Minimum |r| to display (group average)", "default": "0.5", "type": float},
{"key": "fmin", "label": "Lower frequency bound (Hz)", "default": "0.04", "type": float},
{"key": "fmax", "label": "Upper frequency bound (Hz)", "default": "0.2", "type": float},
{"key": "alpha", "label": "FDR significance threshold (group-level)", "default": "0.05", "type": float},
{"key": "min_participants", "label": "Minimum participants required to run the group test", "default": "3", "type": int},
],
}
DESCRIPTION = """0. Beta-Series Correlation (run_group_functional_connectivity_betas)
\nFor each selected participant, resamples to resample_freq (default 4 Hz - well above what's needed to resolve trial-level GLM amplitudes, but far cheaper than running the fit at full acquisition rate) and computes trial-level GLM betas per channel, correlating them within-subject WITHOUT thresholding at the individual level. Those raw per-subject correlation matrices are Fisher-Z transformed and combined across the group using a one-sample t-test (against zero) per channel pair, then FDR-corrected (q < alpha) across all pairs. A significant connection means the group, on average, shows consistent trial-evoked co-activation between two channels - not that every individual participant showed it.
\nRequires at least min_participants (default 3, more is stronger) participants with usable data - each needs enough trials of the selected event to compute their own beta series. Participants with channel sets that don't overlap with the rest of the group are excluded from the shared channel set before analysis.
\nWith a small number of participants and many channel pairs, FDR correction is often the limiting factor even when there's a real underlying effect - check the p-value histogram and top-pairs report generated alongside the main plot: a cluster of small (but not FDR-significant) p-values well below what's expected by chance suggests a real but underpowered effect, worth revisiting with more participants, rather than a true null result.
\n1. Spectral Coherence (run_group_functional_connectivity_epochs)
\nFor each selected participant, computes spectral connectivity between HbO channels using the selected method: coherence ('coh'), Phase Lag Index ('pli'), or debiased weighted PLI squared ('wpli2_debiased', default). PLI/wPLI-family methods discount zero-lag contributions to connectivity, making them substantially more robust to shared systemic/vascular signal (which tends to hit multiple channels near-simultaneously) than plain coherence - recommended over 'coh' unless you have a specific reason to want raw coherence. Raw per-subject matrices are combined across the group the same way as the Beta-Series method: Fisher-Z, one-sample t-test per channel pair, FDR correction.
\nfmin must satisfy at least 5 full oscillation cycles within your epoch length (epoch_duration x fmin >= 5) for a reliable estimate - if it doesn't, the analysis will refuse to run with an error stating the minimum viable fmin for your epoch length, rather than silently producing an unreliable result. Shorter epochs require a higher fmin, which moves you out of the classic 0.04-0.2 Hz "low-frequency oscillation" band used in longer resting-state recordings - this is a real trade-off in what the analysis measures, not just a technical constraint.
\nSame minimum-participant, channel-alignment, and underpowered-vs-null-result caveats apply as the Beta-Series method above.
"""
class IntraGroupFunctionalConnectivityWidget(IntraGroupUIMixin, FlaresBaseWidget):
def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
epochs_dict: dict[str, Epochs],
group_dict: dict[str, str],
config_dict: dict[str, dict[str, Any]]
) -> None:
super().__init__("IntraGroupFunctionalConnectivity")
self.setWindowTitle(f"Intra-Group Functional Connectivity Viewer [BETA] - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
#self.group_dict = group_dict
self.config_dict = config_dict
self.epochs_dict = epochs_dict
self.group_dict = group_dict
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. "
"By clicking OK, you accept that the images generated may not be factual.")
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in beta. While the results are now almost finalized, the processing is slow and it WILL hang the application for HOURS.")
self.setup_intra_group_ui(["0 (Betas)",])
self.setup_intra_group_ui(["0 (Beta-Series Correlation)", "1 (Spectral Coherence)"], placeholder_text=DESCRIPTION)
def process_request(self):
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
if request is None:
return
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
for idx in selected_indexes:
if idx == 0:
params = param_values.get(idx, {})
n_lines = params.get("n_lines", None)
vmin = params.get("vmin", None)
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
run_group_functional_connectivity(self.haemo_dict, self.config_dict, selected_file_paths, selected_event, 50, 0.5)
for idx in selected_indexes:
params = param_values.get(idx, {})
if idx == 0:
n_lines = params.get("n_lines", 20)
vmin = params.get("vmin", 0.5)
drift_model = params.get("drift_model", "cosine")
drift_order = params.get("drift_order", 1)
hrf_model = params.get("hrf_model", "glover")
apply_gsr = params.get("apply_gsr", True)
resample_freq = params.get("resample_freq", 4.0)
alpha = params.get("alpha", 0.05)
min_participants = params.get("min_participants", 3)
run_group_functional_connectivity_betas(
self.haemo_dict, selected_file_paths, selected_event, n_lines, vmin,
drift_model=drift_model,
drift_order=drift_order,
hrf_model=hrf_model,
apply_gsr=apply_gsr,
resample_freq=resample_freq,
alpha=alpha,
min_participants=min_participants,
)
elif idx == 1:
method = params.get("method", "wpli2_debiased")
n_lines = params.get("n_lines", 20)
vmin = params.get("vmin", 0.5)
fmin = params.get("fmin", 0.04)
fmax = params.get("fmax", 0.2)
alpha = params.get("alpha", 0.05)
min_participants = params.get("min_participants", 3)
run_group_functional_connectivity_epochs(
self.epochs_dict,
selected_file_paths,
event_name=selected_event,
n_lines=n_lines,
vmin=vmin,
fmin=fmin,
method=method,
fmax=fmax,
alpha=alpha,
min_participants=min_participants,
)
else:
print(f"No method defined for index {idx}")
+2 -6
View File
@@ -268,6 +268,7 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
run_roi_second_level_analysis(
df_roi_all=df_filtered,
condition=selected_event,
df_cha_all=all_cha_filtered,
raw_haemo=p_haemo,
p_threshold=p_threshold,
@@ -382,14 +383,9 @@ class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
"(check regions.json channel names against this montage).")
continue
# TODO: Come back to this
# df_cha_all intentionally omitted (None): the topography
# section of run_roi_second_level_analysis expects
# single-condition Condition values in df_cha_all, which
# doesn't semantically match a contrast name - skip it here
# rather than pass mismatched data.
run_roi_second_level_analysis(
df_roi_all=roi_theta,
condition=contrast_name,
df_cha_all=None,
raw_haemo=p_haemo,
p_threshold=p_threshold,
+82 -92
View File
@@ -6,11 +6,16 @@ Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in Imports
import os
from pathlib import Path
import time
import traceback
from multiprocessing import Process, current_process, Manager
from typing import Any, Dict, List, Optional, Tuple, Union
# External library imports
from matplotlib.backend_bases import Event
import numpy as np
import matplotlib.pyplot as plt
@@ -18,25 +23,27 @@ import matplotlib.image as mpimg
from matplotlib.figure import Figure
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from PySide6.QtWidgets import QFrame, QGridLayout, QHBoxLayout, QLabel, QProgressBar, QPushButton, QScrollArea, QSizePolicy, QWidget, QDialog, QVBoxLayout
from PySide6.QtCore import QThread, Qt, QSize, QTimer
from PySide6.QtGui import QPixmap, QImage
from PySide6.QtWidgets import QFrame, QGridLayout, QHBoxLayout, QLabel, QLayout, QProgressBar, QPushButton, QScrollArea, QSizePolicy, QWidget, QDialog, QVBoxLayout
from PySide6.QtCore import QThread, Qt, QSize, QTimer, QObject, Signal
from PySide6.QtGui import QCloseEvent, QMouseEvent, QPixmap, QImage
from pandas import DataFrame
from mne.io.base import BaseRaw
from src.shared.flaresbasewidget import FlaresBaseWidget
from src.shared.shareddata import APP_NAME, resource_path
class MultiProgressDialog(QDialog):
def __init__(self, parent=None):
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.setWindowTitle("fOLD Analysis Progress")
self.setFixedWidth(400)
self.setWindowModality(Qt.WindowModality.NonModal)
self.layout = QVBoxLayout(self)
self.bars = {}
self.main_layout = QVBoxLayout(self)
self.bars: Dict[str, QProgressBar] = {}
self.allow_closing = False
def add_participant(self, label, total_steps):
def add_participant(self, label: Any, total_steps: Union[int, float, str]) -> None:
clean_key = str(label).strip()
label_widget = QLabel(f"Analyzing {clean_key}...")
pbar = QProgressBar()
@@ -44,16 +51,17 @@ class MultiProgressDialog(QDialog):
pbar.setMaximum(int(total_steps)) # Ensure this is a strict integer
pbar.setValue(0)
self.layout.addWidget(label_widget)
self.layout.addWidget(pbar)
self.bars[label] = pbar
self.main_layout.addWidget(label_widget)
self.main_layout.addWidget(pbar)
self.bars[clean_key] = pbar
def update_bar(self, label, value):
if label in self.bars:
def update_bar(self, label: Any, value: Union[int, float, str]) -> None:
clean_key = str(label).strip()
if clean_key in self.bars:
# Force integers to prevent QProgressBar from breaking or flickering
self.bars[label].setValue(int(value))
self.bars[clean_key].setValue(int(value))
def closeEvent(self, event):
def closeEvent(self, event: QCloseEvent) -> None:
if self.allow_closing:
event.accept()
else:
@@ -64,8 +72,13 @@ class MultiProgressDialog(QDialog):
self.close()
def single_participant_worker(
file_path: str,
raw_data: Any,
result_queue: Any,
progress_queue: Any,
) -> None:
def single_participant_worker(file_path, raw_data, result_queue, progress_queue):
""" Runs inside its own dedicated process """
p_name = os.path.basename(file_path)
try:
@@ -81,8 +94,7 @@ def single_participant_worker(file_path, raw_data, result_queue, progress_queue)
progress_queue.put(f"ERROR: {p_name} - {str(e)}")
def get_landmark_color_map():
def get_landmark_color_map() -> Dict[str, Tuple[float, float, float, float]]:
"""Generates the unified 40-color map for fOLD landmarks."""
landmarks = [
"1 - Primary Somatosensory Cortex", "2 - Primary Somatosensory Cortex",
@@ -116,7 +128,15 @@ class StaticChannelCanvas(FigureCanvas):
"""The Pop-up Window Canvas.
Renders the interactive pie chart on the left, and a matching PNG image on the right.
"""
def __init__(self, channel_name, data_list, color_map, image_path=None, parent=None):
def __init__(
self,
channel_name: str,
data_list: List[Dict[str, Any]],
color_map: Dict[str, Union[str, Tuple[float, float, float, float]]],
image_path: Optional[str] = None,
parent: Optional[QWidget] = None,
) -> None:
self.fig = Figure(figsize=(11.0, 5.5))
self.ax = self.fig.subplots(1, 2)
@@ -194,7 +214,7 @@ class StaticChannelCanvas(FigureCanvas):
self.mpl_connect('motion_notify_event', self._on_hover)
def _on_hover(self, event):
def _on_hover(self, event: Event) -> None:
try:
# FIX: Only track mouse events when hovering over the LEFT axis frame containing the pie chart
if event.inaxes != self.ax[0]:
@@ -231,10 +251,10 @@ class StaticChannelCanvas(FigureCanvas):
self.draw_idle()
except Exception as err:
print("[ERROR] Internal failure inside _on_hover loop:")
print(f"[ERROR] Internal failure inside _on_hover loop: {err}")
traceback.print_exc()
def _explode_wedge(self, index_to_expand):
def _explode_wedge(self, index_to_expand: int) -> None:
changed = False
for idx, wedge in enumerate(self.wedges):
if idx == index_to_expand:
@@ -252,7 +272,7 @@ class StaticChannelCanvas(FigureCanvas):
if changed:
self.draw_idle()
def _reset_wedges(self):
def _reset_wedges(self) -> None:
changed = False
for wedge in self.wedges:
if wedge.center != (0.0, 0.0):
@@ -274,7 +294,7 @@ class StandaloneLegendDialog(QWidget):
layout.setContentsMargins(10, 10, 10, 10)
# Reuse your exact card creation method to render inside the popup window
legend_card = canvas_engine.create_legend_card(title_prefix, self)
legend_card = canvas_engine.create_legend_card(title_prefix)
layout.addWidget(legend_card)
@@ -381,7 +401,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
self.mpl_connect('button_press_event', self._on_canvas_click)
def create_matrix_card(self, title_prefix, layout_to_attach_to):
def create_matrix_card(self, title_prefix: str, layout_to_attach_to: QLayout) -> QFrame:
"""Wraps the channel matrix layout inside a responsive, matching hover-stylized card frame."""
# 1. Create matching styled container card frame
card_frame = QFrame()
@@ -422,7 +442,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
layout_to_attach_to.addWidget(card_frame)
return card_frame
def _on_canvas_click(self, event):
def _on_canvas_click(self, event: Any) -> None:
# CASE 1: Whitespace Clicked -> Open full 25-matrix in fullscreen window
if event.inaxes is None:
self._open_fullscreen_grid()
@@ -473,7 +493,8 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
self._fullscreen_refs = [w for w in self._fullscreen_refs if w.isVisible()]
self._fullscreen_refs.append(fullscreen_window)
def _calculate_total_brodmann_profile(self, channels_data):
def _calculate_total_brodmann_profile(self, channels_data: Dict[str, Any]):
"""Sums and normalizes the specificity profile across all channels."""
totals = {}
num_channels = len(channels_data)
@@ -553,7 +574,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
self._open_popups.append(popup)
def create_total_summary_card(self, title_prefix, layout_to_attach_to):
def create_total_summary_card(self, title_prefix: str, layout_to_attach_to: QLayout) -> QFrame:
"""Generates a highly compact, clickable embedded card on the main window showing aggregated data."""
# 1. Calculate the normalized profile data payload using the instance's own data
summary_data = self._calculate_total_brodmann_profile(self.channels_data)
@@ -609,7 +630,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
card_layout.addWidget(summary_canvas)
card_layout.addStretch(0)
def handle_card_click(event):
def handle_card_click(event: QMouseEvent) -> None:
# Only trigger expansion if it's a primary left-click action
if event.button() == Qt.MouseButton.LeftButton:
self._open_expanded_summary_window(title_prefix, summary_data)
@@ -626,7 +647,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
def create_legend_card(self, title_prefix, layout_to_attach_to):
def create_legend_card(self, title_prefix: str) -> QFrame:
card = QFrame()
card.setStyleSheet("QFrame { background-color: #ffffff; border-radius: 8px; border: 1px solid #e9ecef; }")
@@ -686,7 +707,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
return card
def _open_expanded_summary_window(self, title_prefix, summary_data):
def _open_expanded_summary_window(self, title_prefix: str, summary_data: List[Any]) -> None:
"""Pops open a beautifully scaled, independent large window when the card is clicked."""
popup = QWidget(None)
popup.setWindowTitle(f"Grand Total Profile Details - {title_prefix}")
@@ -719,16 +740,18 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
self._summary_popups.append(popup)
from PySide6.QtCore import QObject, Signal
from multiprocessing import Manager, Process
class ProcessOrchestrator(QObject):
# Fires when Manager + Processes are completely ready
# Emits: (manager_instance, result_queue, progress_queue, active_processes_list)
setup_finished = Signal(object, object, object, list)
setup_failed = Signal(str)
def __init__(self, selected_files, haemo_dict, worker_func):
def __init__(self,
selected_files,
haemo_dict: dict[str | Path, BaseRaw],
worker_func
):
super().__init__()
self.selected_files = selected_files
self.haemo_dict = haemo_dict
@@ -758,7 +781,12 @@ class ProcessOrchestrator(QObject):
class ParticipantFoldChannelsWidget(FlaresBaseWidget):
def __init__(self, haemo_dict, cha_dict):
def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
cha_dict: dict[str, DataFrame]
) -> None:
super().__init__("ParticipantFoldChannels")
self.setWindowTitle(f"Participant Fold Channels Viewer - {APP_NAME.upper()}")
self.haemo_dict = haemo_dict
@@ -773,9 +801,9 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.participant_map[file_path] = short_label
self.participant_dropdown_items.append(display_label)
self.layout = QVBoxLayout(self)
self.main_layout = QVBoxLayout(self)
self.top_bar = QHBoxLayout()
self.layout.addLayout(self.top_bar)
self.main_layout.addLayout(self.top_bar)
self.participant_dropdown = self._create_multiselect_dropdown(self.participant_dropdown_items)
self.participant_dropdown.currentIndexChanged.connect(self.update_participant_dropdown_label)
@@ -829,7 +857,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.scroll_area.setWidget(self.scroll_content_widget)
# Add the self.scroll_area widget to your root layout view frame panel
self.layout.addWidget(self.scroll_area)
self.main_layout.addWidget(self.scroll_area)
self.thumb_size = QSize(280, 180)
self.showMaximized()
@@ -889,7 +917,14 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.orchestrator_thread.start()
print(f"After 4: {datetime.now()}")
def on_orchestration_success(self, manager, result_queue, progress_queue, active_processes):
def on_orchestration_success(
self,
manager: Any,
result_queue: Any,
progress_queue: Any,
active_processes: List[Any]
) -> None:
""" Executed on the Main GUI Thread once background process setup finishes """
self.manager = manager
self.result_queue = result_queue
@@ -902,15 +937,15 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.result_timer.timeout.connect(self.check_parallel_results)
self.result_timer.start()
def on_orchestration_failed(self, error_msg):
def on_orchestration_failed(self, error_msg: str) -> None:
""" Fallback handler if Windows permissions or pickling fails in background """
if hasattr(self, 'multi_progress'):
self.multi_progress.close()
print(f"[CRITICAL FAILURE] Background Orchestration Failed:\n{error_msg}")
def check_parallel_results(self):
def check_parallel_results(self) -> None:
# Check for progress/completion signals
while not self.progress_queue.empty():
@@ -991,8 +1026,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
legend_title = "Grand Total Brodmann Mapping Profile"
legend_card = global_canvas.create_legend_card(
title_prefix=legend_title,
layout_to_attach_to=self.scroll_content_widget.layout()
title_prefix=legend_title
)
def handle_legend_click(event):
@@ -1006,52 +1040,8 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
col = count % 3
self.grid_layout.addWidget(legend_card, row, col)
# def add_images_to_grid(self, result_dict):
# """
# result_dict format: { file_path: {"main": bytes, "legend": bytes} }
# """
# for file_path, images in result_dict.items():
# if self.grid_layout.count() == 0 and "legend" in images:
# self._add_legend_to_grid(images["legend"])
# # Create a container for this participant's results
# container = QFrame()
# container.setFrameShape(QFrame.StyledPanel)
# vbox = QVBoxLayout(container)
# participant_label = self.participant_map.get(file_path, os.path.basename(file_path))
# title = QLabel(f"<b>{participant_label}</b>")
# title.setAlignment(Qt.AlignCenter)
# vbox.addWidget(title)
# # We primarily want to show the 'main' plot in the grid
# if "main" in images:
# pixmap = self._bytes_to_pixmap(images["main"])
# img_label = QLabel()
# # Scale it to fit the thumbnail size defined in __init__
# img_label.setPixmap(pixmap.scaled(
# self.thumb_size,
# Qt.KeepAspectRatio,
# Qt.SmoothTransformation
# ))
# img_label.setAlignment(Qt.AlignCenter)
# # Optional: Click to open full size
# img_label.mousePressEvent = lambda e, p=pixmap, t=participant_label: self._open_full_size(p, t)
# vbox.addWidget(img_label)
# # Determine grid position (row-major order)
# count = self.grid_layout.count()
# row = count // 3 # 3 columns wide
# col = count % 3
# self.grid_layout.addWidget(container, row, col)
def add_images_to_grid(self, result_dict):
def add_images_to_grid(self, result_dict: Dict[str, Dict[str, Any]]) -> None:
color_map = get_landmark_color_map()
for file_path, channels_data in result_dict.items():
@@ -1091,12 +1081,12 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.grid_layout.addWidget(summary_card, row, col)
def _bytes_to_pixmap(self, png_bytes):
def _bytes_to_pixmap(self, png_bytes: bytes) -> QPixmap:
"""Converts raw bytes from the multiprocess queue to a QPixmap."""
image = QImage.fromData(png_bytes)
return QPixmap.fromImage(image)
def _open_full_size(self, pixmap, title):
def _open_full_size(self, pixmap: QPixmap, title: str) -> None:
"""Simple popup to view the image at a readable scale."""
view = QDialog(self)
view.setWindowTitle(f"Full View - {title}")
@@ -1106,7 +1096,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
layout.addWidget(label)
view.show()
def _add_legend_to_grid(self, legend_bytes):
def _add_legend_to_grid(self, legend_bytes: bytes) -> None:
"""Helper to put the legend in the first slot."""
container = QFrame()
container.setStyleSheet("background-color: #f9f9f9; border: 1px solid #ccc;")
+106 -101
View File
@@ -23,83 +23,58 @@ from src.shared.shareddata import APP_NAME
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
0: [
{
"key": "n_lines",
"label": "<Description>",
"default": "20",
"type": int,
},
{
"key": "vmin",
"label": "<Description>",
"default": "0.9",
"type": float,
},
0: [ # Spectral Coherence
{"key": "method", "label": "Connectivity method", "default": "wpli2_debiased", "type": list, "options": ["coh", "pli", "wpli2_debiased"]},
{"key": "n_lines", "label": "Number of strongest connections to draw", "default": "20", "type": int},
{"key": "vmin", "label": "Minimum coherence value to display", "default": "0.3", "type": float},
{"key": "fmin", "label": "Lower frequency bound (Hz)", "default": "0.04", "type": float},
{"key": "fmax", "label": "Upper frequency bound (Hz)", "default": "0.2", "type": float},
],
1: [
{
"key": "n_lines",
"label": "<Description>",
"default": "20",
"type": int,
},
{
"key": "vmin",
"label": "<Description>",
"default": "0.9",
"type": float,
},
1: [ # Envelope Correlation
{"key": "n_lines", "label": "Number of strongest connections to draw", "default": "20", "type": int},
{"key": "vmin", "label": "Minimum correlation value to display", "default": "0.9", "type": float},
{"key": "fmin", "label": "Band-pass lower frequency (Hz)", "default": "0.04", "type": float},
{"key": "fmax", "label": "Band-pass upper frequency (Hz)", "default": "0.2", "type": float},
{"key": "orthogonalize", "label": "Orthogonalize (reduce signal leakage between channels)", "default": "False", "type": bool},
{"key": "absolute", "label": "Use absolute value (discard anti-correlation sign)", "default": "True", "type": bool},
],
2: [
{
"key": "n_lines",
"label": "<Description>",
"default": "20",
"type": int,
},
{
"key": "vmin",
"label": "<Description>",
"default": "0.9",
"type": float,
},
2: [ # Beta-Series Correlation
{"key": "n_lines", "label": "Number of strongest connections to draw", "default": "20", "type": int},
{"key": "drift_model", "label": "Drift model", "default": "cosine", "type": list, "options": ["cosine", "polynomial"]},
{"key": "drift_order", "label": "Drift order", "default": "1", "type": int},
{"key": "hrf_model", "label": "HRF model", "default": "glover", "type": list, "options": ["glover", "spm", "fir"]},
{"key": "apply_gsr", "label": "Apply Global Signal Regression", "default": "True", "type": bool},
{"key": "resample_freq", "label": "Resample rate before GLM fit (Hz) - lower is much faster", "default": "4.0", "type": float},
{"key": "min_effect_size", "label": "Minimum |r| to display", "default": "0.7", "type": float},
{"key": "alpha", "label": "FDR significance threshold", "default": "0.05", "type": float},
],
3: [
{
"key": "n_lines",
"label": "<Description>",
"default": "20",
"type": int,
},
{
"key": "vmin",
"label": "<Description>",
"default": "0.9",
"type": float,
},
3: [ # Time-Resolved Spectral Coherence
{"key": "method", "label": "Connectivity method", "default": "wpli", "type": list, "options": ["coh", "pli", "wpli"]},
{"key": "n_lines", "label": "Number of strongest connections to draw", "default": "20", "type": int},
{"key": "vmin", "label": "Minimum coherence value to display", "default": "0.3", "type": float},
{"key": "fmin", "label": "Lower frequency bound (Hz)", "default": "0.04", "type": float},
{"key": "fmax", "label": "Upper frequency bound (Hz)", "default": "0.2", "type": float},
{"key": "n_freqs", "label": "Number of frequency bins", "default": "10", "type": int},
{"key": "cycles_multiplier", "label": "Cycles per frequency (window length control)", "default": "2.0", "type": float},
],
}
DESCRIPTION = """0. Spectral Coherence (functional_connectivity_spectral_epochs)
\nTests for frequency-domain phase synchronization between channel pairs in a specific oscillatory band (0.04-0.2 Hz) across epoched data using multitaper spectral estimation. A significant connection means two brain regions share consistent, synchronized oscillatory phase dynamics across epochs, reflecting steady-state functional coupling. It does not tell you when during the epoch the interaction occurred (as time is integrated out), nor does it guarantee the interaction is neural, as shared systemic vascular oscillations or motion artifacts can drive spurious high coherence across distant sensors.
\nIf connectivity appears lower or sparser than expected, common causes include: non-stationarity within the epoch (phase relationships that shift rapidly over time cancel out when averaged across the whole window); trial-to-trial timing jitter; or applying overly stringent edge thresholding or FDR correction across all unique channel pairs.
\nTests for connectivity between channel pairs using the selected method: coherence ('coh'), Phase Lag Index ('pli'), or debiased weighted PLI squared ('wpli2_debiased', default). PLI/wPLI-family methods discount zero-lag contributions, making them substantially more robust to shared systemic/vascular signal (which tends to hit multiple channels near-simultaneously) than plain coherence - recommended over 'coh' unless you have a specific reason to want raw coherence.
\nfmin must satisfy at least 5 full oscillation cycles within your epoch length (epoch_duration x fmin >= 5) for a reliable estimate - if it doesn't, this will refuse to run with an error stating the minimum viable fmin for your epoch length, rather than silently producing an unreliable result. Note that different methods have very different typical value ranges (coherence commonly 0.3-1.0; wPLI/wPLI2-debiased often much lower, sometimes 0.1-0.4) - vmin needs to be recalibrated when switching methods, or real connections may not render.
\n1. Envelope Correlation (functional_connectivity_envelope)
\nExtracts the Hilbert amplitude envelope from bandpass-filtered signals (0.04-0.2 Hz) to measure slow amplitude power correlations across time within epoched data. A significant result indicates that the overall energy profiles or activation magnitudes of two regions co-vary over time, independent of sub-second phase locking. It says nothing about fast phase interactions or exact event-locked timing, and its power is heavily degraded if epoch lengths are too short (under ~10-15s) to capture multiple complete cycles of low-frequency hemodynamic fluctuations.
\nIf this method underperforms compared to phase-based coherence, the most likely explanation is that your trial window is too brief for robust envelope extraction, or that the functional coupling between regions is purely phase-locked rather than power-coupled. Additionally, uncorrected global motion or systemic arterial pressure shifts can globally inflate envelope correlations across the whole head.
\nExtracts the Hilbert amplitude envelope from bandpass-filtered signals to measure slow amplitude power correlations across time within epoched data. A significant result indicates that the overall energy profiles or activation magnitudes of two regions co-vary over time, independent of sub-second phase locking. Its power is heavily degraded if epoch lengths are too short to capture multiple complete cycles at fmin - same cycle-count requirement as the Spectral Coherence method above, though this method does not currently enforce it automatically.
\nUncorrected global motion or systemic arterial pressure shifts can globally inflate envelope correlations across the whole head - consider this alongside orthogonalize/absolute when interpreting results.
\n2. Time-Resolved Spectral Coherence (functional_connectivity_spectral_time)
\nUses continuous Morlet wavelet time-frequency decomposition across multiple frequencies (0.04-0.2 Hz) to track how spectral coherence between channel pairs dynamically evolves over the duration of a trial. A significant result pinpoints the exact temporal window within a trial where functional coupling emerges or dissolves (e.g., during stimulus encoding vs. motor execution). It demands precise, jitter-free stimulus onset triggers and carries high computational complexity; it is also susceptible to wavelet edge artifacts at the start and end of epoch windows.
\nIf expected temporal connectivity changes fail to emerge, check whether trial-to-trial onset latency variability across subjects is smearing the time-resolved average, or if the chosen wavelet cycle parameter (n_cycles) is oversmoothing short-lived, transient phase-coupling events.
\n2. Beta-Series Correlation (functional_connectivity_betas)
\nFits a GLM to estimate trial-by-trial activation magnitudes (betas), optionally applies Global Signal Regression (GSR) to strip head-wide systemic noise, and correlates those beta series across trials with FDR (q < alpha) and effect-size thresholding. A significant connection means that when Region A responds more strongly on a given trial, Region B also responds more strongly. Not subject to the epoch-length/frequency-resolution constraint that affects the spectral methods above, since no spectral estimation is involved.
\nRequires at least 4 (ideally 15+) repeated trials of the selected event. hrf_model='fir' is far more computationally expensive than 'glover'/'spm' (a separate regressor column per FIR delay per trial) - if this method is slow to the point of appearing frozen, check hrf_model is not set to 'fir' before assuming something is broken.
\n3. Beta-Series Correlation (functional_connectivity_betas)
\nFits a General Linear Model (GLM) using a flexible Finite Impulse Response (FIR) basis set to estimate trial-by-trial activation magnitudes (betas), optionally applies Global Signal Regression (GSR) to strip head-wide systemic noise, and correlates those beta series across events with FDR (q < alpha) and effect-size thresholding. A significant connection means that when Region A responds more strongly on a given trial, Region B also responds more strongly, isolating true task-evoked co-activation from background resting-state noise. It requires at least 4 (ideally 15+) repeated trials per condition to establish degrees of freedom for the correlation t-test, and relies heavily on a correctly specified trial annotation structure.
\nIf this test returns no significant edges, the primary culprit is typically insufficient trial count (leading to severely underpowered degrees of freedom), poorly separated trials that induce severe multicollinearity in the FIR design matrix, or applying GSR when the underlying neural effect itself is diffuse, causing true network correlations to be over-regressed.
\n3. Time-Resolved Spectral Coherence (functional_connectivity_spectral_time)
\nSame connectivity methods as Spectral Coherence above ('coh'/'pli'/'wpli' - note: 'wpli2_debiased' is NOT available for this method, unlike the epochs-based one), but tracks how connectivity evolves over multiple frequency bins across the trial duration rather than a single averaged value. Same fmin/epoch-length cycle-count requirement as method 0 applies and is enforced the same way.
\nMore computationally expensive than method 0 due to the additional frequency/time resolution - if timing matters, prefer method 0 unless the time-resolved view is specifically needed.
"""
@@ -115,11 +90,12 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg
self.haemo_dict = haemo_dict
self.epochs_dict = epochs_dict
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in development and the results should currently be taken with a grain of salt. "
"By clicking OK, you accept that the images generated may not be factual.")
self.setup_participant_ui(["0 (Spectral Connectivity Epochs)", "1 (Envelope Correlation)", "2 (Betas)", "3 (Spectral Connectivity Epochs)"], placeholder_text=DESCRIPTION)
QMessageBox.warning(self, f"Warning - {APP_NAME.upper()}", f"Functional Connectivity is still in beta. While the results are now almost finalized, the processing is slow and it WILL hang the application for minutes.")
self.setup_participant_ui(
["0 (Spectral Coherence)", "1 (Envelope Correlation)", "2 (Beta-Series Correlation)", "3 (Time-Resolved Spectral Coherence)"],
placeholder_text=DESCRIPTION
)
def process_request(self):
request = self.get_common_request_data(PARAMETERIZED_INDEXES)
@@ -134,7 +110,7 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg
for file_path in selected_file_paths:
haemo_obj = self.haemo_dict.get(file_path)
epochs_obj = self.epochs_dict.get(file_path)
if haemo_obj is None or epochs_obj is None:
continue
@@ -153,46 +129,75 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg
continue
for idx in selected_indexes:
params = param_values.get(idx, {})
if idx == 0:
params = param_values.get(idx, {})
n_lines = params.get("n_lines", None)
vmin = params.get("vmin", None)
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
functional_connectivity_spectral_epochs(epochs_obj, n_lines, vmin)
method = params.get("method", "wpli2_debiased")
n_lines = params.get("n_lines", 20)
vmin = params.get("vmin", 0.9)
fmin = params.get("fmin", 0.04)
fmax = params.get("fmax", 0.2)
functional_connectivity_spectral_epochs(epochs=epochs_obj, n_lines=n_lines, vmin=vmin, fmin=fmin, fmax=fmax, method=method)
elif idx == 1:
params = param_values.get(idx, {})
n_lines = params.get("n_lines", None)
vmin = params.get("vmin", None)
n_lines = params.get("n_lines", 20)
vmin = params.get("vmin", 0.9)
fmin = params.get("fmin", 0.04)
fmax = params.get("fmax", 0.2)
orthogonalize = params.get("orthogonalize", False)
absolute = params.get("absolute", True)
functional_connectivity_envelope(
epochs=epochs_obj,
n_lines=n_lines,
vmin=vmin, fmin=fmin,
fmax=fmax,
orthogonalize=orthogonalize,
absolute=absolute,
)
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
functional_connectivity_envelope(epochs_obj, n_lines, vmin)
elif idx == 2:
params = param_values.get(idx, {})
n_lines = params.get("n_lines", None)
vmin = params.get("vmin", None)
n_lines = params.get("n_lines", 20)
drift_model = params.get("drift_model", "cosine")
drift_order = params.get("drift_order", 1)
hrf_model = params.get("hrf_model", "glover")
apply_gsr = params.get("apply_gsr", True)
min_effect_size = params.get("min_effect_size", 0.7)
alpha = params.get("alpha", 0.05)
resample_freq = params.get("resample_freq", 4.0)
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
functional_connectivity_betas(haemo_obj, n_lines, vmin, selected_event)
functional_connectivity_betas(
raw_hbo=haemo_obj,
n_lines=n_lines,
event_name=selected_event,
drift_model=drift_model,
drift_order=drift_order,
hrf_model=hrf_model,
apply_gsr=apply_gsr,
min_effect_size=min_effect_size,
alpha=alpha,
resample_freq=resample_freq,
)
elif idx == 3:
params = param_values.get(idx, {})
n_lines = params.get("n_lines", None)
vmin = params.get("vmin", None)
method = params.get("method", "wpli")
n_lines = params.get("n_lines", 20)
vmin = params.get("vmin", 0.9)
fmin = params.get("fmin", 0.04)
fmax = params.get("fmax", 0.2)
n_freqs = params.get("n_freqs", 10)
cycles_multiplier = params.get("cycles_multiplier", 2.0)
if n_lines is None or vmin is None:
print(f"Missing parameters for index {idx}, skipping.")
continue
functional_connectivity_spectral_time(epochs_obj, n_lines, vmin)
functional_connectivity_spectral_time(
epochs=epochs_obj,
n_lines=n_lines,
vmin=vmin,
fmin=fmin,
fmax=fmax,
n_freqs=n_freqs,
cycles_multiplier=cycles_multiplier,
method=method
)
else:
print(f"No method defined for index {idx}")
+26 -23
View File
@@ -6,19 +6,22 @@ Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
import os
import json
from enum import Enum, auto
from datetime import datetime
from typing import Optional
# External library imports
import numpy as np
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QLineEdit, QPushButton, QComboBox, QHBoxLayout, QMessageBox, QFileDialog
from PySide6.QtCore import Qt
from mne import Annotations
from mne.io import read_raw_snirf
from mne_nirs.io import write_raw_snirf
from mne.io import read_raw_snirf #type: ignore
from mne_nirs.io import write_raw_snirf #type: ignore
from src.shared.shareddata import APP_NAME
@@ -29,7 +32,7 @@ class EventUpdateMode(Enum):
class UpdateEventsWindow(QWidget):
def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
def __init__(self, parent: Optional[QWidget]=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
super().__init__(parent, Qt.WindowType.Window)
self.mode = mode
@@ -91,7 +94,7 @@ class UpdateEventsWindow(QWidget):
help_btn_a = QPushButton("?")
help_btn_a.setFixedWidth(25)
help_btn_a.setToolTip(help_text_a)
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
help_btn_a.clicked.connect(lambda: self.show_help_popup(help_text_a))
file_a_layout.addWidget(help_btn_a)
# Container for label + line_edit + browse button with tooltip
@@ -114,7 +117,7 @@ class UpdateEventsWindow(QWidget):
help_btn_b = QPushButton("?")
help_btn_b.setFixedWidth(25)
help_btn_b.setToolTip(help_text_b)
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
help_btn_b.clicked.connect(lambda: self.show_help_popup(help_text_b))
file_b_layout.addWidget(help_btn_b)
file_b_container = QWidget()
@@ -136,7 +139,7 @@ class UpdateEventsWindow(QWidget):
help_btn_suffix = QPushButton("?")
help_btn_suffix.setFixedWidth(25)
help_btn_suffix.setToolTip(help_text_suffix)
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
help_btn_suffix.clicked.connect(lambda: self.show_help_popup(help_text_suffix))
suffix_layout.addWidget(help_btn_suffix)
suffix_container = QWidget()
@@ -157,7 +160,7 @@ class UpdateEventsWindow(QWidget):
help_btn_suffix = QPushButton("?")
help_btn_suffix.setFixedWidth(25)
help_btn_suffix.setToolTip(help_text_suffix)
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
help_btn_suffix.clicked.connect(lambda: self.show_help_popup(help_text_suffix))
suffix2_layout.addWidget(help_btn_suffix)
suffix2_container = QWidget()
@@ -177,7 +180,7 @@ class UpdateEventsWindow(QWidget):
help_btn_snirf_events = QPushButton("?")
help_btn_snirf_events.setFixedWidth(25)
help_btn_snirf_events.setToolTip(help_text_snirf_events)
help_btn_snirf_events.clicked.connect(lambda _, text=help_text_snirf_events: self.show_help_popup(text))
help_btn_snirf_events.clicked.connect(lambda: self.show_help_popup(help_text_snirf_events))
snirf_events_layout.addWidget(help_btn_snirf_events)
snirf_events_container = QWidget()
@@ -199,13 +202,13 @@ class UpdateEventsWindow(QWidget):
self.setLayout(layout)
def show_help_popup(self, text):
def show_help_popup(self, text: str) -> None:
msg = QMessageBox(self)
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
msg.setText(text)
msg.exec()
def browse_file_a(self):
def browse_file_a(self) -> None:
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
if file_path:
self.line_edit_file_a.setText(file_path)
@@ -235,7 +238,7 @@ class UpdateEventsWindow(QWidget):
self.combo_snirf_events.clear()
self.combo_snirf_events.setEnabled(False)
def browse_file_b(self):
def browse_file_b(self) -> None:
file_path, _ = QFileDialog.getOpenFileName(self, "Select BORIS File", "", "BORIS project Files (*.boris)")
if file_path:
self.line_edit_file_b.setText(file_path)
@@ -288,11 +291,11 @@ class UpdateEventsWindow(QWidget):
self.combo_events.addItems(event_entries)
self.combo_events.setEnabled(bool(event_entries))
def clear_files(self):
def clear_files(self) -> None:
self.line_edit_file_a.clear()
self.line_edit_file_b.clear()
def go_action(self):
def go_action(self) -> None:
file_a = self.line_edit_file_a.text()
suffix = "flare"
@@ -540,7 +543,7 @@ class UpdateEventsWindow(QWidget):
class UpdateEventsBlazesWindow(QWidget):
def __init__(self, parent=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
def __init__(self, parent: Optional[QWidget]=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
super().__init__(parent, Qt.WindowType.Window)
self.mode = mode
@@ -595,7 +598,7 @@ class UpdateEventsBlazesWindow(QWidget):
help_btn_a = QPushButton("?")
help_btn_a.setFixedWidth(25)
help_btn_a.setToolTip(help_text_a)
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
help_btn_a.clicked.connect(lambda: self.show_help_popup(help_text_a))
file_a_layout.addWidget(help_btn_a)
# Container for label + line_edit + browse button with tooltip
@@ -618,7 +621,7 @@ class UpdateEventsBlazesWindow(QWidget):
help_btn_b = QPushButton("?")
help_btn_b.setFixedWidth(25)
help_btn_b.setToolTip(help_text_b)
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
help_btn_b.clicked.connect(lambda: self.show_help_popup(help_text_b))
file_b_layout.addWidget(help_btn_b)
file_b_container = QWidget()
@@ -640,7 +643,7 @@ class UpdateEventsBlazesWindow(QWidget):
help_btn_suffix = QPushButton("?")
help_btn_suffix.setFixedWidth(25)
help_btn_suffix.setToolTip(help_text_suffix)
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
help_btn_suffix.clicked.connect(lambda: self.show_help_popup(help_text_suffix))
suffix2_layout.addWidget(help_btn_suffix)
suffix2_container = QWidget()
@@ -660,7 +663,7 @@ class UpdateEventsBlazesWindow(QWidget):
help_btn_snirf_events = QPushButton("?")
help_btn_snirf_events.setFixedWidth(25)
help_btn_snirf_events.setToolTip(help_text_snirf_events)
help_btn_snirf_events.clicked.connect(lambda _, text=help_text_snirf_events: self.show_help_popup(text))
help_btn_snirf_events.clicked.connect(lambda: self.show_help_popup(help_text_snirf_events))
snirf_events_layout.addWidget(help_btn_snirf_events)
snirf_events_container = QWidget()
@@ -683,13 +686,13 @@ class UpdateEventsBlazesWindow(QWidget):
self.setLayout(layout)
def show_help_popup(self, text):
def show_help_popup(self, text: str) -> None:
msg = QMessageBox(self)
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
msg.setText(text)
msg.exec()
def browse_file_a(self):
def browse_file_a(self) -> None:
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
if file_path:
self.line_edit_file_a.setText(file_path)
@@ -719,7 +722,7 @@ class UpdateEventsBlazesWindow(QWidget):
self.combo_snirf_events.clear()
self.combo_snirf_events.setEnabled(False)
def browse_file_b(self):
def browse_file_b(self) -> None:
file_path, _ = QFileDialog.getOpenFileName(self, "Select JSON Timeline File", "", "JSON Files (*.json)")
if file_path:
self.line_edit_file_b.setText(file_path)
@@ -762,12 +765,12 @@ class UpdateEventsBlazesWindow(QWidget):
return event_strings
def clear_files(self):
def clear_files(self) -> None:
self.line_edit_file_a.clear()
self.line_edit_file_b.clear()
def go_action(self):
def go_action(self) -> None:
file_a = self.line_edit_file_a.text()
file_b = self.line_edit_file_b.text()
suffix = APP_NAME
+33 -18
View File
@@ -1,16 +1,21 @@
"""
Filename: updateoptodes.py
Description: Methods to update optode locations for FLARES
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
import os
from pathlib import Path
from typing import Dict, Optional, Union
# External library imports
import pandas as pd
import numpy as np
import numpy.typing as npt
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QMessageBox, QLineEdit, QPushButton, QFileDialog
from PySide6.QtCore import Qt
@@ -24,7 +29,7 @@ from src.shared.shareddata import APP_NAME
class UpdateOptodesWindow(QWidget):
def __init__(self, parent=None):
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle(f"Update optode positions - {APP_NAME.upper()}")
self.resize(760, 200)
@@ -50,7 +55,6 @@ class UpdateOptodesWindow(QWidget):
self.btn_clear.clicked.connect(self.clear_files)
self.btn_go.clicked.connect(self.go_action)
# ---
layout = QVBoxLayout()
self.description = QLabel()
self.description.setTextFormat(Qt.TextFormat.RichText)
@@ -75,7 +79,7 @@ class UpdateOptodesWindow(QWidget):
help_btn_a = QPushButton("?")
help_btn_a.setFixedWidth(25)
help_btn_a.setToolTip(help_text_a)
help_btn_a.clicked.connect(lambda _, text=help_text_a: self.show_help_popup(text))
help_btn_a.clicked.connect(lambda: self.show_help_popup(help_text_a))
file_a_layout.addWidget(help_btn_a)
# Container for label + line_edit + browse button with tooltip
@@ -98,7 +102,7 @@ class UpdateOptodesWindow(QWidget):
help_btn_b = QPushButton("?")
help_btn_b.setFixedWidth(25)
help_btn_b.setToolTip(help_text_b)
help_btn_b.clicked.connect(lambda _, text=help_text_b: self.show_help_popup(text))
help_btn_b.clicked.connect(lambda: self.show_help_popup(help_text_b))
file_b_layout.addWidget(help_btn_b)
file_b_container = QWidget()
@@ -121,7 +125,7 @@ class UpdateOptodesWindow(QWidget):
help_btn_suffix = QPushButton("?")
help_btn_suffix.setFixedWidth(25)
help_btn_suffix.setToolTip(help_text_suffix)
help_btn_suffix.clicked.connect(lambda _, text=help_text_suffix: self.show_help_popup(text))
help_btn_suffix.clicked.connect(lambda: self.show_help_popup(help_text_suffix))
suffix_layout.addWidget(help_btn_suffix)
suffix_container = QWidget()
@@ -143,13 +147,13 @@ class UpdateOptodesWindow(QWidget):
self.setLayout(layout)
def show_help_popup(self, text):
def show_help_popup(self, text: str) -> None:
msg = QMessageBox(self)
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
msg.setText(text)
msg.exec()
def handle_link_click(self, link):
def handle_link_click(self, link: str) -> None:
if link == "custom_link":
msg = QMessageBox(self)
msg.setWindowTitle("Example Digitization File")
@@ -166,21 +170,21 @@ class UpdateOptodesWindow(QWidget):
msg.setText(text)
msg.exec()
def browse_file_a(self):
def browse_file_a(self) -> None:
file_path, _ = QFileDialog.getOpenFileName(self, "Select SNIRF File", "", "SNIRF Files (*.snirf)")
if file_path:
self.line_edit_file_a.setText(file_path)
def browse_file_b(self):
def browse_file_b(self) -> None:
file_path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "Supported Files (*.txt *.xlsx)")
if file_path:
self.line_edit_file_b.setText(file_path)
def clear_files(self):
def clear_files(self) -> None:
self.line_edit_file_a.clear()
self.line_edit_file_b.clear()
def go_action(self):
def go_action(self) -> None:
file_a = self.line_edit_file_a.text()
file_b = self.line_edit_file_b.text()
suffix = self.line_edit_suffix.text().strip() or "flare"
@@ -220,7 +224,12 @@ class UpdateOptodesWindow(QWidget):
QMessageBox.information(self, "File Saved", f"File was saved to:\n{save_path}")
def update_optode_positions(self, file_a, file_b, save_path):
def update_optode_positions(
self,
file_a: Union[str, Path],
file_b: Union[str, Path],
save_path: Union[str, Path]
) -> None:
fiducials = {}
ch_positions = {}
@@ -247,16 +256,22 @@ class UpdateOptodesWindow(QWidget):
elif extension == '.xlsx':
# TODO: Bad! Why assume sheet1 has the contents?
df = pd.read_excel(file_b, sheet_name='Sheet1')
df = pd.read_excel(file_b, sheet_name='Sheet1') # type: ignore
def _get_block_data(df, block_id, row_mapping, scale=0.001):
def _get_block_data(
target_df: pd.DataFrame,
block_id: int,
row_mapping: Union[Dict[int, str], str],
scale: float = 0.001
) -> Dict[str, npt.NDArray[np.float64]]:
"""Isolates a block, cleans numeric data, and returns a scaled dictionary."""
# 1. Isolate and clean
block = df[df['block_id'] == block_id].iloc[:, [1, 2, 3]].copy()
block = target_df[target_df['block_id'] == block_id].iloc[:, [1, 2, 3]].copy()
block = block.apply(pd.to_numeric, errors='coerce')
# 2. Extract into dictionary based on mapping
result = {}
result: Dict[str, npt.NDArray[np.float64]] = {}
# If row_mapping is a dict (like {0: 'nz'}), use it directly
if isinstance(row_mapping, dict):
@@ -265,7 +280,7 @@ class UpdateOptodesWindow(QWidget):
result[key] = block.iloc[row_idx].to_numpy(dtype=float) * scale
# If row_mapping is a string prefix (like 'D' or 'S'), auto-generate keys
elif isinstance(row_mapping, str):
else:
for i in range(len(block)):
result[f"{row_mapping}{i+1}"] = block.iloc[i].to_numpy(dtype=float) * scale
@@ -292,5 +307,5 @@ class UpdateOptodesWindow(QWidget):
# Read the SNIRF file, set the montage, and write it back
raw = read_raw_snirf(file_a, preload=True)
raw.set_montage(initial_montage)
raw.set_montage(initial_montage) # type: ignore
write_raw_snirf(raw, save_path)
+53 -8
View File
@@ -1,15 +1,25 @@
"""
Filename: viewerlauncher.py
Description: Viewer launcher window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
from pathlib import Path
from typing import Any, Callable, Type
# External library imports
from pandas import DataFrame
from PySide6.QtWidgets import QPushButton, QWidget, QVBoxLayout
from PySide6.QtCore import QTimer
from mne import Epochs
from mne.io.base import BaseRaw
from src.analysis.exporttocsv import ExportToCSVWidget
from src.analysis.intragroupbrainimage import IntraGroupBrainImageWidget
from src.analysis.intergroupbrainimage import InterGroupBrainImageWidget
@@ -24,18 +34,31 @@ from src.shared.shareddata import APP_NAME
class ViewerLauncherWidget(QWidget):
def __init__(self, haemo_dict, epochs_dict, cha_dict, df_ind_dict, design_matrix_dict, config_dict, fig_bytes_dict, contrast_results_dict, roi_channel_map_dict, folding_bypass):
def __init__(
self,
haemo_dict: dict[str | Path, BaseRaw],
epochs_dict: dict[str, Epochs],
cha_dict: dict[str, DataFrame],
df_ind_dict: dict[str, DataFrame],
design_matrix_dict: dict[str, DataFrame],
config_dict: dict[str, dict[str, Any]],
fig_bytes_dict: dict[str, dict[str, bytes]],
contrast_results_dict: dict[str, dict[str, Any]],
roi_channel_map_dict: dict[str, dict[str, str]],
folding_bypass: bool,
) -> None:
super().__init__()
self.setWindowTitle(f"Viewer Launcher - {APP_NAME.upper()}")
group_dict = {f: c.get("GROUP", "Unknown") for f, c in config_dict.items()}
btn_data = [
btn_data: list[tuple[str, Type[QWidget], list[Any], bool]] = [
("Participant Image Viewer", ParticipantImageViewerWidget, [haemo_dict, fig_bytes_dict], True),
("Participant Brain Viewer", ParticipantBrainViewerWidget, [haemo_dict, cha_dict], True),
("Participant Fold Channels Viewer", ParticipantFoldChannelsWidget, [haemo_dict, cha_dict], False),
("Participant Functional Connectivity Viewer [BETA]", ParticipantFunctionalConnectivityWidget, [haemo_dict, epochs_dict], True),
("Intra-Group Functional Connectivity Viewer [BETA]", IntraGroupFunctionalConnectivityWidget, [haemo_dict, group_dict, config_dict], True),
("Intra-Group Functional Connectivity Viewer [BETA]", IntraGroupFunctionalConnectivityWidget, [haemo_dict, epochs_dict, group_dict], True),
("Intra-Group Stats Viewer", IntraGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, roi_channel_map_dict, group_dict], True),
("Inter-Group Stats Viewer", InterGroupStatsWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, roi_channel_map_dict, group_dict], True),
("Intra-Group Brain and Image Viewer", IntraGroupBrainImageWidget, [haemo_dict, cha_dict, df_ind_dict, design_matrix_dict, contrast_results_dict, group_dict], True),
@@ -47,21 +70,43 @@ class ViewerLauncherWidget(QWidget):
for label, widget_class, args, requires_bypass in btn_data:
btn = QPushButton(f"Open {label}")
# Connect directly to the generic opener
btn.clicked.connect(lambda _, c=widget_class, b=btn, a=args: self._open_viewer(c, b, *a))
btn.clicked.connect(self._make_viewer_callback(widget_class, btn, args))
btn.setEnabled(not (requires_bypass and folding_bypass))
layout.addWidget(btn)
def _open_viewer(self, widget_class, btn, *args):
def _make_viewer_callback(
self,
widget_class: Type[QWidget],
btn: QPushButton,
args: list[Any],
) -> Callable[[bool], None]:
def callback(_checked: bool = False) -> None:
self._open_viewer(widget_class, btn, *args)
return callback
def _open_viewer(
self,
widget_class: Type[QWidget],
btn: QPushButton,
*args: Any
) -> None:
# Instantiate and show dynamically
self.active_viewer = widget_class(*args)
self.active_viewer.show()
self._trigger_success(btn)
def _launch(self, func, btn, *args):
def _launch(
self,
func: Callable[..., Any],
btn: QPushButton,
*args: Any
) -> None:
func(*args)
self._trigger_success(btn)
def _trigger_success(self, button):
def _trigger_success(self, button: QPushButton) -> None:
"""Temporarily adds a green checkmark to the button text."""
original_text = button.text()
button.setText(f"{original_text} ✔")
@@ -70,6 +115,6 @@ class ViewerLauncherWidget(QWidget):
# Revert after 1 second
QTimer.singleShot(1000, lambda: self._revert_button(button, original_text))
def _revert_button(self, button, original_text):
def _revert_button(self, button: QPushButton, original_text: str) -> None:
button.setText(original_text)
button.setStyleSheet("")
+97 -56
View File
@@ -1,6 +1,7 @@
"""
Filename: updater.py
Description: Generic updater file
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
@@ -17,13 +18,14 @@ import zipfile
import traceback
import subprocess
import configparser
from typing import List
# External library imports
import psutil
import requests
from PySide6.QtWidgets import QMessageBox
from PySide6.QtCore import QThread, Signal, QObject
from PySide6.QtWidgets import QMainWindow, QMessageBox
class UpdateDownloadThread(QThread):
@@ -38,7 +40,14 @@ class UpdateDownloadThread(QThread):
update_ready = Signal(str, str)
error_occurred = Signal(str)
def __init__(self, download_url, latest_version, platform_name, app_name):
def __init__(
self,
download_url: str,
latest_version: str,
platform_name: str,
app_name: str,
) -> None:
super().__init__()
self.download_url = download_url
self.latest_version = latest_version
@@ -54,6 +63,7 @@ class UpdateDownloadThread(QThread):
os.makedirs(tmp_dir, exist_ok=True)
local_path = os.path.join(tmp_dir, local_filename)
else:
tmp_dir = os.getcwd()
local_path = os.path.join(os.getcwd(), local_filename)
# Download the file
@@ -92,7 +102,6 @@ class UpdateDownloadThread(QThread):
self.error_occurred.emit(str(e))
class UpdateCheckThread(QThread):
"""
Thread that checks for updates by querying the API and emits a signal based on the result.
@@ -107,7 +116,15 @@ class UpdateCheckThread(QThread):
no_update_available = Signal()
error_occurred = Signal(str)
def __init__(self, api_url, api_url_sec, current_version, platform_name, app_name):
def __init__(
self,
api_url: str,
api_url_sec: str,
current_version: str,
platform_name: str,
app_name: str,
) -> None:
super().__init__()
self.api_url = api_url
self.api_url_sec = api_url_sec
@@ -137,8 +154,9 @@ class UpdateCheckThread(QThread):
except Exception as e:
self.error_occurred.emit(f"Update check failed: {e}")
def version_compare(self, v1, v2):
def normalize(v): return [int(x) for x in v.split(".")]
def version_compare(self, v1: str, v2: str) -> int:
def normalize(v: str) -> List[int]:
return [int(x) for x in v.split(".")]
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
def get_latest_release_for_platform(self):
@@ -165,7 +183,7 @@ class UpdateCheckThread(QThread):
return tag, asset["browser_download_url"]
return tag, None
except (requests.RequestException, ValueError) as e:
except (requests.RequestException, ValueError, KeyError):
continue
return None, None
@@ -182,15 +200,23 @@ class LocalPendingUpdateCheckThread(QThread):
pending_update_found = Signal(str, str)
no_pending_update = Signal()
def __init__(self, current_version, platform_suffix, platform_name, app_name):
def __init__(
self,
current_version: str,
platform_suffix: str,
platform_name: str,
app_name: str,
) -> None:
super().__init__()
self.current_version = current_version
self.platform_suffix = platform_suffix
self.platform_name = platform_name
self.app_name = app_name
def version_compare(self, v1, v2):
def normalize(v): return [int(x) for x in v.split(".")]
def version_compare(self, v1: str, v2: str) -> int:
def normalize(v: str) -> List[int]:
return [int(x) for x in v.split(".")]
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
def run(self):
@@ -220,18 +246,25 @@ class LocalPendingUpdateCheckThread(QThread):
self.no_pending_update.emit()
class UpdateManager(QObject):
"""
Orchestrates the update process.
Main apps should instantiate this and call check_for_updates().
"""
def __init__(self, main_window, api_url, api_url_sec, current_version, platform_name, platform_suffix, app_name):
super().__init__()
self.parent = main_window
def __init__(
self,
main_window: QMainWindow,
api_url: str,
api_url_sec: str,
current_version: str,
platform_name: str,
platform_suffix: str,
app_name: str,
) -> None:
super().__init__(main_window)
self.main_window: QMainWindow = main_window
self.api_url = api_url
self.api_url_sec = api_url_sec
self.current_version = current_version
@@ -243,59 +276,64 @@ class UpdateManager(QObject):
self.pending_update_path = None
def manual_check_for_updates(self):
def manual_check_for_updates(self) -> None:
self.local_check_thread = LocalPendingUpdateCheckThread(self.current_version, self.platform_suffix, self.platform_name, self.app_name)
self.local_check_thread.pending_update_found.connect(self.on_pending_update_found)
self.local_check_thread.no_pending_update.connect(self.on_no_pending_update)
self.local_check_thread.start()
def on_pending_update_found(self, version, folder_path):
self.parent.statusBar().showMessage(f"Pending update found: version {version}")
def on_pending_update_found(self, version: str, folder_path: str) -> None:
self.main_window.statusBar().showMessage(f"Pending update found: version {version}")
self.pending_update_version = version
self.pending_update_path = folder_path
self.show_pending_update_popup()
def on_no_pending_update(self):
def on_no_pending_update(self) -> None:
# No pending update found locally, start server check directly
self.parent.statusBar().showMessage("No pending local update found. Checking server...")
self.main_window.statusBar().showMessage("No pending local update found. Checking server...")
self.start_update_check_thread()
def show_pending_update_popup(self):
msg_box = QMessageBox(self.parent)
def show_pending_update_popup(self) -> None:
msg_box = QMessageBox(self.main_window)
msg_box.setWindowTitle("Pending Update Found")
msg_box.setText(f"A previously downloaded update for {self.app_name.upper()} (version {self.pending_update_version}) is available at:\n{self.pending_update_path}\nWould you like to install it now?")
install_now_button = msg_box.addButton("Install Now", QMessageBox.ButtonRole.AcceptRole)
install_later_button = msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole)
msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole)
msg_box.exec()
if msg_box.clickedButton() == install_now_button:
if msg_box.clickedButton() == install_now_button and self.pending_update_path:
self.install_update(self.pending_update_path)
else:
self.parent.statusBar().showMessage("Pending update available. Install later.")
if self.main_window.statusBar():
self.main_window.statusBar().showMessage("Pending update available. Install later.")
# After user dismisses, still check the server for new updates
self.start_update_check_thread()
def start_update_check_thread(self):
def start_update_check_thread(self) -> None:
self.check_thread = UpdateCheckThread(self.api_url, self.api_url_sec, self.current_version, self.platform_name, self.app_name)
self.check_thread.download_requested.connect(self.on_server_update_requested)
self.check_thread.no_update_available.connect(self.on_server_no_update)
self.check_thread.error_occurred.connect(self.on_error)
self.check_thread.start()
def on_server_no_update(self):
self.parent.statusBar().showMessage("No new updates found on server.", 5000)
def on_server_no_update(self) -> None:
if self.main_window.statusBar():
self.main_window.statusBar().showMessage("No new updates found on server.", 5000)
def on_server_update_requested(self, download_url, latest_version):
if self.pending_update_version:
cmp = self.version_compare(latest_version, self.pending_update_version)
def on_server_update_requested(self, download_url: str, latest_version: str) -> None:
pending_path = self.pending_update_path
pending_version = self.pending_update_version
if pending_version and pending_path:
cmp = self.version_compare(latest_version, pending_version)
if cmp > 0:
# Server version is newer than pending update
self.parent.statusBar().showMessage(f"Newer version {latest_version} available on server. Removing old pending update...")
self.main_window.statusBar().showMessage(f"Newer version {latest_version} available on server. Removing old pending update...")
try:
shutil.rmtree(self.pending_update_path)
self.parent.statusBar().showMessage(f"Deleted old update folder: {self.pending_update_path}")
shutil.rmtree(pending_path)
self.main_window.statusBar().showMessage(f"Deleted old update folder: {pending_path}")
except Exception as e:
self.parent.statusBar().showMessage(f"Failed to delete old update folder: {e}")
self.main_window.statusBar().showMessage(f"Failed to delete old update folder: {e}")
# Clear pending update info so new download proceeds
self.pending_update_version = None
@@ -305,39 +343,41 @@ class UpdateManager(QObject):
self.download_update(download_url, latest_version)
elif cmp == 0:
# Versions equal, no download needed
self.parent.statusBar().showMessage(f"Pending update version {self.pending_update_version} is already latest. No download needed.")
self.main_window.statusBar().showMessage(f"Pending update version {self.pending_update_version} is already latest. No download needed.")
else:
# Server version older than pending? Unlikely but just keep pending update
self.parent.statusBar().showMessage(f"Pending update version {self.pending_update_version} is newer than server version. No action.")
self.main_window.statusBar().showMessage(f"Pending update version {self.pending_update_version} is newer than server version. No action.")
else:
# No pending update, just download
self.download_update(download_url, latest_version)
def download_update(self, download_url, latest_version):
self.parent.statusBar().showMessage("Downloading update...")
def download_update(self, download_url: str, latest_version: str) -> None:
if self.main_window.statusBar():
self.main_window.statusBar().showMessage("Downloading update...")
self.download_thread = UpdateDownloadThread(download_url, latest_version, self.platform_name, self.app_name)
self.download_thread.update_ready.connect(self.on_update_ready)
self.download_thread.error_occurred.connect(self.on_error)
self.download_thread.start()
def on_update_ready(self, latest_version, extract_folder):
self.parent.statusBar().showMessage("Update downloaded and extracted.")
def on_update_ready(self, latest_version: str, extract_folder: str) -> None:
if self.main_window.statusBar():
self.main_window.statusBar().showMessage("Update downloaded and extracted.")
msg_box = QMessageBox(self.parent)
msg_box = QMessageBox(self.main_window)
msg_box.setWindowTitle("Update Ready")
msg_box.setText(f"Version {latest_version} has been downloaded and extracted to:\n{extract_folder}\nWould you like to install it now?")
install_now_button = msg_box.addButton("Install Now", QMessageBox.ButtonRole.AcceptRole)
install_later_button = msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole)
msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole)
msg_box.exec()
if msg_box.clickedButton() == install_now_button:
self.install_update(extract_folder)
else:
self.parent.statusBar().showMessage("Update ready. Install later.")
self.main_window.statusBar().showMessage("Update ready. Install later.")
def install_update(self, extract_folder):
def install_update(self, extract_folder: str) -> None:
# Path to updater executable
if self.platform_name == 'windows':
@@ -354,7 +394,7 @@ class UpdateManager(QObject):
updater_path = os.getcwd()
if not os.path.exists(updater_path):
QMessageBox.critical(self.parent, "Error", f"Updater not found at:\n{updater_path}. The absolute path was {os.path.abspath(updater_path)}")
QMessageBox.critical(self.main_window, "Error", f"Updater not found at:\n{updater_path}. The absolute path was {os.path.abspath(updater_path)}")
return
# Launch updater with extracted folder path as argument
@@ -373,18 +413,19 @@ class UpdateManager(QObject):
sys.exit(0)
except Exception as e:
QMessageBox.critical(self.parent, "Error", f"[Updater Launch Failed]\n{str(e)}\n{traceback.format_exc()}")
QMessageBox.critical(self.main_window, "Error", f"[Updater Launch Failed]\n{str(e)}\n{traceback.format_exc()}")
def on_error(self, message):
# print(f"Error: {message}")
self.parent.statusBar().showMessage(f"Error occurred during update process. {message}")
def on_error(self, message: str) -> None:
if self.main_window.statusBar():
self.main_window.statusBar().showMessage(f"Error occurred during update process. {message}")
def version_compare(self, v1, v2):
def normalize(v): return [int(x) for x in v.split(".")]
def version_compare(self, v1: str, v2: str) -> int:
def normalize(v: str) -> List[int]:
return [int(x) for x in v.split(".")]
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
def wait_for_process_to_exit(process_name, timeout=10):
def wait_for_process_to_exit(process_name: str, timeout: int = 10) -> bool:
"""
Waits for a process with the specified name to exit within a timeout period.
@@ -416,7 +457,7 @@ def wait_for_process_to_exit(process_name, timeout=10):
return False
def finish_update_if_needed(platform_name, app_name, cfg_path, finish_update):
def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, finish_update: bool) -> None:
"""
Completes a pending application update if '--finish-update' is present in the command-line arguments.
"""
@@ -534,7 +575,7 @@ def finish_update_if_needed(platform_name, app_name, cfg_path, finish_update):
sys.argv.remove("--finish-update")
def remove_quarantine(app_path, app_name):
def remove_quarantine(app_path: str, app_name: str) -> None:
"""
Removes the macOS quarantine attribute from the specified application path.
"""