diff --git a/changelog.md b/changelog.md index 03f3639..58550d7 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/flares.py b/flares.py index f57adaa..d8d2e56 100644 --- a/flares.py +++ b/flares.py @@ -42,19 +42,19 @@ from numpy import float64, floating import pandas as pd from pandas import DataFrame -import h5py +import h5py # type: ignore import seaborn as sns from nilearn.plotting import plot_design_matrix # type: ignore -from nilearn.glm.regression import OLSModel +from nilearn.glm.regression import OLSModel # type: ignore import statsmodels.formula.api as smf # type: ignore -from statsmodels.stats.multitest import multipletests -from statsmodels.tools.sm_exceptions import ConvergenceWarning +from statsmodels.stats.multitest import multipletests # type: ignore +from statsmodels.tools.sm_exceptions import ConvergenceWarning # type: ignore from scipy.spatial.distance import cdist from scipy.signal import welch, butter, filtfilt, periodogram # type: ignore -from scipy.stats import pearsonr, zscore, ttest_1samp, ttest_ind, sem, t as t_dist +from scipy.stats import pearsonr, zscore, ttest_1samp, ttest_ind, sem, t as t_dist # type: ignore import pywt # type: ignore import neurokit2 as nk # type: ignore @@ -68,37 +68,37 @@ import xlrd # External library imports for mne from mne import ( EvokedArray, SourceEstimate, Info, Epochs, Label, Annotations, - events_from_annotations, read_source_spaces, create_info, - stc_near_sensors, pick_types, grand_average, get_config, set_config, read_labels_from_annot -) # type: ignore + events_from_annotations, read_source_spaces, create_info, # type: ignore + stc_near_sensors, pick_types, grand_average, get_config, set_config, read_labels_from_annot # type: ignore +) from mne.source_space import SourceSpaces from mne.transforms import Transform # type: ignore from mne.io import BaseRaw, RawArray, read_raw_snirf # type: ignore from mne.preprocessing.nirs import ( - beer_lambert_law, optical_density, - temporal_derivative_distribution_repair, - source_detector_distances, short_channels -) # type: ignore -from mne.viz import Brain, plot_events, plot_evoked_topo, plot_compare_evokeds + beer_lambert_law, optical_density, # type: ignore + temporal_derivative_distribution_repair, # type: ignore + source_detector_distances, short_channels # type: ignore +) +from mne.viz import Brain, plot_events, plot_evoked_topo, plot_compare_evokeds # type: ignore from mne.filter import filter_data # type: ignore -from mne.utils import _check_fname, _validate_type, warn -from mne.channels import make_standard_montage -from mne.datasets.sample import data_path +from mne.utils import _check_fname, _validate_type, warn # type: ignore +from mne.channels import make_standard_montage # type: ignore +from mne.datasets.sample import data_path # type: ignore from mne_nirs.visualisation import plot_glm_group_topo # type: ignore from mne_nirs.channels import get_long_channels, get_short_channels # type: ignore from mne_nirs.experimental_design import make_first_level_design_matrix # type: ignore from mne_nirs.statistics import run_glm, statsmodels_to_results # type: ignore -from mne_nirs.signal_enhancement import ( - enhance_negative_correlation, short_channel_regression -) # type: ignore +from mne_nirs.signal_enhancement import ( # type: ignore + enhance_negative_correlation, short_channel_regression # type: ignore +) from mne_nirs.io.fold import fold_channel_specificity # type: ignore from mne_nirs.preprocessing import peak_power # type: ignore -from mne.preprocessing.nirs.nirs import _validate_nirs_info +from mne.preprocessing.nirs.nirs import _validate_nirs_info # type: ignore from mne_nirs.statistics._glm_level_first import RegressionResults # type: ignore -from mne_connectivity.viz import plot_connectivity_circle -from mne_connectivity import envelope_correlation, spectral_connectivity_epochs, spectral_connectivity_time +from mne_connectivity.viz import plot_connectivity_circle # type: ignore +from mne_connectivity import envelope_correlation, spectral_connectivity_epochs, spectral_connectivity_time # type: ignore from src.shared.shareddata import PLATFORM_NAME, resource_path @@ -108,7 +108,7 @@ PRIMARY_COLORS = { "SCI only": "skyblue", # Scalp Coupling Index (Standard MNE) "SNR only": "lightgreen", # Signal-to-Noise Ratio (Your original) "PSP only": "salmon", # Power Spectral Peak (Original Noise check) - "Coeff_var only": "yellow", # Relative Noise (The coeff_var-only check) + "Coeff_var only": "yellow", # Relative Noise (The coeff_var-only check) "Range only": "coral", # Z-Swing (The Range Outlier check) "Noise only": "plum", # High-Freq PSD (The Noise check) "Disp. only": "palegreen", # Sensor Displacement (Variance Drop) @@ -1818,7 +1818,7 @@ def _check_load_fold(fold_files, atlas): -def fold_channels(raw: BaseRaw, p_name: str, atlas: str='Brodmann', progress_queue=None) -> dict[str, list[dict[str, Any]]]: +def fold_channels(raw: BaseRaw, p_name: str, atlas: str='Brodmann', progress_queue: Optional[Any]=None) -> dict[str, list[dict[str, Any]]]: """Runs in background process. Does only heavy math/lookup. Returns data instead of a static image. """ @@ -2061,47 +2061,96 @@ def aggregate_fnirs_group_geometry(raw_list: Sequence[BaseRaw | None]) -> BaseRa Returns a unified MNE Raw object with exactly one dot per optode. """ + def _safe_nanmean_columns(arr: np.ndarray, label: str, relevant_slice: slice = slice(0, 9)) -> np.ndarray | None: + """Column-wise nanmean, treating a column as invalid only if it's still + NaN after averaging AND within the coordinate range this function + actually uses (loc[0:9]: channel midpoint, source, detector). Slots + 9-11 are unused fNIRS loc fields (commonly NaN by design) and must not + trigger exclusion.""" + if arr.size == 0: + return None + with np.errstate(invalid="ignore"): + result = np.nanmean(arr, axis=0) + if np.any(np.isnan(result[relevant_slice])): + logger.warning(f"[aggregate_geometry] '{label}' has NaN in relevant coordinate " + f"slots (0:9) after averaging - excluding.") + return None + return result + channel_locs = {} all_ch_names = [] - + for raw in raw_list: if raw is None: continue raw_hbo = raw.copy().pick(picks="hbo") - + for i, ch_name in enumerate(raw_hbo.ch_names): if ch_name not in channel_locs: channel_locs[ch_name] = [] all_ch_names.append(ch_name) - + channel_locs[ch_name].append(raw_hbo.info['chs'][i]['loc']) - avg_pairings = {name: np.nanmean(locs, axis=0) for name, locs in channel_locs.items()} + avg_pairings = {} + skipped_channels = [] + for name, locs in channel_locs.items(): + locs_arr = np.array(locs) + valid = locs_arr[~np.all(np.isnan(locs_arr), axis=1)] + result = _safe_nanmean_columns(valid, name) + if result is None: + skipped_channels.append(name) + continue + avg_pairings[name] = result + + if skipped_channels: + logger.warning(f"[aggregate_geometry] {len(skipped_channels)} channel(s) had no valid " + f"position data from any participant, excluded: {skipped_channels}") optode_collections = {'sources': {}, 'detectors': {}} - + for ch_name, loc in avg_pairings.items(): parts = ch_name.split()[0].split('_') s_name, d_name = parts[0], parts[1] - + optode_collections['sources'].setdefault(s_name, []).append(loc[3:6]) optode_collections['detectors'].setdefault(d_name, []).append(loc[6:9]) - final_sources = {s: np.nanmean(coords, axis=0) for s, coords in optode_collections['sources'].items()} - final_detectors = {d: np.nanmean(coords, axis=0) for d, coords in optode_collections['detectors'].items()} + final_sources = {} + for s, coords in optode_collections['sources'].items(): + coords_arr = np.array(coords) + valid = coords_arr[~np.all(np.isnan(coords_arr), axis=1)] + result = _safe_nanmean_columns(valid, f"source '{s}'") + if result is not None: + final_sources[s] = result + + final_detectors = {} + for d, coords in optode_collections['detectors'].items(): + coords_arr = np.array(coords) + valid = coords_arr[~np.all(np.isnan(coords_arr), axis=1)] + result = _safe_nanmean_columns(valid, f"detector '{d}'") + if result is not None: + final_detectors[d] = result ref_raw = raw_list[0].copy().pick(picks="hbo") template_lookup = {ch['ch_name']: ch for ch in ref_raw.info['chs']} final_chs = [] for ch_name in all_ch_names: - unified_loc = avg_pairings[ch_name].copy() + if ch_name not in avg_pairings: + continue parts = ch_name.split()[0].split('_') s_name, d_name = parts[0], parts[1] - + + if s_name not in final_sources or d_name not in final_detectors: + logger.warning(f"[aggregate_geometry] channel '{ch_name}' references source/detector " + f"with no valid coordinates, excluding.") + continue + + unified_loc = avg_pairings[ch_name].copy() unified_loc[3:6] = final_sources[s_name] unified_loc[6:9] = final_detectors[d_name] unified_loc[0:3] = (final_sources[s_name] + final_detectors[d_name]) / 2.0 - + # Create the new channel object new_ch = template_lookup.get(ch_name, ref_raw.info['chs'][0]).copy() new_ch['ch_name'] = ch_name @@ -2109,11 +2158,12 @@ def aggregate_fnirs_group_geometry(raw_list: Sequence[BaseRaw | None]) -> BaseRa final_chs.append(new_ch) # Create the final MNE Info - fake_info = create_info(ch_names=all_ch_names, sfreq=ref_raw.info['sfreq'], ch_types='hbo') + final_ch_names = [ch['ch_name'] for ch in final_chs] + fake_info = create_info(ch_names=final_ch_names, sfreq=ref_raw.info['sfreq'], ch_types='hbo') with fake_info._unlock(): fake_info['chs'] = final_chs - - return RawArray(np.zeros((len(all_ch_names), 1)), fake_info) + + return RawArray(np.zeros((len(final_ch_names), 1)), fake_info) @@ -2126,58 +2176,73 @@ def brain_3d_visualization( show_text: bool = True, brain_bounds: float | tuple[float, float] | Sequence[float] = 1.0, ) -> None: + if raw_haemo is None: + raise ValueError("No haemo data available for the selected participant(s) - cannot render brain visualization.") + if df_cha is None or df_cha.empty: + raise ValueError("No channel-level results (df_cha) available for the selected participant(s).") + if selected_event is None: + raise ValueError("No event selected - brain_3d_visualization requires a specific condition to plot.") - clim = dict(kind="value", pos_lims=(0, brain_bounds/2, brain_bounds)) + if isinstance(brain_bounds, (tuple, list)): + b_low, b_high = float(brain_bounds[0]), float(brain_bounds[-1]) + else: + b_low, b_high = 0.0, float(brain_bounds) - # Get all activity conditions - for cond in [f'{selected_event}']: + clim = dict(kind="value", pos_lims=(b_low, (b_low + b_high) / 2, b_high)) - ch_summary = df_cha.query(f"Condition.str.startswith('{cond}_delay_') and Chroma == 'hbo'", engine='python') # type: ignore + cond = str(selected_event) - print(ch_summary) - - if ch_summary.empty: - #not fir model - print("No data found for this condition.") - ch_summary = df_cha.query(f"Condition in [@cond] and Chroma == 'hbo'", engine='python') + ch_summary = df_cha.query("Condition == @cond and Chroma == 'hbo'", engine='python') + if ch_summary.empty: + raise ValueError(f"No hbo data found for condition '{cond}' in the selected participant(s).") - # Use ordinary least squares (OLS) if only one participant - # TODO: Fix. - if True: - # t values - if t_or_theta == 't': - ch_model = smf.ols("t ~ -1 + ch_name", ch_summary).fit() # type: ignore + n_participants = ch_summary["ID"].nunique() if "ID" in ch_summary.columns else 1 + formula = f"{t_or_theta} ~ -1 + ch_name" - # theta values - elif t_or_theta == 'theta': - ch_model = smf.ols("theta ~ -1 + ch_name", ch_summary).fit() # type: ignore + if n_participants > 1 and "ID" in ch_summary.columns: + try: + ch_model = smf.mixedlm(formula, ch_summary, groups=ch_summary["ID"]).fit() + if ch_model.cov_re.values.flatten()[0] < 1e-6: + print(f"WARNING: Random-effects variance near zero for condition '{cond}' - " + f"mixed-effects model may not be meaningfully different from pooled OLS here.") + print(f"Mixed-effects model used across {n_participants} participants.") + except Exception as e: + print(f"Mixed-effects model failed ({e}), falling back to OLS - " + f"note: pooled OLS across {n_participants} participants does not " + f"account for repeated-measures structure and may understate uncertainty.") + ch_model = smf.ols(formula, ch_summary).fit() + else: + ch_model = smf.ols(formula, ch_summary).fit() + print("OLS model used (single participant).") - print("OLS model is being used as there is only one participant!") + model_df = cast(DataFrame, statsmodels_to_results(ch_model, order=ch_summary["ch_name"].unique())) - # Convert model results - model_df = cast(DataFrame, statsmodels_to_results(ch_model, order=ch_summary["ch_name"].unique())) # type: ignore - valid_channels = ch_summary["ch_name"].unique().tolist() # type: ignore - raw_for_plot = raw_haemo.copy().pick(picks=valid_channels) # type: ignore + valid_channels = ch_summary["ch_name"].unique().tolist() # type: ignore + raw_for_plot = raw_haemo.copy().pick(picks=valid_channels) # type: ignore - print(f"DEBUG: Model DF rows: {len(model_df)}") - print(f"DEBUG: Raw channels: {len(raw_for_plot.ch_names)}") + print(f"DEBUG: Model DF rows: {len(model_df)}") + print(f"DEBUG: Raw channels: {len(raw_for_plot.ch_names)}") - brain = plot_3d_evoked_array(raw_for_plot.pick(picks="hbo"), model_df, view="dorsal", distance=0.02, colorbar=True, clim=clim, mode="weighted", size=(800, 700)) # type: ignore - - if show_optodes == 'all' or show_optodes == 'sensors': - brain.add_sensors(raw_for_plot.pick(picks="hbo").info, trans=Transform('head', 'mri', np.eye(4)), fnirs=["channels", "pairs", "sources", "detectors"], verbose=False) # type: ignore - - if True: - display_text = ('Folder: ' + '\nGroup: ' + '\nCondition: '+ cond + '\nShort Channel Regression: ' - + '\nLooking at: ' + t_or_theta + ' values') + '\nBrain Distance: ' + brain = plot_3d_evoked_array(raw_for_plot.pick(picks="hbo"), model_df, view="dorsal", distance=0.02, colorbar=True, clim=clim, mode="weighted", size=(800, 700)) # type: ignore + + if show_optodes == 'all' or show_optodes == 'sensors': + brain.add_sensors(raw_for_plot.pick(picks="hbo").info, trans=Transform('head', 'mri', np.eye(4)), fnirs=["channels", "pairs", "sources", "detectors"], verbose=False) # type: ignore - # Apply the text onto the brain - if show_text: - brain.add_text(0.12, 0.64, display_text, "title", font_size=11, color="k") # type: ignore + elif show_optodes == "labels": + print("show_optodes='labels' is not currently implemented - no optode overlay shown.") - return brain + if show_text: + display_text = ( + f"Condition: {cond}\n" + f"Model: {'Mixed-effects' if n_participants > 1 else 'OLS'} " + f"(n={n_participants})\n" + f"Looking at: {t_or_theta} values" + ) + brain.add_text(0.12, 0.64, display_text, "title", font_size=11, color="k") # type: ignore + + return brain @@ -2330,28 +2395,46 @@ def plot_2d_3d_contrasts_between_groups( show_optodes: Literal['sensors', 'labels', 'none', 'all'] = 'all', show_text: bool = True, brain_bounds: float = 1.0, + min_participants_per_group: int = 2, ) -> None: + if raw_haemo is None: + raise ValueError("raw_haemo is required for group contrast visualization.") + if group_a_name == group_b_name: + raise ValueError(f"group_a_name and group_b_name must differ (both were '{group_a_name}').") + if contrast_df_a.empty or contrast_df_b.empty: + raise ValueError("One or both contrast dataframes are empty - check group selection.") - logger.info("-----") contrast_df_a = contrast_df_a.copy() contrast_df_a["group"] = group_a_name contrast_df_b = contrast_df_b.copy() contrast_df_b["group"] = group_b_name - logger.info("-----") df_combined = pd.concat([contrast_df_a, contrast_df_b], ignore_index=True) - con_summary = df_combined.query("Chroma == 'hbo'").copy() - logger.info("-----") - valid_channels = (pd.crosstab(con_summary["group"], con_summary["ch_name"]) > 1).all() - valid_channels = valid_channels[valid_channels].index.tolist() + counts = pd.crosstab(con_summary["group"], con_summary["ch_name"]) + valid_mask = (counts >= min_participants_per_group).all() + valid_channels = valid_mask[valid_mask].index.tolist() con_summary = con_summary[con_summary["ch_name"].isin(valid_channels)] - logger.info("-----") + + if con_summary.empty: + raise ValueError( + f"No channels have >= {min_participants_per_group} participants in BOTH " + f"'{group_a_name}' and '{group_b_name}' - cannot fit a group contrast model. " + f"Select more participants per group, or lower min_participants_per_group." + ) model_formula = "effect ~ -1 + group:ch_name:Chroma" - con_model = smf.mixedlm(model_formula, con_summary, groups=con_summary["ID"]).fit(method="nm") - logger.info("-----") + try: + con_model = smf.mixedlm(model_formula, con_summary, groups=con_summary["ID"]).fit(method="nm") + if not getattr(con_model, "converged", True): + print(f"WARNING: Group contrast mixed-effects model did not converge cleanly " + f"({group_a_name} vs {group_b_name}) - results may be unreliable.") + except Exception as e: + print(f"Mixed-effects model failed ({e}), falling back to OLS - " + f"note: pooled OLS does not account for repeated-measures structure " + f"and may understate uncertainty.") + con_model = smf.ols(model_formula, con_summary).fit() if t_or_theta == "t": group1_vals = con_model.tvalues.filter(like=f"group[{group_a_name}]") @@ -2359,88 +2442,67 @@ def plot_2d_3d_contrasts_between_groups( else: group1_vals = con_model.params.filter(like=f"group[{group_a_name}]") group2_vals = con_model.params.filter(like=f"group[{group_b_name}]") - logger.info("-----") - group1_channels = [name.split(":")[1].split("[")[1].split("]")[0] for name in group1_vals.index] - group2_channels = [name.split(":")[1].split("[")[1].split("]")[0] for name in group2_vals.index] + def _extract_ch_names(vals): + names = [] + for term in vals.index: + parts = term.split(":") + if len(parts) < 2 or "[" not in parts[1] or "]" not in parts[1]: + raise ValueError( + f"Unexpected coefficient name format: '{term}'. Expected " + f"'group[...]:ch_name[...]:Chroma[...]' - patsy naming may " + f"have changed. Cannot safely extract channel names." + ) + names.append(parts[1].split("[")[1].split("]")[0]) + return names + + group1_channels = _extract_ch_names(group1_vals) + group2_channels = _extract_ch_names(group2_vals) + + known_channels = set(raw_haemo.copy().pick(picks="hbo").ch_names) + unrecognized = (set(group1_channels) | set(group2_channels)) - known_channels + if unrecognized: + raise ValueError( + f"Extracted channel name(s) not found in raw_haemo: {unrecognized}. " + f"Coefficient-name parsing likely broke - verify model term format." + ) df_group1 = DataFrame({"Coef.": group1_vals.values}, index=group1_channels) df_group2 = DataFrame({"Coef.": group2_vals.values}, index=group2_channels) - df_contrast = df_group1.join(df_group2, how="inner", lsuffix=f"_{group_a_name}", rsuffix=f"_{group_b_name}") - logger.info("-----") - # A - B - df_contrast["Coef."] = df_contrast[f"Coef._{group_a_name}"] - df_contrast[f"Coef._{group_b_name}"] - con_model_df_1_2 = DataFrame({ - "ch_name": df_contrast.index, - "Coef.": df_contrast["Coef."], - "Chroma": "hbo" - }) - logger.info("-----") + if df_contrast.empty: + raise ValueError(f"No channels in common between '{group_a_name}' and '{group_b_name}' model results.") mne_ch_names = raw_haemo.copy().pick(picks="hbo").ch_names - glm_ch_names = con_model_df_1_2["ch_name"].tolist() - common_channels = [ch for ch in mne_ch_names if ch in glm_ch_names] - con_model_df_filtered = raw_haemo.copy().pick(picks=common_channels) - con_model_df_1_2 = con_model_df_1_2.set_index("ch_name").loc[common_channels].reset_index() - logger.info("-----") + def _plot_direction(coef_a_col, coef_b_col, name_first, name_second): + df_contrast["Coef."] = df_contrast[coef_a_col] - df_contrast[coef_b_col] + con_model_df = DataFrame({ + "ch_name": df_contrast.index, + "Coef.": df_contrast["Coef."], + "Chroma": "hbo" + }) + glm_ch_names = con_model_df["ch_name"].tolist() + common_channels = [ch for ch in mne_ch_names if ch in glm_ch_names] + con_model_df_filtered = raw_haemo.copy().pick(picks=common_channels) + con_model_df = con_model_df.set_index("ch_name").loc[common_channels].reset_index() - if is_3d: - brain_3d_contrast( - con_model_df_1_2, - con_model_df_filtered, - common_channels, - group_a_name, - group_b_name, - t_or_theta, - show_optodes, - show_text, - brain_bounds - ) - else: - plot_glm_group_topo(con_model_df_filtered.copy().pick(picks="hbo"), con_model_df_1_2, names=True, res=128, vlim=(-brain_bounds, brain_bounds)) # type: ignore + if is_3d: + brain_3d_contrast( + con_model_df, con_model_df_filtered, common_channels, + name_first, name_second, t_or_theta, show_optodes, show_text, brain_bounds + ) + else: + plot_glm_group_topo( + con_model_df_filtered.copy().pick(picks="hbo"), con_model_df, + names=True, res=128, vlim=(-brain_bounds, brain_bounds) + ) + plt.title(f"Contrast: {name_first} vs {name_second}") + plt.show() - # TODO: The title currently goes on the colorbar. Low priority - plt.title(f"Contrast: {group_a_name} vs {group_b_name}") # type: ignore - plt.show() # type: ignore - - # plt.title(f"Contrast: {group_a_name} vs {group_b_name}") - # plt.show() - - # B - A - df_contrast["Coef."] = df_contrast[f"Coef._{group_b_name}"] - df_contrast[f"Coef._{group_a_name}"] - con_model_df_2_1 = DataFrame({ - "ch_name": df_contrast.index, - "Coef.": df_contrast["Coef."], - "Chroma": "hbo" - }) - - glm_ch_names = con_model_df_2_1["ch_name"].tolist() - common_channels = [ch for ch in mne_ch_names if ch in glm_ch_names] - - con_model_df_filtered = raw_haemo.copy().pick(picks=common_channels) - con_model_df_2_1 = con_model_df_2_1.set_index("ch_name").loc[common_channels].reset_index() - - if is_3d: - brain_3d_contrast( - con_model_df_2_1, - con_model_df_filtered, - common_channels, - group_b_name, - group_a_name, - t_or_theta, - show_optodes, - show_text, - brain_bounds - ) - else: - plot_glm_group_topo(con_model_df_filtered.copy().pick(picks="hbo"), con_model_df_2_1, names=True, res=128, vlim=(-brain_bounds, brain_bounds)) # type: ignore - - # TODO: The title currently goes on the colorbar. Low priority - plt.title(f"Contrast: {group_b_name} vs {group_a_name}") # type: ignore - plt.show() # type: ignore + _plot_direction(f"Coef._{group_a_name}", f"Coef._{group_b_name}", group_a_name, group_b_name) + _plot_direction(f"Coef._{group_b_name}", f"Coef._{group_a_name}", group_b_name, group_a_name) @@ -2452,6 +2514,12 @@ def plot_fir_model_results( l_bound: float, u_bound: float, ) -> None: + ''' + FIR Model Results requires per-delay Condition data, but the current + df_ind_dict is pre-collapsed (delay information stripped) upstream in + generate_roi_results. This method is not currently functional as it + needs an uncollapsed per-delay ROI dataframe to be threaded through separately. + ''' df["isActivity"] = [f"{selected_event}" in n for n in df["Condition"]] df["isDelay"] = ["delay" in n for n in df["Condition"]] @@ -2651,6 +2719,7 @@ def load_snirf(file_path: str, downsample_frequency: int, verbosity: bool) -> tu def run_roi_second_level_analysis( df_roi_all: DataFrame, + condition: str, df_cha_all: DataFrame | None = None, raw_haemo: BaseRaw | None = None, p_threshold: float = 0.05, @@ -2671,7 +2740,7 @@ def run_roi_second_level_analysis( raise ValueError(f"Input ROI DataFrame must include: {required_cols}") # 2. Filter ROI data for the targeted chromophore - df_chroma = df_roi_all[df_roi_all['Chroma'] == target_chroma].copy() + df_chroma = df_roi_all[(df_roi_all['Chroma'] == target_chroma) & (df_roi_all['Condition'] == condition)].copy() df_chroma = df_chroma.dropna(subset=['theta']) # 3. Perform 1-sample t-test against zero for each ROI @@ -2799,7 +2868,7 @@ def run_roi_second_level_analysis( val_col = 'effect' if 'effect' in df_cha_all.columns else 'theta' ch_col = 'ch_name' if 'ch_name' in df_cha_all.columns else 'channel' - con_summary = df_cha_all[df_cha_all['Chroma'] == target_chroma].copy() + con_summary = df_cha_all[(df_cha_all['Chroma'] == target_chroma) & (df_cha_all['Condition'] == condition)].copy() raw_picked = raw_haemo.copy().pick(picks=target_chroma) # Fit channel LME (suppress ConvergenceWarning locally) @@ -4252,6 +4321,16 @@ def wavelet_iqr_denoise(signal: NDArray[float64], wavelet: str = 'db4', level: i The denoised signal array, with the same length as the input. """ + max_level = pywt.dwt_max_level(len(signal), pywt.Wavelet(wavelet).dec_len) + if level > max_level: + raise ValueError( + f"wavelet_level={level} exceeds the maximum valid decomposition level " + f"({max_level}) for a signal of length {len(signal)} with wavelet '{wavelet}'. " + f"Reduce wavelet_level to at most {max_level}, or use a longer signal." + ) + if level < 1: + raise ValueError(f"wavelet_level must be >= 1, got {level}.") + # Decompose the signal using wavelet transform and initialize a list with approximation coefficients coeffs: list[NDArray[float64]] = pywt.wavedec(signal, wavelet, level=level) # type: ignore cA = coeffs[0] @@ -4317,43 +4396,131 @@ def calculate_and_apply_wavelet(data: BaseRaw, wavelet_type: str, wavelet_level: -def short_channel_processing_for_hr(data: BaseRaw, short_chans: BaseRaw | None, seconds_to_strip_hr: int, verbosity: bool) -> tuple[float, NDArray[float64], NDArray[float64]]: +def _select_hr_source_channels( + channel_data: NDArray[float64], + sfreq: float, + cardiac_band: tuple[float, float] = (0.8, 3.0), +) -> NDArray[float64]: """ - Extract and trim short-channel fNIRS signal for heart rate analysis. + Scores each channel by the fraction of its power spectral density + falling within a plausible cardiac frequency band. Higher scores mean + more of that channel's signal energy sits where a heartbeat would be - + a cheap proxy for cardiac-signal quality that doesn't require peak + detection to already have succeeded. + """ + n_channels = channel_data.shape[0] + scores = np.zeros(n_channels) + nperseg = min(channel_data.shape[1], 2048) + if nperseg < 8: + return scores # too little data for a meaningful PSD + + for ch in range(n_channels): + freqs, psd = welch(channel_data[ch], fs=sfreq, nperseg=nperseg) + band_mask = (freqs >= cardiac_band[0]) & (freqs <= cardiac_band[1]) + total_power = np.sum(psd) + scores[ch] = np.sum(psd[band_mask]) / total_power if total_power > 0 else 0.0 + + return scores + + + +def short_channel_processing_for_hr( + data: BaseRaw, + short_chans: BaseRaw | None, + seconds_to_strip_hr: int, + verbosity: bool, + cardiac_band: tuple[float, float] = (0.8, 3.0), + max_channels_used: int = 5, +) -> tuple[float, NDArray[float64], NDArray[float64]]: + """ + Builds a single combined signal for heart rate estimation from ALL + available candidate channels, rather than an arbitrary single channel. + + Prefers short-separation channels (dominated by superficial/systemic + signal, which includes the cardiac pulse, with little brain-hemodynamic + contamination). Falls back to long channels only if no short channels + are available - a weaker source, since long-channel signal is a mix of + systemic AND real task/brain response, so the combined signal there is + more at risk of being pulled around by task-locked activity unrelated + to heart rate. Treat long-channel-derived HR estimates with more + caution than short-channel ones. + + Every candidate channel is scored by how much of its power sits in a + plausible cardiac band (cardiac_band), and the top max_channels_used + channels are combined via a score-weighted average - channels with + more cardiac-band power contribute more, channels with little to none + contribute little to nothing. Parameters ---------- data : BaseRaw - The loaded data object to process. + The loaded data object (used for long-channel fallback and time axis). short_chans : BaseRaw | None Data object with only short separation channels, or None if unavailable. + seconds_to_strip_hr : int + Seconds to trim from each end of the signal to remove edge artifacts. + 0 disables trimming. + cardiac_band : tuple[float, float], default (0.8, 3.0) + Frequency range (Hz) used to score channel quality (48-180 BPM by default). + max_channels_used : int, default 5 + Maximum number of top-scoring channels to combine. Returns ------- tuple[float, NDArray[float64], NDArray[float64]] - float: Sampling frequency of the signal. - - NDArray[float64]: Trimmed short-channel signal. + - NDArray[float64]: Trimmed, channel-combined signal. - NDArray[float64]: Corresponding time values. """ - - # Find the short channel (or best candidate) and extract signal data and sampling frequency - logger.info("Extracting the signal and calculating the sampling frequency...") - - # If a short channel exists, use it for our signal. Otherwise just take the first channel in the data - # TODO: Find a better way around this - if short_chans is not None: - signal = cast(NDArray[float64], short_chans.get_data(picks=[0], verbose=verbosity))[0] # type: ignore + if short_chans is not None and len(short_chans.ch_names) > 0: + source = short_chans + source_label = "short" else: - signal = cast(NDArray[float64], data.get_data(picks=[0], verbose=verbosity))[0] # type: ignore + logger.warning( + "No short channels available for heart rate estimation - falling back " + "to long channels. Long-channel HR estimates are less reliable, since " + "task/brain-hemodynamic signal can dominate over the cardiac pulse." + ) + source = data + source_label = "long" - # Calculate the sampling frequency - sfreq = cast(int, data.info['sfreq']) + channel_data = cast(NDArray[float64], source.get_data(verbose=verbosity)) + sfreq = cast(float, source.info['sfreq']) + n_available = channel_data.shape[0] - # Trim start and end of the signal to remove edge artifacts - logger.info(f"Removing {seconds_to_strip_hr} seconds from the beginning and end of the file...") - strip_samples = int(sfreq * seconds_to_strip_hr) - signal_trimmed = signal[strip_samples:-strip_samples] - times_trimmed = cast(NDArray[float64], getattr(data, "times"))[strip_samples:-strip_samples] + if n_available == 0: + raise ValueError(f"No channels available in the '{source_label}' source for heart rate estimation.") + + scores = _select_hr_source_channels(channel_data, sfreq, cardiac_band=cardiac_band) + n_use = min(max_channels_used, n_available) + top_idx = np.argsort(scores)[::-1][:n_use] + + used_names = [source.ch_names[i] for i in top_idx] + logger.info( + f"Heart rate: using {n_use}/{n_available} {source_label} channel(s), " + f"selected by cardiac-band power: {list(zip(used_names, np.round(scores[top_idx], 3)))}" + ) + + weights = scores[top_idx] + if weights.sum() > 0: + weights = weights / weights.sum() + else: + logger.warning( + f"All selected {source_label} channels have zero measurable power in " + f"the {cardiac_band} Hz cardiac band - heart rate estimate is unlikely " + f"to be meaningful. Falling back to a plain (unweighted) average." + ) + weights = np.ones(n_use) / n_use + + signal = np.average(channel_data[top_idx, :], axis=0, weights=weights) + + if seconds_to_strip_hr > 0: + strip_samples = int(sfreq * seconds_to_strip_hr) + signal_trimmed = signal[strip_samples:-strip_samples] + times_trimmed = data.times[strip_samples:-strip_samples] + else: + signal_trimmed = signal + times_trimmed = data.times return sfreq, signal_trimmed, times_trimmed @@ -5391,8 +5558,12 @@ def process_participant(file_path, file_start, progress_callback=None): # Step 20: Extracting Events if EVENTS and not FOLDING_BYP: - events, event_dict = events_from_annotations(raw_haemo, event_id=EVENT_ID, regexp=EVENT_REGEX, verbose=VERBOSITY) #TODO: Implement the chunk duration - fig_events = plot_events(events, event_id=event_dict, sfreq=raw_haemo.info["sfreq"], show=False) + if SHORT_CHANNELS: + raw_haemo_evnt = get_long_channels(raw_haemo, min_dist=SHORT_CHANNELS_THRESHOLD, max_dist=LONG_CHANNELS_THRESHOLD) + else: + raw_haemo_evnt = raw_haemo + events, event_dict = events_from_annotations(raw_haemo_evnt, event_id=EVENT_ID, regexp=EVENT_REGEX, verbose=VERBOSITY) #TODO: Implement the chunk duration + fig_events = plot_events(events, event_id=event_dict, sfreq=raw_haemo_evnt.info["sfreq"], show=False) _enqueue("Events", fig_events, png_queue) if progress_callback: progress_callback(20) logger.info("Step 20 Completed.") @@ -5401,7 +5572,7 @@ def process_participant(file_path, file_start, progress_callback=None): # Step 21: Epoch Calculations if EPOCHS and EVENTS and not FOLDING_BYP: epochs = epochs_calculations( - raw_haemo, + raw_haemo_evnt, events, event_dict, epoch_handling=EPOCH_HANDLING, @@ -5508,175 +5679,269 @@ def sanitize_paths_for_pickle(raw_haemo, epochs): +def _check_epoch_frequency_resolution( + epoch_duration: float, + fmin: float, + min_cycles: float = 5.0, + allow_unreliable: bool = False, +) -> None: + required_duration = min_cycles / fmin + if epoch_duration >= required_duration: + return + actual_cycles = epoch_duration * fmin + message = ( + f"Epoch duration ({epoch_duration:.2f}s) gives only {actual_cycles:.2f} cycles " + f"at fmin={fmin} Hz; need >= {min_cycles} cycles ({required_duration:.1f}s epochs) " + f"for a reliable estimate. Below this threshold, the estimate is commonly dominated " + f"by whatever frequency content IS well-resolved (often cardiac pulsation or motion " + f"artifact), producing plausible-looking but spurious results rather than failing loudly." + ) + if allow_unreliable: + logger.warning(f"{message} Proceeding anyway because allow_unreliable=True.") + else: + raise ValueError( + f"{message} Raise fmin to >= {min_cycles / epoch_duration:.3f} Hz for this epoch " + f"length, use longer epochs, or pass allow_unreliable=True to proceed anyway." + ) + + def functional_connectivity_spectral_epochs( epochs: Epochs, n_lines: int, vmin: float, + fmin: float = 0.04, + fmax: float = 0.2, + method: str = "wpli2_debiased", + allow_unreliable: bool = False, + verbose: bool = False ) -> None: + logger.info(f"[spectral_epochs] called (method={method}, fmin={fmin}, fmax={fmax})") + epochs.load_data() hbo_epochs = epochs.copy().pick(picks="hbo") - + + epoch_duration = hbo_epochs.times[-1] - hbo_epochs.times[0] + _check_epoch_frequency_resolution(epoch_duration, fmin, allow_unreliable=allow_unreliable) + con_coh = spectral_connectivity_epochs( - hbo_epochs, - method="coh", - mode="multitaper", - sfreq=hbo_epochs.info["sfreq"], - fmin=0.04, - fmax=0.2, - faverage=True, - verbose=True + hbo_epochs, method=method, mode="fourier", sfreq=hbo_epochs.info["sfreq"], + fmin=fmin, fmax=fmax, faverage=True, verbose=verbose ) coh = np.squeeze(con_coh.get_data(output="dense")) + coh = coh + coh.T - np.diag(np.diag(coh)) np.fill_diagonal(coh, 0) - + + if verbose: + logger.info(f"[{method}] matrix stats: min={coh.min():.6f}, max={coh.max():.6f}, " + f"mean={coh.mean():.6f}, count above vmin({vmin})={np.sum(coh >= vmin)}") + plot_connectivity_circle( - coh, - hbo_epochs.ch_names, - title="fNIRS Functional Connectivity (HbO - Coherence)", - n_lines=n_lines, - vmin=vmin + coh, hbo_epochs.ch_names, + title=f"fNIRS Functional Connectivity (HbO - {method}, {fmin}-{fmax} Hz)", + n_lines=n_lines, vmin=vmin ) + logger.info("[spectral_epochs] finished") def functional_connectivity_envelope( epochs: Epochs, n_lines: int, vmin: float, + fmin: float = 0.04, + fmax: float = 0.2, + orthogonalize: bool = False, + absolute: bool = True, + allow_unreliable: bool = False, + verbose: bool = True, ) -> None: + """ + Compute and plot HbO functional connectivity via envelope correlation. + See DESCRIPTION for full method explanation. + """ + logger.info(f"[envelope] called (fmin={fmin}, fmax={fmax})") + epochs.load_data() hbo_epochs = epochs.copy().pick(picks="hbo") - hbo_epochs.filter(l_freq=0.04, h_freq=0.2, verbose=True) - - env = envelope_correlation( - hbo_epochs.get_data(), - orthogonalize=False, - absolute=True - ) - env_corr = np.mean(env.get_data(output="dense"), axis=0) - env_corr = np.squeeze(env_corr) + + epoch_duration = hbo_epochs.times[-1] - hbo_epochs.times[0] + _check_epoch_frequency_resolution(epoch_duration, fmin, allow_unreliable=allow_unreliable) + + hbo_epochs.filter(l_freq=fmin, h_freq=fmax, verbose=True) # MNE's filter() naming, translated internally + + data = hbo_epochs.get_data() + + env = envelope_correlation(data, orthogonalize=orthogonalize, absolute=absolute, verbose=verbose) + env_data = env.get_data(output="dense") + env_corr = np.squeeze(env_data.mean(axis=0)) np.fill_diagonal(env_corr, 0) - + if verbose: + _log_matrix_diagnostics("envelope", env_corr) + plot_connectivity_circle( env_corr, hbo_epochs.ch_names, - title="fNIRS HbO Envelope Correlation (Task Connectivity)", + title=f"fNIRS HbO Envelope Correlation ({fmin}-{fmax} Hz, " + f"{'orth' if orthogonalize else 'no orth'}, " + f"{'abs' if absolute else 'signed'})", n_lines=n_lines, vmin=vmin ) + logger.info("[envelope] finished") def functional_connectivity_spectral_time( epochs: Epochs, n_lines: int, vmin: float, + fmin: float = 0.04, + fmax: float = 0.2, + n_freqs: int = 10, + cycles_multiplier: float = 2.0, + method: str = "wpli", + allow_unreliable: bool = False, + verbose: bool = True ) -> None: + logger.info(f"[spectral_time] called (method={method}, fmin={fmin}, fmax={fmax})") + epochs.load_data() hbo_epochs = epochs.copy().pick(picks="hbo") - - freqs = np.linspace(0.04, 0.2, 10) - n_cycles = freqs * 2 - + + epoch_duration = hbo_epochs.times[-1] - hbo_epochs.times[0] + _check_epoch_frequency_resolution(epoch_duration, fmin, allow_unreliable=allow_unreliable) + + data = hbo_epochs.get_data() + names = hbo_epochs.ch_names + sfreq = hbo_epochs.info["sfreq"] + + freqs = np.linspace(fmin, fmax, n_freqs) + n_cycles = freqs * cycles_multiplier + con_coh = spectral_connectivity_time( - hbo_epochs.get_data(), - freqs=freqs, - method="coh", - mode="multitaper", - sfreq=hbo_epochs.info["sfreq"], - fmin=0.04, - fmax=0.2, - n_cycles=n_cycles, - faverage=True, - verbose=True + data, freqs=freqs, method=method, mode="multitaper", sfreq=sfreq, + fmin=fmin, fmax=fmax, n_cycles=n_cycles, faverage=True, verbose=verbose ) - coh = np.squeeze(con_coh.get_data(output="dense")) + coh = con_coh.get_data(output="dense").squeeze() if coh.ndim == 3: coh = coh.mean(axis=0) + coh = coh + coh.T - np.diag(np.diag(coh)) np.fill_diagonal(coh, 0) - + + if verbose: + logger.info(f"[{method}] matrix stats: min={coh.min():.6f}, max={coh.max():.6f}, " + f"mean={coh.mean():.6f}, count above vmin({vmin})={np.sum(coh >= vmin)}") + plot_connectivity_circle( - coh, - hbo_epochs.ch_names, - title="fNIRS Functional Connectivity (HbO - Coherence, Time-Resolved)", - n_lines=n_lines, - vmin=vmin + coh, names, + title=f"fNIRS Functional Connectivity (HbO - {method}, Time-Resolved, {fmin}-{fmax} Hz, {n_freqs} bins)", + n_lines=n_lines, vmin=vmin ) + logger.info("[spectral_time] finished") def functional_connectivity_betas( raw_hbo: BaseRaw, n_lines: int, - vmin: float, event_name: str | None = None, *, drift_model: str = "cosine", drift_order: int = 1, + hrf_model: str = "glover", apply_gsr: bool = True, min_effect_size: float = 0.7, alpha: float = 0.05, + resample_freq: float | None = 4.0, + verbose: bool = True, ) -> None: + logger.info(f"[betas] called (hrf_model={hrf_model}, event_name={event_name})") + raw_hbo = raw_hbo.copy().pick(picks="hbo") - ann = raw_hbo.annotations - ann.description = np.array([ - f"{desc}__trial_{i:03d}" for i, desc in enumerate(ann.description) + + if event_name is not None: + keep_mask = [desc == event_name for desc in raw_hbo.annotations.description] + raw_hbo.set_annotations(raw_hbo.annotations[keep_mask]) + + if resample_freq is not None and raw_hbo.info["sfreq"] > resample_freq: + raw_hbo.resample(resample_freq, npad="auto") + + raw_hbo.annotations.description = np.array([ + f"{desc}__trial_{i:03d}" for i, desc in enumerate(raw_hbo.annotations.description) ]) - - design_matrix = make_first_level_design_matrix( - raw=raw_hbo, - hrf_model="fir", - fir_delays=np.arange(0, 12, 1), - drift_model=drift_model, - drift_order=drift_order, - ) - + + design_kwargs = dict(raw=raw_hbo, drift_model=drift_model, drift_order=drift_order) + if hrf_model == "fir": + design_kwargs.update(hrf_model="fir", fir_delays=np.arange(0, 12, 1)) + else: + design_kwargs.update(hrf_model=hrf_model) + + if verbose: + logger.info(f"[betas] building design matrix, n_samples={len(raw_hbo.times)}...") + design_matrix = make_first_level_design_matrix(**design_kwargs) + if verbose: + logger.info(f"[betas] design matrix built: shape={design_matrix.shape}") + glm_results = run_glm(raw_hbo, design_matrix) betas = np.array(glm_results.theta()) if betas.ndim == 3 and betas.shape[-1] == 1: betas = betas.squeeze(axis=-1) - + reg_names = list(design_matrix.columns) n_channels = betas.shape[0] - + assert betas.shape[1] == len(reg_names), ( + f"betas has {betas.shape[1]} columns but design matrix has " + f"{len(reg_names)} regressors (betas.shape={betas.shape})" + ) + trial_tags = sorted({ - col.split("_delay")[0] + (col.split("_delay")[0] if "_delay" in col else col) for col in reg_names - if ("__trial_" in col) and (event_name is None or col.startswith(event_name + "__")) + if ("__trial_" in col) + and (event_name is None or col.startswith(event_name + "__")) }) - + + if len(trial_tags) == 0: + raise ValueError(f"No trials found for event_name={event_name}") if len(trial_tags) < 4: raise ValueError( f"Only {len(trial_tags)} trials found for event_name={event_name}; " "need at least 4 to compute correlation degrees of freedom." ) + + if verbose: + logger.info(f"[betas] trial_tags found: {len(trial_tags)}") beta_series = np.zeros((n_channels, len(trial_tags))) for t_idx, tag in enumerate(trial_tags): - col_idx = [j for j, col in enumerate(reg_names) if col.split("_delay")[0] == tag] + col_idx = [ + j for j, col in enumerate(reg_names) + if (col.split("_delay")[0] if "_delay" in col else col) == tag + ] beta_series[:, t_idx] = betas[:, col_idx].mean(axis=1) - # Vectorized Global Signal Regression (GSR) if apply_gsr: global_signal = np.mean(beta_series, axis=0) - A = np.vstack([global_signal, np.ones(len(global_signal))]).T - # Solve least squares for all channels simultaneously - params, _, _, _ = np.linalg.lstsq(A, beta_series.T, rcond=None) - beta_series_clean = (beta_series.T - A @ params).T + beta_series_clean = np.zeros_like(beta_series) + for i in range(n_channels): + slope, intercept = np.polyfit(global_signal, beta_series[i, :], 1) + beta_series_clean[i, :] = beta_series[i, :] - (slope * global_signal + intercept) + if verbose: + logger.info("[betas] GSR applied") else: beta_series_clean = beta_series n_trials = beta_series_clean.shape[1] corr_matrix = np.corrcoef(beta_series_clean) - - # Safe t-statistic calculation avoiding division by zero on diagonal - corr_clipped = np.clip(corr_matrix, -0.999999, 0.999999) - t_stats = corr_clipped * np.sqrt((n_trials - 2) / (1 - corr_clipped ** 2)) + + with np.errstate(divide="ignore", invalid="ignore"): + t_stats = corr_matrix * np.sqrt((n_trials - 2) / (1 - corr_matrix ** 2)) p_matrix = 2 * t_dist.sf(np.abs(t_stats), df=n_trials - 2) np.fill_diagonal(p_matrix, 1.0) triu = np.triu_indices(n_channels, k=1) flat_p = p_matrix[triu] - reject, _, _, _ = multipletests(flat_p, method="fdr_bh", alpha=alpha) + reject, _ = multipletests(flat_p, method="fdr_bh", alpha=alpha)[:2] sig_corr_matrix = np.zeros_like(corr_matrix) + for idx, is_sig in enumerate(reject): r_val = corr_matrix[triu[0][idx], triu[1][idx]] if is_sig and abs(r_val) > min_effect_size: @@ -5687,12 +5952,30 @@ def functional_connectivity_betas( plot_connectivity_circle( sig_corr_matrix, raw_hbo.ch_names, - title=f"Beta-Series Connectivity (FDR q<{alpha}, |r|>{min_effect_size}, {gsr_tag})", + title=f"Beta-Series Connectivity ({hrf_model}, FDR q<{alpha}, " + f"|r|>{min_effect_size}, {gsr_tag})", n_lines=n_lines, vmin=min_effect_size, vmax=1.0, colormap="hot", ) + logger.info("[betas] finished") + + +def _log_matrix_diagnostics(name: str, mat: np.ndarray) -> None: + """Temporary diagnostic: checks whether a connectivity matrix is fully + populated and symmetric, or only has one triangle filled (the bug found + in the group coherence path).""" + n = mat.shape[0] + total_offdiag = n * n - n + nonzero = np.count_nonzero(mat) + is_symmetric = np.allclose(mat, mat.T) + upper_nonzero = np.count_nonzero(np.triu(mat, k=1)) + lower_nonzero = np.count_nonzero(np.tril(mat, k=-1)) + logger.info( + f"[{name}] shape={mat.shape}, nonzero={nonzero}/{total_offdiag} off-diag cells, " + f"symmetric={is_symmetric}, upper_tri_nonzero={upper_nonzero}, lower_tri_nonzero={lower_nonzero}" + ) @@ -5701,208 +5984,390 @@ def functional_connectivity_betas( +# ============================================================================ +# Shared: channel alignment across participants +# ============================================================================ +def _align_participant_matrices( + subject_results: list[tuple[np.ndarray, list[str]]], +) -> tuple[np.ndarray, list[str], list[int]]: + """ + Given a list of (corr_matrix, ch_names) per participant, find the + channel set common to ALL participants, reindex every matrix to that + common set (same order), and stack into one array. + This is required because participants can legitimately end up with + different channel counts/sets (bad-channel handling, interpolation + failures, short/long trimming differences) - stacking raw matrices + without this step either crashes or silently misaligns channel + positions across participants. + Returns + ------- + stacked : (n_included_participants, n_common_channels, n_common_channels) + common_names : list of channel names, in the order used for stacking + dropped_indices : indices into subject_results that were excluded + because they didn't have all common channels (shouldn't happen + once common_names is the intersection, but guards against + duplicate/malformed names) + """ + if not subject_results: + raise ValueError("No participant results to align - subject_results is empty.") + name_sets = [set(names) for _, names in subject_results] + common = set.intersection(*name_sets) - - -def get_single_subject_beta_corr(raw_hbo, event_name=None, config=None): - """Processes one participant and returns their correlation matrix.""" - raw_hbo = raw_hbo.copy().pick(picks="hbo") - ann = raw_hbo.annotations - - # Rename for trial-level GLM - new_desc = [f"{desc}__trial_{i:03d}" for i, desc in enumerate(ann.description)] - ann.description = np.array(new_desc) - - if config == None: - print("no config") - design_matrix = make_first_level_design_matrix( - raw=raw_hbo, hrf_model='fir', - fir_delays=np.arange(0, 12, 1), - drift_model='cosine', drift_order=1 + if not common: + raise ValueError( + "No channels are common across all selected participants. " + "Check that bad-channel handling / trimming produced consistent " + "channel sets, or select a smaller/more homogeneous participant group." ) + + dropped_channels_per_subject = { + i: name_sets[i] - common for i in range(len(subject_results)) if name_sets[i] - common + } + if dropped_channels_per_subject: + for i, dropped in dropped_channels_per_subject.items(): + logger.warning( + f"Participant {i}: {len(dropped)} channel(s) not shared across " + f"the group, excluded from group analysis: {sorted(dropped)}" + ) + + common_names = sorted(common) # deterministic order + stacked = [] + dropped_indices = [] + + for i, (corr, names) in enumerate(subject_results): + name_to_idx = {n: j for j, n in enumerate(names)} + try: + idx = [name_to_idx[n] for n in common_names] + except KeyError: + dropped_indices.append(i) + continue + reindexed = corr[np.ix_(idx, idx)] + stacked.append(reindexed) + + if not stacked: + raise ValueError("After channel alignment, no participants had usable data.") + + return np.array(stacked), common_names, dropped_indices + + +# ============================================================================ +# Shared: group-level statistics (Fisher-Z average, t-test, FDR) +# ============================================================================ + +def _group_ttest_connectivity( + corr_stack: np.ndarray, + alpha: float, + min_effect_size: float, + min_participants: int = 3, + channel_names: list[str] | None = None, + n_top_pairs: int = 15, +) -> np.ndarray: + n_participants = corr_stack.shape[0] + if n_participants < min_participants: + raise ValueError( + f"Only {n_participants} participant(s) with usable data; need at " + f"least {min_participants} for a group-level test to be meaningful." + ) + + n_channels = corr_stack.shape[1] + z_stack = np.arctanh(np.clip(corr_stack, -0.999, 0.999)) + + t_stats, p_values = ttest_1samp(z_stack, popmean=0, axis=0) + + triu = np.triu_indices(n_channels, k=1) + flat_p = p_values[triu] + + nan_mask = np.isnan(flat_p) + if nan_mask.any(): + logger.warning(f"{nan_mask.sum()} channel pair(s) had zero variance across participants.") + flat_p = np.where(nan_mask, 1.0, flat_p) + + n_tests = len(flat_p) + sorted_p = np.sort(flat_p) + bh_thresholds = np.array([(k + 1) / n_tests * alpha for k in range(n_tests)]) + + # --- Plot 1: histogram with BH threshold + expected-null reference --- + fig1, ax1 = plt.subplots(figsize=(7, 5)) + counts, bins, _ = ax1.hist(flat_p, bins=20, range=(0, 1), color="steelblue", edgecolor="white") + expected_uniform = n_tests / 20 # expected count per bin if p-values were uniform (null) + ax1.axhline(expected_uniform, color="gray", linestyle="--", linewidth=1.5, + label=f"expected under null ({expected_uniform:.1f}/bin)") + ax1.axvline(alpha, color="red", linestyle=":", linewidth=1.5, label=f"alpha={alpha}") + ax1.set_xlabel("raw p-value") + ax1.set_ylabel("count") + ax1.set_title(f"p-value distribution across {n_tests} channel pairs (before FDR)") + ax1.legend() + plt.show(block=False) + + # --- Plot 2: text report card of the smallest p-values, by actual pair --- + avg_r = np.tanh(np.mean(z_stack, axis=0)) + order = np.argsort(flat_p)[:n_top_pairs] + + def _pair_label(flat_idx): + r, c = triu[0][flat_idx], triu[1][flat_idx] + if channel_names is not None: + return f"{channel_names[r]} <-> {channel_names[c]}" + return f"ch{r} <-> ch{c}" + + lines = [ + f"Group connectivity summary", + f"n_participants={n_participants} n_comparisons={n_tests} alpha={alpha}", + f"smallest p-value: {sorted_p[0]:.4f} BH threshold at rank 1: {bh_thresholds[0]:.6f}", + "", + f"Top {n_top_pairs} pairs by raw p-value (uncorrected):", + "-" * 60, + ] + for rank, idx in enumerate(order, start=1): + lines.append( + f"{rank:>2}. {_pair_label(idx):<28} p={flat_p[idx]:.4f} avg_r={avg_r[triu[0][idx], triu[1][idx]]:+.3f}" + ) + + fig2, ax2 = plt.subplots(figsize=(8, 6)) + ax2.axis("off") + ax2.text(0.02, 0.98, "\n".join(lines), va="top", ha="left", family="monospace", fontsize=9) + plt.show(block=False) + + reject, _ = multipletests(flat_p, method="fdr_bh", alpha=alpha)[:2] + logger.info(f"[group-stats] FDR pass: {reject.sum()}/{len(reject)} pairs significant at alpha={alpha}") + + sig_avg_r = np.zeros_like(avg_r) + + for i, is_sig in enumerate(reject): + row, col = triu[0][i], triu[1][i] + r_val = avg_r[row, col] + if is_sig and abs(r_val) >= min_effect_size: + sig_avg_r[row, col] = sig_avg_r[col, row] = r_val + + n_after_effect_size = np.count_nonzero(sig_avg_r) // 2 # matrix is symmetric, count unique pairs + logger.info( + f"[group-stats] after min_effect_size={min_effect_size} filter: " + f"{n_after_effect_size}/{reject.sum()} FDR-significant pairs survive " + f"(max avg_r in final matrix={sig_avg_r.max():.3f} if any)" + ) + + return sig_avg_r + +# ============================================================================ +# Per-subject extractors (raw, UNTHRESHOLDED correlation - group stats +# handle significance, not these) +# ============================================================================ + +def _single_subject_beta_corr( + raw_haemo: BaseRaw, + event_name: str | None, + drift_model: str, + drift_order: int, + hrf_model: str, + apply_gsr: bool, + min_trials: int = 4, + resample_freq: float | None = 4.0, +) -> tuple[np.ndarray | None, list[str] | None]: + raw_hbo = raw_haemo.copy().pick(picks="hbo") + + if event_name is not None: + keep_mask = [desc == event_name for desc in raw_hbo.annotations.description] + raw_hbo.set_annotations(raw_hbo.annotations[keep_mask]) + logger.info(f"[betas] filtered to event '{event_name}': {len(raw_hbo.annotations)} annotations remain") + + if resample_freq is not None and raw_hbo.info["sfreq"] > resample_freq: + logger.info(f"[betas] resampling {raw_hbo.info['sfreq']}Hz -> {resample_freq}Hz") + raw_hbo.resample(resample_freq, npad="auto") + + raw_hbo.annotations.description = np.array([ + f"{desc}__trial_{i:03d}" for i, desc in enumerate(raw_hbo.annotations.description) + ]) + logger.info(f"[betas] annotations rewritten: {len(raw_hbo.annotations)} trial regressors") + + design_kwargs = dict(raw=raw_hbo, drift_model=drift_model, drift_order=drift_order) + if hrf_model == "fir": + design_kwargs.update(hrf_model="fir", fir_delays=np.arange(0, 12, 1)) else: - print("config") - if config.get("SHORT_CHANNEL_REGRESSION") == True: - short_chans = get_short_channels(raw_hbo, max_dist=config.get("SHORT_CHANNELS_THRESHOLD")) - - design_matrix = make_first_level_design_matrix( - raw=raw_hbo, - stim_dur=config.get("STIM_DUR"), - hrf_model=config.get("HRF_MODEL"), - drift_model=config.get("DRIFT_MODEL"), - high_pass=config.get("HIGH_PASS"), - drift_order=config.get("DRIFT_ORDER"), - fir_delays=config.get("FIR_DELAYS"), - add_regs=short_chans.get_data().T, - add_reg_names=short_chans.ch_names, - min_onset=config.get("MIN_ONSET"), - oversampling=config.get("OVERSAMPLING") - ) - print("yep") - else: - design_matrix = make_first_level_design_matrix( - raw=raw_hbo, - stim_dur=config.get("STIM_DUR"), - hrf_model=config.get("HRF_MODEL"), - drift_model=config.get("DRIFT_MODEL"), - high_pass=config.get("HIGH_PASS"), - drift_order=config.get("DRIFT_ORDER"), - fir_delays=config.get("FIR_DELAYS"), - min_onset=config.get("MIN_ONSET"), - oversampling=config.get("OVERSAMPLING") - ) + design_kwargs.update(hrf_model=hrf_model) + logger.info(f"[betas] building design matrix, hrf_model={hrf_model}, n_samples={len(raw_hbo.times)}...") + design_matrix = make_first_level_design_matrix(**design_kwargs) + logger.info(f"[betas] design matrix built: shape={design_matrix.shape}") glm_results = run_glm(raw_hbo, design_matrix) - betas = np.array(glm_results.theta()) + betas = np.array(glm_results.theta()) + if betas.ndim == 3 and betas.shape[-1] == 1: + betas = betas.squeeze(axis=-1) + reg_names = list(design_matrix.columns) n_channels = betas.shape[0] + if betas.shape[1] != len(reg_names): + logger.error(f"[betas] shape mismatch: {betas.shape[1]} vs {len(reg_names)}, skipping.") + return None, None - # Filter trials by event name trial_tags = sorted({ - col.split("_delay")[0] for col in reg_names - if "__trial_" in col and (event_name is None or col.startswith(event_name + "__")) + (col.split("_delay")[0] if "_delay" in col else col) + for col in reg_names + if ("__trial_" in col) and (event_name is None or col.startswith(event_name + "__")) }) - if not trial_tags: + if len(trial_tags) < min_trials: + logger.warning(f"[betas] only {len(trial_tags)} trials, need {min_trials}, skipping.") return None, None - # Build Beta Series beta_series = np.zeros((n_channels, len(trial_tags))) - for t, tag in enumerate(trial_tags): - idx = [i for i, col in enumerate(reg_names) if col.startswith(f"{tag}_delay")] - beta_series[:, t] = np.mean(betas[:, idx], axis=1).flatten() - #beta_series[:, t] = np.max(betas[:, idx], axis=1).flatten() #TODO: Figure out which one to use + for t_idx, tag in enumerate(trial_tags): + col_idx = [j for j, col in enumerate(reg_names) + if (col.split("_delay")[0] if "_delay" in col else col) == tag] + beta_series[:, t_idx] = betas[:, col_idx].mean(axis=1) - # Z-score and GSR (Global Signal Regression) - beta_series = zscore(beta_series, axis=1) - global_signal = np.mean(beta_series, axis=0) - for i in range(n_channels): - slope, _ = np.polyfit(global_signal, beta_series[i, :], 1) - beta_series[i, :] -= (slope * global_signal) + if apply_gsr: + global_signal = np.mean(beta_series, axis=0) + beta_series_clean = np.zeros_like(beta_series) + for i in range(n_channels): + slope, intercept = np.polyfit(global_signal, beta_series[i, :], 1) + beta_series_clean[i, :] = beta_series[i, :] - (slope * global_signal + intercept) + else: + beta_series_clean = beta_series - # Correlation Matrix - corr_matrix = np.corrcoef(beta_series) + corr_matrix = np.corrcoef(beta_series_clean) + np.fill_diagonal(corr_matrix, 0) return corr_matrix, raw_hbo.ch_names +def _single_subject_epoch_coherence( + epochs: Epochs, + event_name: str | None = None, + fmin: float = 0.04, + fmax: float = 0.2, + method: str = "wpli2_debiased", + allow_unreliable: bool = False, +) -> tuple[np.ndarray | None, list[str] | None]: + epochs.load_data() + if event_name is not None: + try: + epochs = epochs[event_name] + except KeyError: + logger.warning(f"[coherence] event '{event_name}' not found, skipping.") + return None, None -def run_group_functional_connectivity( - haemo_dict: dict[str | Path, BaseRaw], - config_dict: dict[str, Any], + logger.info(f"[coherence] n_epochs after event filter: {len(epochs)}") + + hbo_epochs = epochs.copy().pick(picks="hbo") + + epoch_duration = hbo_epochs.times[-1] - hbo_epochs.times[0] + _check_epoch_frequency_resolution(epoch_duration, fmin, allow_unreliable=allow_unreliable) + + con_coh = spectral_connectivity_epochs( + hbo_epochs, method=method, mode="fourier", sfreq=hbo_epochs.info["sfreq"], + fmin=fmin, fmax=fmax, faverage=True, verbose=False, + ) + coh = np.squeeze(con_coh.get_data(output="dense")) + if coh.ndim != 2: + logger.warning("Unexpected coherence shape for participant, skipping.") + return None, None + coh = coh + coh.T - np.diag(np.diag(coh)) + np.fill_diagonal(coh, 0) + return coh, hbo_epochs.ch_names + + +# ============================================================================ +# Group-level entry points +# ============================================================================ + +def run_group_functional_connectivity_betas( + haemo_dict, selected_paths, event_name, n_lines, vmin, + *, drift_model="cosine", drift_order=1, hrf_model="glover", + apply_gsr=True, alpha=0.05, min_participants=3, resample_freq=4.0, +) -> None: + subject_results = [] + for path in selected_paths: + raw = haemo_dict.get(path) + logger.info(f"[group-betas] {path}: raw object id={id(raw)}") + if raw is None: + logger.warning(f"[group-betas] {path}: no haemo data, skipping.") + continue + corr, names = _single_subject_beta_corr(raw, event_name, drift_model, drift_order, hrf_model, apply_gsr, resample_freq=resample_freq) + if corr is not None: + subject_results.append((corr, names)) + logger.info(f"[group-betas] {path}: added, running total={len(subject_results)}") + + if not subject_results: + logger.error("[group-betas] no usable participant data.") + return + + stacked, common_names, dropped = _align_participant_matrices(subject_results) + logger.info(f"[group-betas] aligned: stacked shape={stacked.shape}") + if dropped: + logger.warning(f"[group-betas] {len(dropped)} participant(s) excluded.") + + sig_avg_r = _group_ttest_connectivity(stacked, alpha=alpha, min_effect_size=vmin, min_participants=min_participants, channel_names=common_names) + logger.info(f"[group-betas] stats done. nonzero significant entries={np.count_nonzero(sig_avg_r)}") + + plot_connectivity_circle( + sig_avg_r, common_names, n_lines=n_lines, + title=f"Group Betas Connectivity ({hrf_model}, n={stacked.shape[0]}, FDR q<{alpha}): " + f"{event_name if event_name else 'All Events'}", + vmin=vmin, vmax=1.0, colormap="hot", + ) + + +def run_group_functional_connectivity_epochs( + epochs_dict: dict[str | Path, Epochs], selected_paths: list[str], event_name: str | None, n_lines: int, vmin: float, + *, + fmin: float = 0.04, + fmax: float = 0.2, + method: str = "wpli2_debiased", + alpha: float = 0.05, + min_participants: int = 3, + allow_unreliable: bool = False, ) -> None: - - """Aggregates multiple participants and triggers the plot.""" - all_z_matrices = [] - common_names = None - + logger.info( + f"[group-coherence] START: method={method}, n_lines={n_lines}, vmin={vmin}, " + f"fmin={fmin}, fmax={fmax}, alpha={alpha}, min_participants={min_participants}, " + f"event_name={event_name}, n_selected_paths={len(selected_paths)}" + ) + subject_results = [] for path in selected_paths: - raw = haemo_dict.get(path) - config = config_dict.get(path) - if raw is None: continue - print(config) - - corr, names = get_single_subject_beta_corr(raw, event_name, config) - + epochs = epochs_dict.get(path) + if epochs is None: + continue + corr, names = _single_subject_epoch_coherence( + epochs, event_name=event_name, fmin=fmin, fmax=fmax, + method=method, allow_unreliable=allow_unreliable + ) if corr is not None: - # Fisher Z-transform for averaging - z_mat = np.arctanh(np.clip(corr, -0.99, 0.99)) - all_z_matrices.append(z_mat) - common_names = names + subject_results.append((corr, names)) - # 1. Convert list to 3D array: (Participants, Channels, Channels) - group_z_data = np.array(all_z_matrices) - - print("1") - # 2. Perform a T-Test across the participant dimension (axis 0) - # We test if the mean Z-score is different from 0 - # C:\Users\tyler\Desktop\research\.venv\Lib\site-packages\scipy\stats\_axis_nan_policy.py:611: RuntimeWarning: Precision loss occurred in moment calculation due to catastrophic cancellation. This occurs when the data are nearly identical. Results may be unreliable. - # res = hypotest_fun_out(*samples, axis=axis, **kwds) + if not subject_results: + logger.error("No participants produced usable epoch coherence data for group analysis.") + return - print("--- Variance Check ---") + stacked, common_names, dropped = _align_participant_matrices(subject_results) + if dropped: + logger.warning(f"{len(dropped)} participant(s) excluded during channel alignment.") - # ADD THIS LINE: Define n_channels based on the data shape - # group_z_data.shape is (n_participants, n_channels, n_channels) - n_channels = group_z_data.shape[1] - - variance_matrix = np.var(group_z_data, axis=0) - - # Find where variance is exactly 0 (or very close to it) - zero_var_indices = np.where(variance_matrix < 1e-15) - coords = list(zip(zero_var_indices[0], zero_var_indices[1])) - - diag_count = 0 - non_diag_pairs = [] - - for r, c in coords: - if r == c: - diag_count += 1 - else: - non_diag_pairs.append((r, c)) - - print(f"Total pairs with zero variance: {len(coords)}") - print(f"Identical diagonals: {diag_count}/{n_channels}") - - if non_diag_pairs: - print(f"Warning: {len(non_diag_pairs)} non-diagonal pairs have zero variance!") - for r, c in non_diag_pairs[:10]: # Print first 10 - print(f" - Pair: Channel {r} & Channel {c}") - else: - print("Clean! Zero variance only exists on the diagonals.") - print("----------------------") - - t_stats, p_values = ttest_1samp(group_z_data, popmean=0, axis=0) - print("2") - - # 3. Multiple Comparisons Correction (FDR) - # We only care about the upper triangle (unique connections) - n_channels = p_values.shape[0] - triu_indices = np.triu_indices(n_channels, k=1) - flat_p = p_values[triu_indices] - - reject, corrected_p = multipletests(flat_p, method='fdr_bh', alpha=0.05)[:2] - - # 4. Create the final "Significant" Matrix - avg_r = np.tanh(np.mean(group_z_data, axis=0)) - sig_avg_r = np.zeros_like(avg_r) - - # Only keep connections that are Significant AND above your VMIN (r-threshold) - for idx, is_sig in enumerate(reject): - row, col = triu_indices[0][idx], triu_indices[1][idx] - r_val = avg_r[row, col] - - if is_sig and abs(r_val) >= vmin: - sig_avg_r[row, col] = sig_avg_r[col, row] = r_val - - # 5. Plot the significant results - - - # if not all_z_matrices: - # return - - # # Average and convert back to R - # avg_z = np.mean(all_z_matrices, axis=0) - # avg_r = np.tanh(avg_z) - - # # Thresholding - # avg_r[np.abs(avg_r) < vmin] = 0 + sig_avg_r = _group_ttest_connectivity( + stacked, alpha=alpha, min_effect_size=vmin, min_participants=min_participants, channel_names=common_names + ) plot_connectivity_circle( - sig_avg_r, common_names, n_lines=n_lines, - title=f"Group Connectivity: {event_name if event_name else 'All Events'}", - vmin=vmin, vmax=1.0, colormap='hot' + sig_avg_r, common_names, n_lines=n_lines, + title=f"Group Connectivity ({method}, {fmin}-{fmax} Hz, n={stacked.shape[0]}, FDR q<{alpha})", + vmin=vmin, vmax=1.0, colormap="hot", ) + + + + + + + def sparks_csv_export( haemo_obj: BaseRaw, save_path: str, diff --git a/flares_updater.py b/flares_updater.py index fed014b..7932129 100644 --- a/flares_updater.py +++ b/flares_updater.py @@ -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: diff --git a/pylance_progress b/pylance_progress index 343a387..8d1b38d 100644 --- a/pylance_progress +++ b/pylance_progress @@ -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 \ No newline at end of file +project_manager.py 407 \ No newline at end of file diff --git a/src/analysis/intergroupbrainimage.py b/src/analysis/intergroupbrainimage.py index bd540a3..72ddbdb 100644 --- a/src/analysis/intergroupbrainimage.py +++ b/src/analysis/intergroupbrainimage.py @@ -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): diff --git a/src/analysis/intragroupbrainimage.py b/src/analysis/intragroupbrainimage.py index 4359fde..15f8f0c 100644 --- a/src/analysis/intragroupbrainimage.py +++ b/src/analysis/intragroupbrainimage.py @@ -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}") \ No newline at end of file diff --git a/src/analysis/intragroupfunctionalconnectivity.py b/src/analysis/intragroupfunctionalconnectivity.py index debd0ea..b4b1bbe 100644 --- a/src/analysis/intragroupfunctionalconnectivity.py +++ b/src/analysis/intragroupfunctionalconnectivity.py @@ -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": "", - "default": "20", - "type": int, - }, - { - "key": "vmin", - "label": "", - "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}") \ No newline at end of file diff --git a/src/analysis/intragroupstats.py b/src/analysis/intragroupstats.py index d277b22..ea7d90d 100644 --- a/src/analysis/intragroupstats.py +++ b/src/analysis/intragroupstats.py @@ -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, diff --git a/src/analysis/participantfoldchannels.py b/src/analysis/participantfoldchannels.py index eb34165..24b1437 100644 --- a/src/analysis/participantfoldchannels.py +++ b/src/analysis/participantfoldchannels.py @@ -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"{participant_label}") - # 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;") diff --git a/src/analysis/participantfunctionalconnectivity.py b/src/analysis/participantfunctionalconnectivity.py index 895d138..68f9985 100644 --- a/src/analysis/participantfunctionalconnectivity.py +++ b/src/analysis/participantfunctionalconnectivity.py @@ -23,83 +23,58 @@ from src.shared.shareddata import APP_NAME PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = { - 0: [ - { - "key": "n_lines", - "label": "", - "default": "20", - "type": int, - }, - { - "key": "vmin", - "label": "", - "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": "", - "default": "20", - "type": int, - }, - { - "key": "vmin", - "label": "", - "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": "", - "default": "20", - "type": int, - }, - { - "key": "vmin", - "label": "", - "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": "", - "default": "20", - "type": int, - }, - { - "key": "vmin", - "label": "", - "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}") \ No newline at end of file diff --git a/src/window/updateevents.py b/src/window/updateevents.py index 9ba612c..b81be90 100644 --- a/src/window/updateevents.py +++ b/src/window/updateevents.py @@ -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 diff --git a/src/window/updateoptodes.py b/src/window/updateoptodes.py index 30e421d..5c8be66 100644 --- a/src/window/updateoptodes.py +++ b/src/window/updateoptodes.py @@ -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) \ No newline at end of file diff --git a/src/window/viewerlauncher.py b/src/window/viewerlauncher.py index b737c9f..e975b5a 100644 --- a/src/window/viewerlauncher.py +++ b/src/window/viewerlauncher.py @@ -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("") \ No newline at end of file diff --git a/updater.py b/updater.py index b021476..3778781 100644 --- a/updater.py +++ b/updater.py @@ -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. """