"""UNI Task PSD Explorer: EC / EO / SM condition comparison."""
import numpy as np
import pandas as pd
import plotly.graph_objects as go
from scipy import signal
import gradio as gr
import lcmv_xtra as lx
import logging
from pathlib import Path
from typing import Dict, List, Tuple
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# =============================================================================
# 1. CONFIGURATION & CONSTANTS
# =============================================================================
TENSOR_DIR = Path("./data")
CONDITION_LABELS = {
"ec": "Eyes Closed",
"eo": "Eyes Open",
"sm": "Motor Task",
}
CONDITION_COLORS = {
"ec": "#1F77B4", # Blue
"eo": "#2CA02C", # Green
"sm": "#D62728", # Red
}
PSD_WINDOW_SECONDS: float = 4.0
PSD_OVERLAP_FRACTION: float = 0.75
PSD_EPSILON: float = 1e-15
REFERENCE_BAND_HZ: Tuple[float, float] = (1.0, 4.0)
FREQ_MAX_PLOT_HZ: float = 40.0
PLOT_BANDS = [
(1, 4, 'Delta', '#90B3F9'),
(4, 8, 'Theta', '#FFF9B2'),
(8, 13, 'Alpha', '#AAFCD2'),
(13, 20, 'Low Beta', '#97C2F9'),
(20, 30, 'High Beta', '#90BEF5'),
]
BAND_OPTIONS = {
'Delta (1-4 Hz)': (1, 4),
'Theta (4-8 Hz)': (4, 8),
'Alpha (8-13 Hz)': (8, 13),
'Low Beta (13-20 Hz)': (13, 20),
'High Beta (20-30 Hz)': (20, 30),
'Low Gamma (30-50 Hz)': (30, 50),
}
# =============================================================================
# 2. DATA LOADING & ATLAS MANAGEMENT
# =============================================================================
def load_psd_cache() -> dict:
"""Load single precomputed PSD cache file."""
cache_path = TENSOR_DIR / "psd_cache.npz"
cache = np.load(cache_path, allow_pickle=True)
logger.info(
f"Loaded PSD cache: {len(cache['entries'])} entries × "
f"{cache['n_rois']} ROIs × {len(cache['freqs'])} freq bins"
)
return cache
def build_cascading_roi_map(atlas_df: pd.DataFrame) -> Tuple[Dict[str, List[str]], Dict[str, int]]:
"""Parse CIMT atlas DataFrame into cascading dropdown structures."""
required_cols = ['index', 'region_full_name', 'hemisphere', 'functional_system']
assert all(col in atlas_df.columns for col in required_cols), \
f"Atlas missing required columns: {set(required_cols) - set(atlas_df.columns)}"
atlas_df = atlas_df.copy()
atlas_df['display_label'] = atlas_df['region_full_name'] + " (" + atlas_df['hemisphere'].str[0] + ")"
system_to_rois: Dict[str, List[str]] = {}
for system in sorted(atlas_df['functional_system'].unique()):
labels = atlas_df[atlas_df['functional_system'] == system]['display_label'].tolist()
system_to_rois[system] = sorted(labels)
label_to_index: Dict[str, int] = dict(
zip(atlas_df['display_label'], atlas_df['index'].astype(int))
)
logger.info(f"Built cascading map: {len(system_to_rois)} systems, {len(label_to_index)} ROIs")
return system_to_rois, label_to_index
def get_default_roi_state(
system_to_rois: Dict[str, List[str]],
label_to_index: Dict[str, int]
) -> Tuple[str, str, int]:
"""Return (default_system, default_roi_label, default_roi_index)."""
systems = sorted(system_to_rois.keys())
assert len(systems) > 0, "No functional systems found in atlas"
default_system = systems[0]
rois = system_to_rois[default_system]
assert len(rois) > 0, f"No ROIs found in system '{default_system}'"
default_roi = rois[0]
default_index = label_to_index[default_roi]
return default_system, default_roi, default_index
# =============================================================================
# 3. CORE COMPUTATION (REMOVED — now served from cache)
# =============================================================================
# compute_aligned_psd is no longer needed at runtime.
# All PSDs are precomputed in psd_cache.npz.
# =============================================================================
# 4. VISUALIZATION
# =============================================================================
def build_psd_figure(
freqs: np.ndarray,
psd_ec_db: np.ndarray,
psd_eo_db: np.ndarray,
psd_sm_db: np.ndarray,
roi_label: str,
freq_max: float = FREQ_MAX_PLOT_HZ
) -> go.Figure:
"""Construct PSD Plotly figure with band shading (original visual style)."""
fig = go.Figure()
# Band shading with annotations (identical to original)
for f_lo, f_hi, name, color in PLOT_BANDS:
if f_hi <= freq_max:
fig.add_vrect(x0=f_lo, x1=f_hi, fillcolor=color, opacity=0.08, layer="below", line_width=0)
fig.add_annotation(
x=(f_lo + f_hi) / 2, y=0.97, xref="x", yref="paper",
text=f"{name}", showarrow=False,
font=dict(size=10, color='#1E3A5F'), opacity=0.8
)
mask = freqs <= freq_max
traces = [
(CONDITION_LABELS["ec"], psd_ec_db, CONDITION_COLORS["ec"]),
(CONDITION_LABELS["eo"], psd_eo_db, CONDITION_COLORS["eo"]),
(CONDITION_LABELS["sm"], psd_sm_db, CONDITION_COLORS["sm"]),
]
for label, psd_db, color in traces:
fig.add_trace(go.Scatter(
x=freqs[mask], y=psd_db[mask], mode='lines',
name=label, line=dict(color=color, width=2.5),
))
# Identical layout to original
fig.update_layout(
legend=dict(yanchor="top", y=0.99, xanchor="right", x=0.99, font=dict(size=12)),
template='plotly_white', margin=dict(t=80, b=60, l=70, r=30), height=500,
)
fig.update_xaxes(showgrid=True, gridwidth=1, gridcolor='rgba(0,0,0,0.08)')
fig.update_yaxes(showgrid=True, gridwidth=1, gridcolor='rgba(0,0,0,0.08)')
return fig
def build_ratio_figure(
roi_label: str,
band_label: str,
ec_mean: float,
eo_mean: float,
sm_mean: float,
eps: float = PSD_EPSILON
) -> go.Figure:
"""Horizontal bar chart: modulation index relative to EC baseline (original visual style)."""
comparisons = ['Motor Task', 'Eyes Open']
ratio_sm = ((sm_mean - ec_mean) / (sm_mean + ec_mean + eps)) * 100
ratio_eo = ((eo_mean - ec_mean) / (eo_mean + ec_mean + eps)) * 100
values = [ratio_sm, ratio_eo]
colors = [
CONDITION_COLORS["sm"] if ratio_sm >= 0 else CONDITION_COLORS["ec"],
CONDITION_COLORS["eo"] if ratio_eo >= 0 else CONDITION_COLORS["ec"],
]
fig = go.Figure()
fig.add_trace(go.Bar(
y=comparisons, x=values, orientation='h', marker_color=colors,
text=[f'{v:+.1f}%' for v in values], textposition='inside',
textfont=dict(size=12, family='monospace', color='white'), insidetextanchor='middle',
))
# Baseline reference annotations (mirrors original Drug annotation style)
fig.add_annotation(x=1.02, y='MT', xref='paper', yref='y',
text='EC', showarrow=False, font=dict(size=11, color='#333'), xanchor='left')
fig.add_annotation(x=1.02, y='EO', xref='paper', yref='y',
text='EC', showarrow=False, font=dict(size=11, color='#333'), xanchor='left')
# Identical axis/layout styling to original
fig.update_layout(
xaxis=dict(tickfont=dict(size=10), zeroline=True, zerolinewidth=1,
zerolinecolor='#999', showgrid=True, gridwidth=1, gridcolor='rgba(0,0,0,0.06)'),
yaxis=dict(tickfont=dict(size=11, weight='bold'), showgrid=False, zeroline=False, side='left'),
template='plotly_white', height=200, margin=dict(t=50, b=30, l=80, r=60),
)
return fig
# =============================================================================
# 5. GRADIO CALLBACKS (ZERO COMPUTATION — pure cache lookup)
# =============================================================================
def update_psd(roi_label, entry_name, label_to_index, cache):
"""Callback for PSD plot update from precomputed cache."""
if roi_label not in label_to_index:
raise ValueError(f"ROI label '{roi_label}' not found in index map")
idx = label_to_index[roi_label]
entry_idx = np.where(cache['entries'] == entry_name)[0][0]
freqs = cache['freqs']
ec_db = np.nan_to_num(cache['ec_db'][entry_idx, idx, :], nan=0.0)
eo_db = np.nan_to_num(cache['eo_db'][entry_idx, idx, :], nan=0.0)
sm_db = np.nan_to_num(cache['sm_db'][entry_idx, idx, :], nan=0.0)
return build_psd_figure(freqs, ec_db, eo_db, sm_db, roi_label)
def update_ratio(roi_label, band_label, entry_name, label_to_index, cache):
"""Callback for ratio plot from precomputed cache."""
if roi_label not in label_to_index:
raise ValueError(f"ROI label '{roi_label}' not found in index map")
if band_label not in BAND_OPTIONS:
raise ValueError(f"Band label '{band_label}' not found in BAND_OPTIONS")
idx = label_to_index[roi_label]
f_lo, f_hi = BAND_OPTIONS[band_label]
entry_idx = np.where(cache['entries'] == entry_name)[0][0]
freqs = cache['freqs']
ec_db = np.nan_to_num(cache['ec_db'][entry_idx, idx, :], nan=0.0)
eo_db = np.nan_to_num(cache['eo_db'][entry_idx, idx, :], nan=0.0)
sm_db = np.nan_to_num(cache['sm_db'][entry_idx, idx, :], nan=0.0)
band_mask = (freqs >= f_lo) & (freqs <= f_hi)
ec_mean = float(np.mean(10 ** (ec_db[band_mask] / 10)))
eo_mean = float(np.mean(10 ** (eo_db[band_mask] / 10)))
sm_mean = float(np.mean(10 ** (sm_db[band_mask] / 10)))
return build_ratio_figure(roi_label, band_label, ec_mean, eo_mean, sm_mean)
def on_system_change(system, system_to_rois):
"""Update ROI dropdown choices when functional system changes."""
rois = system_to_rois.get(system, [])
new_default = rois[0] if rois else None
return gr.update(choices=rois, value=new_default)
# =============================================================================
# 6. APP INITIALIZATION
# =============================================================================
def create_app():
"""Build and return the Gradio Blocks app. Importable entry point."""
cache = load_psd_cache()
# Load CIMT labels from bundled atlas
import lcmv_xtra
labels_path = Path(lcmv_xtra.__file__).parent / 'data' / 'cimt_atlas' / 'cimt_atlas_labels.csv'
atlas_df = pd.read_csv(labels_path)
SYSTEM_TO_ROIS, LABEL_TO_INDEX = build_cascading_roi_map(atlas_df)
DEFAULT_SYS, DEFAULT_ROI, _ = get_default_roi_state(SYSTEM_TO_ROIS, LABEL_TO_INDEX)
entries = list(cache['entries']) # ["Group Average", "sub-01", "sub-02", ...]
initial_fig = update_psd(DEFAULT_ROI, "Group Average", LABEL_TO_INDEX, cache)
initial_ratio = update_ratio(DEFAULT_ROI, 'Alpha (8-13 Hz)', "Group Average", LABEL_TO_INDEX, cache)
with gr.Blocks(title="UNI Task Atlas Explorer") as app:
gr.Markdown(
"# UNI Task: Full Atlas PSD Explorer\n"
"Interactive delta-aligned PSD analysis across Eyes Closed / Eyes Open / Motor Task conditions"
)
with gr.Row():
with gr.Column(scale=1):
subject_dropdown = gr.Dropdown(
choices=entries,
value="Group Average",
label="Subject",
info="Select individual subject or group average"
)
sys_dropdown = gr.Dropdown(
choices=sorted(SYSTEM_TO_ROIS.keys()),
value=DEFAULT_SYS,
label="Functional System",
info="Select brain network to filter ROIs"
)
roi_dropdown = gr.Dropdown(
choices=SYSTEM_TO_ROIS[DEFAULT_SYS],
value=DEFAULT_ROI,
label="Region of Interest",
info="Select specific anatomical region"
)
band_dropdown = gr.Dropdown(
choices=list(BAND_OPTIONS.keys()),
value='Alpha (8-13 Hz)',
label="Frequency Band",
info="Band-averaged power comparison"
)
ratio_output = gr.Plot(label="Condition Modulation", value=initial_ratio)
with gr.Column(scale=2):
psd_plot = gr.Plot(label="Delta-Aligned PSD", value=initial_fig)
sys_dropdown.change(
fn=lambda s: on_system_change(s, SYSTEM_TO_ROIS),
inputs=sys_dropdown, outputs=roi_dropdown
)
roi_dropdown.change(
fn=lambda r, subj: update_psd(r, subj, LABEL_TO_INDEX, cache),
inputs=[roi_dropdown, subject_dropdown], outputs=psd_plot
)
subject_dropdown.change(
fn=lambda r, subj: update_psd(r, subj, LABEL_TO_INDEX, cache),
inputs=[roi_dropdown, subject_dropdown], outputs=psd_plot
)
roi_dropdown.change(
fn=lambda r, b, subj: update_ratio(r, b, subj, LABEL_TO_INDEX, cache),
inputs=[roi_dropdown, band_dropdown, subject_dropdown], outputs=ratio_output
)
band_dropdown.change(
fn=lambda r, b, subj: update_ratio(r, b, subj, LABEL_TO_INDEX, cache),
inputs=[roi_dropdown, band_dropdown, subject_dropdown], outputs=ratio_output
)
subject_dropdown.change(
fn=lambda r, b, subj: update_ratio(r, b, subj, LABEL_TO_INDEX, cache),
inputs=[roi_dropdown, band_dropdown, subject_dropdown], outputs=ratio_output
)
return app
if __name__ == "__main__":
app = create_app()
app.launch(theme=gr.themes.Soft(), css=".gradio-container { max-width: 1200px !important; }")