from __future__ import annotations from dataclasses import dataclass from html import escape from pathlib import Path from typing import Any, Iterable import numpy as np import pandas as pd import plotly.graph_objects as go from plotly.subplots import make_subplots @dataclass(frozen=True) class AnalysisResult: timeline: pd.DataFrame predictions: np.ndarray def _robust_score(values: np.ndarray) -> np.ndarray: """Map a signal to 0-100 while limiting the influence of outliers.""" values = np.asarray(values, dtype=np.float64) if values.size == 0: return values finite = np.isfinite(values) if not finite.any(): return np.zeros_like(values) fill = float(np.nanmedian(values[finite])) clean = np.where(finite, values, fill) low, high = np.percentile(clean, [5, 95]) if np.isclose(low, high): return np.full_like(clean, 50.0) return np.clip((clean - low) / (high - low) * 100.0, 0.0, 100.0) def _segment_time(segment: Any, fallback: float) -> float: for name in ("start", "onset", "offset"): value = getattr(segment, name, None) if value is not None: try: return float(value) except (TypeError, ValueError): pass return fallback def analyze_predictions( predictions: np.ndarray, segments: Iterable[Any] | None = None, tr: float = 1.0 ) -> AnalysisResult: """Create descriptive, non-clinical metrics from TRIBE cortical predictions.""" preds = np.asarray(predictions, dtype=np.float32) if preds.ndim != 2 or min(preds.shape) == 0: raise ValueError("Predictions must have shape (timesteps, cortical_vertices).") segments = list(segments or []) times = np.array( [ _segment_time(segments[i], i * tr) if i < len(segments) else i * tr for i in range(len(preds)) ], dtype=np.float64, ) response_energy = np.sqrt(np.mean(np.square(preds), axis=1)) delta = np.vstack([np.zeros((1, preds.shape[1]), dtype=preds.dtype), np.diff(preds, axis=0)]) pattern_shift = np.sqrt(np.mean(np.square(delta), axis=1)) response_score = _robust_score(response_energy) shift_score = _robust_score(pattern_shift) persistence_score = ( pd.Series(response_score) .ewm(span=min(7, max(2, len(response_score))), adjust=False) .mean() .to_numpy() ) half = preds.shape[1] // 2 left = np.mean(np.abs(preds[:, :half]), axis=1) right = np.mean(np.abs(preds[:, half : half * 2]), axis=1) hemisphere_balance = np.divide( left - right, left + right, out=np.zeros_like(left), where=(left + right) > 1e-8, ) timeline = pd.DataFrame( { "time_s": np.round(times, 3), "response_intensity": np.round(response_score, 2), "pattern_shift": np.round(shift_score, 2), "response_persistence": np.round(persistence_score, 2), "hemisphere_balance": np.round(hemisphere_balance, 4), } ) return AnalysisResult(timeline=timeline, predictions=preds) def load_events(path: str | Path | None) -> pd.DataFrame: if not path: return pd.DataFrame(columns=["time_s", "event", "category"]) events = pd.read_csv(path) aliases = {"time": "time_s", "timestamp": "time_s", "label": "event"} events = events.rename(columns={k: v for k, v in aliases.items() if k in events}) required = {"time_s", "event"} if not required.issubset(events.columns): raise ValueError("Events CSV needs `time_s` and `event` columns.") events = events.copy() events["time_s"] = pd.to_numeric(events["time_s"], errors="coerce") events["event"] = events["event"].astype(str).str.slice(0, 80) if "category" not in events: events["category"] = "event" events["category"] = events["category"].astype(str).str.slice(0, 40) return events.dropna(subset=["time_s"]).sort_values("time_s").reset_index(drop=True) def align_events(timeline: pd.DataFrame, events: pd.DataFrame) -> pd.DataFrame: output = timeline.copy() output["event"] = "" output["category"] = "" if events.empty or output.empty: return output indices = np.abs( output["time_s"].to_numpy()[:, None] - events["time_s"].to_numpy()[None, :] ).argmin(axis=0) for row_index, (_, event) in zip(indices, events.iterrows()): existing = output.at[row_index, "event"] output.at[row_index, "event"] = " · ".join( value for value in (existing, event["event"]) if value ) output.at[row_index, "category"] = event["category"] return output def build_timeline_figure(timeline: pd.DataFrame, events: pd.DataFrame) -> go.Figure: fig = make_subplots( rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.12, row_heights=[0.72, 0.28], ) traces = [ ("response_intensity", "RESPONSE", "#d9ff43"), ("pattern_shift", "PATTERN SHIFT", "#ff6b4a"), ("response_persistence", "PERSISTENCE", "#d8d4c7"), ] for column, name, color in traces: fig.add_trace( go.Scatter( x=timeline["time_s"], y=timeline[column], name=name, mode="lines", line={"color": color, "width": 2.3}, hovertemplate=f"{name}: %{{y:.1f}}
%{{x:.1f}}s", ), row=1, col=1, ) fig.add_trace( go.Scatter( x=timeline["time_s"], y=timeline["hemisphere_balance"], name="L ↔ R BALANCE", mode="lines", fill="tozeroy", line={"color": "#6fa8ff", "width": 1.5}, hovertemplate="Balance: %{y:.3f}
%{x:.1f}s", ), row=2, col=1, ) for _, event in events.head(40).iterrows(): fig.add_vline( x=float(event["time_s"]), line_width=1, line_dash="dot", line_color="rgba(255,255,255,.26)", row=1, col=1, ) fig.add_annotation( x=float(event["time_s"]), y=1.02, yref="paper", text=escape(str(event["event"])), showarrow=False, textangle=-28, font={"size": 9, "color": "#a8a69e"}, ) fig.update_yaxes(range=[0, 105], title="INDEX", row=1, col=1) fig.update_yaxes(range=[-1, 1], title="L / R", zeroline=True, row=2, col=1) fig.update_xaxes(title="CONTENT TIME · SECONDS", row=2, col=1) fig.update_layout( height=560, paper_bgcolor="#0b0d0c", plot_bgcolor="#0b0d0c", font={"family": "Azeret Mono, monospace", "color": "#d8d4c7", "size": 11}, margin={"l": 52, "r": 24, "t": 34, "b": 52}, legend={"orientation": "h", "y": 1.13, "x": 0}, hovermode="x unified", ) fig.update_xaxes(gridcolor="rgba(255,255,255,.07)") fig.update_yaxes(gridcolor="rgba(255,255,255,.07)") return fig def build_summary(timeline: pd.DataFrame, events: pd.DataFrame) -> str: peak_rows = timeline.nlargest(min(3, len(timeline)), "response_intensity") peak_text = ", ".join(f"{row.time_s:.1f}s" for row in peak_rows.itertuples()) mean_response = timeline["response_intensity"].mean() mean_shift = timeline["pattern_shift"].mean() event_note = ( f"{len(events)} gameplay markers aligned." if not events.empty else "No gameplay markers supplied." ) return f"""
MEAN RESPONSE{mean_response:.1f}within-clip index
MEAN SHIFT{mean_shift:.1f}temporal change index
PEAK WINDOWS{peak_text}{event_note}
Interpretation boundary. These are normalized descriptors of TRIBE v2 cortical predictions for an average subject—not measurements of dopamine, addiction, emotion, or an individual player. Calibrate those labels against consented EEG, EDA, eye-tracking, and behavioral data before using them.
"""