functional connectivity, pylance, and other improvements
This commit is contained in:
+21
-7
@@ -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
|
||||
|
||||
+45
-26
@@ -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
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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}")
|
||||
@@ -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,
|
||||
|
||||
@@ -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;")
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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.
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user