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
%{x:.1f}s