""" Filename: intergroupbrainimage.py Description: Logic for the Inter-Group Brain & Image analysis window Note: Compliant with pylance strict type checking Author: Tyler de Zeeuw License: GPL-3.0 """ # Built-in imports from typing import Any, cast # External library imports from mne.io.base import BaseRaw import pandas as pd from pandas import DataFrame from flares import aggregate_fnirs_group_geometry, plot_2d_3d_contrasts_between_groups from src.shared.flaresbasewidget import InterGroupUIMixin, FlaresBaseWidget from src.shared.shareddata import APP_NAME PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = { 0: [ { "key": "show_optodes", "label": "Determine what is rendered above the brain. Valid values are 'sensors', 'labels', 'none', 'all'.", "default": "all", "type": str, }, { "key": "t_or_theta", "label": "Specify if t values or theta values should be plotted. Valid values are 't', 'theta'", "default": "theta", "type": str, }, { "key": "show_text", "label": "Display informative text on the top left corner about the contrast.", "default": "True", "type": bool, }, { "key": "brain_bounds", "label": "Graph Upper/Lower Limit", "default": "1.0", "type": float, }, { "key": "is_3d", "label": "Should we display the results in a 3D interactive window?", "default": "True", "type": bool, } ], } 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__( self, haemo_dict: dict[str, BaseRaw], df_ind_dict: dict[str, DataFrame], design_matrix_dict: dict[str, DataFrame], contrast_results_dict: dict[str, dict[str, Any]], group_dict: dict[str, str], ) -> None: super().__init__("InterGroupBrainImage") self.setWindowTitle(f"Inter-Group Brain & Image Viewer - {APP_NAME.upper()}") self.haemo_dict = haemo_dict self.df_ind_dict = df_ind_dict self.design_matrix_dict = design_matrix_dict self.contrast_results_dict = contrast_results_dict self.group_dict = group_dict self.setup_inter_group_ui(["0 (Group Contrast 2D/3D)"], placeholder_text=DESCRIPTION) def process_request(self): request = self.get_common_request_data(PARAMETERIZED_INDEXES) if request is None: return (selected_event, file_paths_a, file_paths_b, all_selected_paths, selected_indexes, raw_params) = request param_values = cast(dict[int | str, dict[str, Any]], raw_params) # Build group-level contrast DataFrames def concat_group_contrasts(file_paths: list[str], event: str | None) -> pd.DataFrame: group_df = pd.DataFrame() for fp in file_paths: print(f"Looking up contrast for: {fp}") event_con_dict = self.contrast_results_dict.get(fp, {}) print("Available events for this file:", list(event_con_dict.keys())) if event and event in event_con_dict: df = event_con_dict[event] print(f"Appending contrast df for event: {event}") group_df = pd.concat([group_df, df], ignore_index=True) else: print(f"Event '{event}' not found for {fp}") return group_df print("Selected event:", selected_event) print("File paths A:", file_paths_a) print("File paths B:", file_paths_b) contrast_df_a = concat_group_contrasts(file_paths_a, selected_event) contrast_df_b = concat_group_contrasts(file_paths_b, selected_event) print("contrast_df_a empty?", contrast_df_a.empty) print("contrast_df_b empty?", contrast_df_b.empty) all_raw_objs = [self.haemo_dict.get(fp) for fp in all_selected_paths if self.haemo_dict.get(fp)] if len(all_raw_objs) > 1: processed_raw = aggregate_fnirs_group_geometry(all_raw_objs) elif len(all_raw_objs) == 1 and all_raw_objs[0] is not None: processed_raw = all_raw_objs[0].copy() processed_raw.pick(picks="hbo") # type: ignore else: processed_raw = None # Visualizations for idx in selected_indexes: if idx == 0: params = param_values.get(idx, {}) show_optodes = params.get("show_optodes", None) t_or_theta = params.get("t_or_theta", None) show_text = params.get("show_text", None) brain_bounds = params.get("brain_bounds", None) is_3d = params.get("is_3d", None) if show_optodes is None or t_or_theta is None or show_text is None or brain_bounds is None or is_3d is None: print(f"Missing parameters for index {idx}, skipping.") continue if not contrast_df_a.empty and not contrast_df_b.empty and processed_raw: plot_2d_3d_contrasts_between_groups( contrast_df_a, contrast_df_b, raw_haemo=processed_raw, group_a_name=self.group_a_dropdown.currentText(), group_b_name=self.group_b_dropdown.currentText(), is_3d=is_3d, t_or_theta=t_or_theta, show_optodes=show_optodes, show_text=show_text, brain_bounds=brain_bounds ) else: print(f"No method defined for index {idx}")