cortexlab-dashboard / pages /3_Temporal_Dynamics.py
siddhant-rajhans
Complete dashboard redesign: futuristic glassmorphism UI
8643122
"""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)
""")