"""Temporal Dynamics - Research Grade.""" import streamlit as st import numpy as np import pandas as pd import plotly.graph_objects as go from plotly.subplots import make_subplots from session import init_session, log_analysis, get_carried_rois, download_csv_button, show_analysis_log from theme import inject_theme, section_header from utils import make_roi_indices, peak_latency, temporal_correlation, decompose_response, ROI_GROUPS, ALL_ROIS from synthetic import generate_realistic_predictions, generate_correlated_features st.set_page_config(page_title="Temporal Dynamics", page_icon="⏱️", layout="wide") init_session() inject_theme() show_analysis_log() st.title("⏱️ Temporal Dynamics") st.markdown("Analyze how brain responses evolve over time, including processing hierarchy and temporal coupling with model features.") # --- Sidebar --- with st.sidebar: st.header("Configuration") stim_type = st.selectbox("Stimulus type", ["visual", "auditory", "language", "multimodal"], index=["visual", "auditory", "language", "multimodal"].index(st.session_state.get("stimulus_type", "visual"))) n_timepoints = st.slider("Duration (TRs)", 30, 200, 80) tr_seconds = st.slider("TR (seconds)", 0.5, 2.0, 1.0, 0.1) seed = st.number_input("Seed", value=42, min_value=0) st.subheader("ROI Selection") carried = get_carried_rois() use_carried = False if carried: use_carried = st.checkbox(f"Use {len(carried)} ROIs from Brain Alignment", value=True) if use_carried and carried: selected_rois = carried st.caption(f"Using: {', '.join(selected_rois[:5])}{'...' if len(selected_rois) > 5 else ''}") else: selected_group = st.selectbox("Region group", list(ROI_GROUPS.keys())) available_rois = ROI_GROUPS[selected_group] selected_rois = st.multiselect("ROIs to analyze", available_rois, default=available_rois[:4]) max_lag = st.slider("Max correlation lag (TRs)", 5, 30, 15) cutoff = st.slider("Decomposition cutoff (seconds)", 1.0, 10.0, 4.0, 0.5) if not selected_rois: st.warning("Select at least one ROI.") st.stop() # --- Generate Data --- roi_indices, n_vertices = make_roi_indices() predictions = generate_realistic_predictions(n_timepoints, roi_indices, stim_type, tr_seconds, seed=seed) features = generate_correlated_features(predictions, alignment_strength=0.5, feature_dim=64, seed=seed + 1) log_analysis(f"Temporal dynamics: {stim_type}, {len(selected_rois)} ROIs") time_axis = np.arange(n_timepoints) * tr_seconds colors = ["#00D2FF", "#FF6B6B", "#A29BFE", "#FFEAA7", "#55EFC4", "#FD79A8", "#74B9FF", "#E17055"] # --- Raw ROI Timecourses --- st.subheader("Raw ROI Timecourses") st.markdown("Mean absolute activation over time for each selected ROI. Note the hemodynamic response shape after stimulus events.") fig_raw = go.Figure() for i, roi in enumerate(selected_rois): if roi in roi_indices: verts = roi_indices[roi] valid = verts[verts < predictions.shape[1]] if len(valid) > 0: tc = np.abs(predictions[:, valid]).mean(axis=1) fig_raw.add_trace(go.Scatter( x=time_axis, y=tc, name=roi, line=dict(color=colors[i % len(colors)], width=2), )) fig_raw.update_layout( xaxis_title="Time (seconds)", yaxis_title="Mean |activation|", height=400, template="plotly_dark", legend=dict(orientation="h", yanchor="bottom", y=1.02), ) st.plotly_chart(fig_raw, use_container_width=True) # --- Peak Latency (sorted = processing hierarchy) --- st.divider() st.subheader("Peak Response Latency (Processing Hierarchy)") st.markdown("ROIs sorted by peak latency reveal the cortical processing hierarchy: early sensory areas respond first, association cortex later.") latency_data = [] for roi in selected_rois: if roi in roi_indices: lat = peak_latency(predictions, roi_indices, roi, tr_seconds) # Determine functional group group = "Other" for g, rois in ROI_GROUPS.items(): if roi in rois: group = g break latency_data.append({"ROI": roi, "Peak Latency (s)": lat, "Group": group}) lat_df = pd.DataFrame(latency_data).sort_values("Peak Latency (s)") group_colors = {"Visual": "#00D2FF", "Auditory": "#FF6B6B", "Language": "#A29BFE", "Executive": "#FFEAA7", "Other": "#888"} col1, col2 = st.columns([2, 1]) with col1: fig_lat = go.Figure(go.Bar( x=lat_df["Peak Latency (s)"], y=lat_df["ROI"], orientation="h", marker_color=[group_colors.get(g, "#888") for g in lat_df["Group"]], )) fig_lat.update_layout( xaxis_title="Time to peak (seconds)", height=max(250, len(selected_rois) * 30), template="plotly_dark", yaxis=dict(autorange="reversed"), ) st.plotly_chart(fig_lat, use_container_width=True) with col2: st.dataframe(lat_df[["ROI", "Peak Latency (s)", "Group"]], use_container_width=True, hide_index=True) download_csv_button(lat_df, "peak_latencies.csv") # --- Lag Correlation with Significance --- st.divider() st.subheader("Temporal Correlation (Brain vs Model Features)") st.markdown("Pearson correlation at different time lags. The peak indicates optimal temporal alignment. " "Gray band shows 95% null range from shuffled data.") lags = np.arange(-max_lag, max_lag + 1) * tr_seconds fig_corr = go.Figure() # Null band (shuffle features, compute correlation envelope) rng = np.random.default_rng(seed) null_corrs = [] for _ in range(50): shuffled = features[rng.permutation(len(features))] for roi in selected_rois[:1]: # Use first ROI for null band if roi in roi_indices: nc = temporal_correlation(predictions, shuffled, roi_indices, roi, max_lag) null_corrs.append(nc) if null_corrs: null_arr = np.array(null_corrs) null_hi = np.percentile(null_arr, 97.5, axis=0) null_lo = np.percentile(null_arr, 2.5, axis=0) fig_corr.add_trace(go.Scatter(x=lags, y=null_hi, mode="lines", line=dict(width=0), showlegend=False)) fig_corr.add_trace(go.Scatter(x=lags, y=null_lo, mode="lines", line=dict(width=0), fill="tonexty", fillcolor="rgba(150,150,150,0.2)", name="95% null range")) # Actual correlations optimal_lags = [] for i, roi in enumerate(selected_rois): if roi in roi_indices: corr = temporal_correlation(predictions, features, roi_indices, roi, max_lag) fig_corr.add_trace(go.Scatter( x=lags, y=corr, name=roi, line=dict(color=colors[i % len(colors)], width=2), )) opt_idx = np.argmax(np.abs(corr)) optimal_lags.append({"ROI": roi, "Optimal Lag (s)": lags[opt_idx], "Max |r|": float(np.abs(corr[opt_idx]))}) fig_corr.add_vline(x=0, line_dash="dash", line_color="gray", opacity=0.5) fig_corr.update_layout( xaxis_title="Lag (seconds)", yaxis_title="Pearson Correlation", height=400, template="plotly_dark", legend=dict(orientation="h", yanchor="bottom", y=1.02), ) st.plotly_chart(fig_corr, use_container_width=True) # --- Optimal Lag Summary --- if optimal_lags: st.subheader("Optimal Lag Summary") opt_df = pd.DataFrame(optimal_lags).sort_values("Max |r|", ascending=False) st.dataframe(opt_df, use_container_width=True, hide_index=True) download_csv_button(opt_df, "optimal_lags.csv") # --- Cross-ROI Lag Matrix --- if len(selected_rois) >= 2: st.divider() st.subheader("Cross-ROI Lag Matrix") st.markdown("Optimal lag between each pair of ROIs. Positive values mean the row ROI leads the column ROI.") n_rois = len(selected_rois) lag_matrix = np.zeros((n_rois, n_rois)) for i, roi_a in enumerate(selected_rois): if roi_a not in roi_indices: continue verts_a = roi_indices[roi_a] valid_a = verts_a[verts_a < predictions.shape[1]] if len(valid_a) == 0: continue tc_a = np.abs(predictions[:, valid_a]).mean(axis=1) for j, roi_b in enumerate(selected_rois): if i == j or roi_b not in roi_indices: continue verts_b = roi_indices[roi_b] valid_b = verts_b[verts_b < predictions.shape[1]] if len(valid_b) == 0: continue tc_b = np.abs(predictions[:, valid_b]).mean(axis=1) # Cross-correlation to find optimal lag corrs_ab = temporal_correlation(predictions, tc_b, roi_indices, roi_a, max_lag) opt_idx = np.argmax(np.abs(corrs_ab)) lag_matrix[i, j] = lags[opt_idx] fig_lagmat = go.Figure(go.Heatmap( z=lag_matrix, x=selected_rois, y=selected_rois, colorscale="RdBu_r", zmid=0, colorbar=dict(title="Lag (s)"), text=np.round(lag_matrix, 1), texttemplate="%{text}", )) fig_lagmat.update_layout(height=400, template="plotly_dark") st.plotly_chart(fig_lagmat, use_container_width=True) # --- Sustained vs Transient --- st.divider() st.subheader("Sustained vs Transient Decomposition") st.markdown("Moving-average filter separates slow sustained responses from fast transient spikes.") roi_for_decomp = st.selectbox("ROI for decomposition", selected_rois) sustained, transient = decompose_response(predictions, roi_indices, roi_for_decomp, cutoff, tr_seconds) original = sustained + transient fig_decomp = make_subplots(rows=3, cols=1, shared_xaxes=True, vertical_spacing=0.06, subplot_titles=("Original Signal", "Sustained Component", "Transient Component")) fig_decomp.add_trace(go.Scatter(x=time_axis, y=original, line=dict(color="#888", width=1.5)), row=1, col=1) fig_decomp.add_trace(go.Scatter(x=time_axis, y=sustained, line=dict(color="#6C5CE7", width=2)), row=2, col=1) fig_decomp.add_trace(go.Scatter(x=time_axis, y=transient, line=dict(color="#FF6B6B", width=1.5)), row=3, col=1) fig_decomp.update_xaxes(title_text="Time (seconds)", row=3, col=1) fig_decomp.update_layout(height=550, template="plotly_dark", showlegend=False) st.plotly_chart(fig_decomp, use_container_width=True) # --- Methodology --- with st.expander("Methodology", expanded=False): st.markdown(""" **Peak Latency** is the time at which mean absolute activation reaches its maximum within an ROI. In real fMRI, early sensory cortex (V1, A1) peaks at ~5-6s post-stimulus due to the hemodynamic response, while association cortex (dlPFC, angular gyrus) peaks ~1-3s later reflecting higher-order processing. **Temporal Correlation** computes Pearson correlation between the ROI timecourse and model feature timecourse at each lag in ``[-max_lag, +max_lag]`` TRs. The lag at maximum absolute correlation reveals the temporal offset at which model and brain are best aligned. **Null significance band** is estimated by shuffling the model features 50 times and computing the lag correlation each time. The 95% envelope of these null correlations provides a significance threshold. **Sustained vs Transient Decomposition** uses a moving-average filter with the specified cutoff period. The sustained component captures slow, maintained responses (e.g., block design activations), while the transient component captures fast, event-related responses. **Cross-ROI Lag Matrix** shows the optimal temporal offset between every pair of ROIs, revealing directional information flow (positive lag = row ROI leads column ROI). **References**: - Boynton et al., 1996, *J Neuroscience* (hemodynamic response function) - Friston et al., 1998, *NeuroImage* (temporal basis functions in fMRI) """)