Spaces:
Sleeping
Sleeping
| """ | |
| VårdSignal — Care Workflow Intelligence Platform | |
| Operator dashboard demo (Gradio frontend) | |
| Course demonstration only. Not for clinical use. | |
| Synthetic data; per-patient Isolation Forest + clinical rule triggers. | |
| """ | |
| import gradio as gr | |
| import pandas as pd | |
| import numpy as np | |
| import matplotlib.pyplot as plt | |
| import matplotlib.dates as mdates | |
| import pickle | |
| from datetime import datetime, timedelta | |
| from io import BytesIO | |
| from PIL import Image | |
| # ─── Load artifacts ───────────────────────────────────────────── | |
| patients = pd.read_csv("patients.csv") | |
| streams = pd.read_csv("synthetic_streams.csv") | |
| streams["date"] = pd.to_datetime(streams["date"]) | |
| with open("models.pkl", "rb") as f: | |
| models = pickle.load(f) | |
| with open("baselines.pkl", "rb") as f: | |
| baselines = pickle.load(f) | |
| with open("scenario_assignments.pkl", "rb") as f: | |
| scenario_assignments = pickle.load(f) | |
| # ─── Configuration ────────────────────────────────────────────── | |
| DAYS = 90 | |
| TRAIN_WINDOW = DAYS - 14 | |
| EVAL_WINDOW = 14 | |
| FEATURE_COLS = ["resting_hr", "steps", "sleep_hours", "adherence"] | |
| # Tier visual styling — light, calm, professional palette | |
| TIER_STYLE = { | |
| "High risk": {"bg": "#FEF2F2", "fg": "#991B1B", "border": "#DC2626", "icon": "▲"}, | |
| "Watch": {"bg": "#FFFBEB", "fg": "#92400E", "border": "#D97706", "icon": "●"}, | |
| "Stable": {"bg": "#F0FDF4", "fg": "#166534", "border": "#16A34A", "icon": "✓"}, | |
| } | |
| TRIGGER_LABEL = { | |
| "rhr_elevation_3d": "Sustained heart rate elevation (3 days)", | |
| "activity_drop": "Significant activity drop (3-day window)", | |
| "sleep_disturbance": "Sleep disturbance pattern (7-day window)", | |
| "adherence_drop": "Medication adherence collapse", | |
| "possible_fall": "Possible fall pattern detected", | |
| "declining_trend": "Declining activity trend (14 days)", | |
| } | |
| # Colour palette for plots — matches dashboard theme | |
| COL_HR = "#DC2626" | |
| COL_STEPS = "#2563EB" | |
| COL_SLEEP = "#16A34A" | |
| COL_BASE = "#94A3B8" | |
| COL_ALERT = "#F59E0B" | |
| COL_GRID = "#E2E8F0" | |
| plt.rcParams.update({ | |
| "font.family": "sans-serif", | |
| "font.size": 9, | |
| "axes.spines.top": False, | |
| "axes.spines.right": False, | |
| "axes.grid": True, | |
| "grid.alpha": 0.3, | |
| "grid.color": COL_GRID, | |
| "axes.edgecolor": "#CBD5E1", | |
| "axes.labelcolor": "#475569", | |
| "xtick.color": "#64748B", | |
| "ytick.color": "#64748B", | |
| }) | |
| # ─── Inference engine ─────────────────────────────────────────── | |
| def evaluate_patient(pid): | |
| df = streams[streams.patient_id == pid].sort_values("date").reset_index(drop=True) | |
| eval_df = df.iloc[TRAIN_WINDOW:].copy() | |
| iforest, scaler = models[pid] | |
| Xs = scaler.transform(eval_df[FEATURE_COLS].values.astype(float)) | |
| scores = iforest.decision_function(Xs) | |
| iforest_min = float(scores.min()) | |
| iforest_flagged = int((scores < -0.02).sum()) | |
| base = baselines[pid] | |
| rhr_mean, rhr_std = base["resting_hr"] | |
| steps_mean, steps_std = base["steps"] | |
| sleep_mean, sleep_std = base["sleep_hours"] | |
| adh_baseline = base["adherence_rate"] | |
| triggers = [] | |
| recent3 = eval_df.tail(3) | |
| recent7 = eval_df.tail(7) | |
| if (recent3["resting_hr"] > rhr_mean + 2 * rhr_std).all(): | |
| triggers.append("rhr_elevation_3d") | |
| if recent3["steps"].mean() < steps_mean - 1.5 * steps_std: | |
| triggers.append("activity_drop") | |
| if recent7["sleep_hours"].mean() < sleep_mean - 1.5 * sleep_std: | |
| triggers.append("sleep_disturbance") | |
| if recent7["adherence"].mean() < adh_baseline - 0.25: | |
| triggers.append("adherence_drop") | |
| for _, row in recent3.iterrows(): | |
| if (row["steps"] < steps_mean - 3 * steps_std and | |
| row["resting_hr"] > rhr_mean + 2.5 * rhr_std): | |
| triggers.append("possible_fall") | |
| break | |
| eval_steps = eval_df["steps"].values | |
| if len(eval_steps) >= 14: | |
| first_half = eval_steps[:7].mean() | |
| second_half = eval_steps[7:].mean() | |
| if first_half > 0 and (first_half - second_half) / first_half > 0.20: | |
| triggers.append("declining_trend") | |
| if "possible_fall" in triggers: | |
| tier = "High risk" | |
| elif "rhr_elevation_3d" in triggers and "activity_drop" in triggers: | |
| tier = "High risk" | |
| elif iforest_min < -0.10 or iforest_flagged >= 5: | |
| tier = "High risk" | |
| elif len(triggers) >= 1 or iforest_flagged >= 2 or iforest_min < -0.04: | |
| tier = "Watch" | |
| else: | |
| tier = "Stable" | |
| return { | |
| "patient_id": pid, | |
| "tier": tier, | |
| "iforest_min_score": iforest_min, | |
| "iforest_flagged_days": iforest_flagged, | |
| "triggers": triggers, | |
| "scores_per_day": scores, | |
| "eval_df": eval_df, | |
| "train_df": df.iloc[:TRAIN_WINDOW], | |
| "full_df": df, | |
| } | |
| # ─── Pre-compute all evaluations ───────────────────────────────── | |
| print("Evaluating all patients...") | |
| ALL_EVALS = {pid: evaluate_patient(pid) for pid in patients.patient_id} | |
| print(f"Done. {len(ALL_EVALS)} patients scored.") | |
| # ─── Cohort overview table ────────────────────────────────────── | |
| def get_cohort_table(filter_tier="All"): | |
| rows = [] | |
| for pid in patients.patient_id: | |
| ev = ALL_EVALS[pid] | |
| p = patients[patients.patient_id == pid].iloc[0] | |
| if filter_tier != "All" and ev["tier"] != filter_tier: | |
| continue | |
| rows.append({ | |
| "Tier": f'{TIER_STYLE[ev["tier"]]["icon"]} {ev["tier"]}', | |
| "Patient ID": pid, | |
| "Name": p["name"], | |
| "Age": p["age"], | |
| "Municipality": p["municipality"], | |
| "Conditions": p["conditions"], | |
| "Triggers": len(ev["triggers"]), | |
| "Anomaly score": f'{ev["iforest_min_score"]:.3f}', | |
| "Flagged days": ev["iforest_flagged_days"], | |
| "Devices": p["devices"], | |
| }) | |
| df = pd.DataFrame(rows) | |
| if df.empty: | |
| return df | |
| tier_order = {"▲ High risk": 0, "● Watch": 1, "✓ Stable": 2} | |
| df["_o"] = df["Tier"].map(tier_order) | |
| df = df.sort_values("_o").drop("_o", axis=1).reset_index(drop=True) | |
| return df | |
| # ─── Definitions / glossary card ──────────────────────────────── | |
| def get_definitions_card(): | |
| return """ | |
| <div style="background:#F8FAFC; border-radius:6px; | |
| padding:10px 18px 14px; | |
| font-family:'Segoe UI', sans-serif;"> | |
| <div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(280px, 1fr)); | |
| gap:8px 18px; font-size:12.5px; color:#334155; line-height:1.45;"> | |
| <div> | |
| <span style="color:#991B1B; font-weight:600;">▲ High risk</span> — | |
| Possible-fall pattern detected, sustained heart-rate elevation paired with activity drop, | |
| or anomaly score below −0.10. Recommended action: district nurse review within hours. | |
| </div> | |
| <div> | |
| <span style="color:#92400E; font-weight:600;">● Watch</span> — | |
| One or more clinical rule triggers fired, or 2+ flagged days in the evaluation window. | |
| Recommended action: hemtjänst coordinator notes for next visit. | |
| </div> | |
| <div> | |
| <span style="color:#166534; font-weight:600;">✓ Stable</span> — | |
| Patient's recent measurements remain within their personalised baseline. No action required. | |
| </div> | |
| <div> | |
| <strong style="color:#0F172A;">Anomaly score</strong> — Output of the Isolation Forest | |
| model (range approx. −0.2 to +0.3). Lower is more anomalous. The displayed value is | |
| the lowest score across the 14-day evaluation window. | |
| </div> | |
| <div> | |
| <strong style="color:#0F172A;">Flagged days</strong> — Number of days within the | |
| 14-day evaluation window where the anomaly score fell below the watch threshold (−0.02). | |
| </div> | |
| <div> | |
| <strong style="color:#0F172A;">Triggers</strong> — Count of clinical rule triggers | |
| that fired (rate elevation, activity drop, sleep disturbance, possible fall, declining trend, | |
| adherence drop). Listed individually in the Patient Detail tab. | |
| </div> | |
| <div> | |
| <strong style="color:#0F172A;">Devices</strong> — Hardware partners supplying signals | |
| for this patient. VårdSignal aggregates across vendors; the operator chooses devices | |
| per patient based on clinical profile. | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| # ─── Patient detail plot ──────────────────────────────────────── | |
| def render_patient_plot(pid): | |
| ev = ALL_EVALS[pid] | |
| p = patients[patients.patient_id == pid].iloc[0] | |
| full = ev["full_df"] | |
| base = baselines[pid] | |
| rhr_mean, rhr_std = base["resting_hr"] | |
| steps_mean, steps_std = base["steps"] | |
| sleep_mean, sleep_std = base["sleep_hours"] | |
| fig, axes = plt.subplots(4, 1, figsize=(11, 9), sharex=True) | |
| fig.patch.set_facecolor("#FFFFFF") | |
| # eval window highlight (last 14 days) | |
| eval_start = full["date"].iloc[TRAIN_WINDOW] | |
| eval_end = full["date"].iloc[-1] | |
| def add_eval_band(ax, show_label=False): | |
| ax.axvspan(eval_start, eval_end, color="#FEF3C7", alpha=0.55, | |
| label="Evaluation window" if show_label else None) | |
| # 1) Resting HR | |
| ax = axes[0] | |
| add_eval_band(ax, show_label=True) | |
| ax.plot(full["date"], full["resting_hr"], color=COL_HR, lw=1.4) | |
| ax.axhline(rhr_mean, color=COL_BASE, lw=0.9, ls="--", | |
| label=f"Baseline μ ({rhr_mean:.1f} bpm)") | |
| ax.fill_between(full["date"], | |
| rhr_mean - 2 * rhr_std, rhr_mean + 2 * rhr_std, | |
| color=COL_BASE, alpha=0.10, label="±2σ band") | |
| ax.set_ylabel("Resting HR (bpm)", fontsize=9, color="#475569") | |
| ax.legend(loc="upper left", fontsize=7.5, framealpha=0.85, | |
| edgecolor="#E2E8F0") | |
| ax.set_title(f"{p['name']} ({pid}) — {p['age']}-year-old {p['sex']}", | |
| fontsize=11, color="#0F172A", loc="left", weight="bold") | |
| # 2) Steps | |
| ax = axes[1] | |
| add_eval_band(ax) | |
| ax.bar(full["date"], full["steps"], color=COL_STEPS, alpha=0.65, width=0.85) | |
| ax.axhline(steps_mean, color=COL_BASE, lw=0.9, ls="--", | |
| label=f"Baseline μ ({steps_mean:.0f})") | |
| ax.set_ylabel("Daily steps", fontsize=9, color="#475569") | |
| ax.legend(loc="upper left", fontsize=7.5, framealpha=0.85, | |
| edgecolor="#E2E8F0") | |
| # 3) Sleep | |
| ax = axes[2] | |
| add_eval_band(ax) | |
| ax.plot(full["date"], full["sleep_hours"], color=COL_SLEEP, lw=1.4) | |
| ax.axhline(sleep_mean, color=COL_BASE, lw=0.9, ls="--", | |
| label=f"Baseline μ ({sleep_mean:.2f} h)") | |
| ax.set_ylabel("Sleep (hours)", fontsize=9, color="#475569") | |
| ax.legend(loc="upper left", fontsize=7.5, framealpha=0.85, | |
| edgecolor="#E2E8F0") | |
| # 4) Anomaly score (eval window only) | |
| ax = axes[3] | |
| add_eval_band(ax) | |
| eval_dates = ev["eval_df"]["date"].values | |
| scores = ev["scores_per_day"] | |
| colors = ["#DC2626" if s < -0.04 else "#F59E0B" if s < -0.02 else "#94A3B8" | |
| for s in scores] | |
| ax.bar(eval_dates, scores, color=colors, alpha=0.85, width=0.85) | |
| ax.axhline(0, color="#94A3B8", lw=0.6) | |
| ax.axhline(-0.04, color="#DC2626", lw=0.7, ls=":", | |
| label="High-risk threshold") | |
| ax.axhline(-0.02, color="#F59E0B", lw=0.7, ls=":", | |
| label="Watch threshold") | |
| ax.set_ylabel("Anomaly score", fontsize=9, color="#475569") | |
| ax.set_xlabel("Date", fontsize=9, color="#475569") | |
| ax.legend(loc="lower left", fontsize=7.5, framealpha=0.85, | |
| edgecolor="#E2E8F0") | |
| # Explanatory annotation: scores only computed during evaluation window | |
| train_mid = full["date"].iloc[TRAIN_WINDOW // 2] | |
| ymin_ax, ymax_ax = ax.get_ylim() | |
| ax.text(train_mid, (ymin_ax + ymax_ax) / 2, | |
| "Scores are computed only on the 14-day\n" | |
| "evaluation window (yellow band).\n" | |
| "Earlier days were used to learn the\n" | |
| "personalised baseline.", | |
| fontsize=8, color="#64748B", style="italic", | |
| ha="center", va="center", | |
| bbox=dict(boxstyle="round,pad=0.4", fc="#F8FAFC", | |
| ec="#CBD5E1", lw=0.6)) | |
| for ax in axes: | |
| ax.xaxis.set_major_locator(mdates.WeekdayLocator(interval=2)) | |
| ax.xaxis.set_major_formatter(mdates.DateFormatter("%d %b")) | |
| ax.tick_params(axis="x", labelsize=8) | |
| ax.tick_params(axis="y", labelsize=8) | |
| ax.set_facecolor("#FAFAFA") | |
| plt.tight_layout() | |
| buf = BytesIO() | |
| plt.savefig(buf, format="png", dpi=110, bbox_inches="tight", | |
| facecolor="#FFFFFF") | |
| plt.close(fig) | |
| buf.seek(0) | |
| return Image.open(buf) | |
| # ─── Patient detail panel HTML ────────────────────────────────── | |
| def render_patient_panel(pid): | |
| ev = ALL_EVALS[pid] | |
| p = patients[patients.patient_id == pid].iloc[0] | |
| style = TIER_STYLE[ev["tier"]] | |
| base = baselines[pid] | |
| eval_df = ev["eval_df"] | |
| rhr_mean, rhr_std = base["resting_hr"] | |
| steps_mean, _ = base["steps"] | |
| sleep_mean, _ = base["sleep_hours"] | |
| adh_base = base["adherence_rate"] | |
| recent_rhr = eval_df["resting_hr"].tail(3).mean() | |
| recent_steps = eval_df["steps"].tail(3).mean() | |
| recent_sleep = eval_df["sleep_hours"].tail(7).mean() | |
| recent_adh = eval_df["adherence"].tail(7).mean() | |
| def delta(recent, base, unit="", fmt="{:.1f}"): | |
| d = recent - base | |
| sign = "+" if d > 0 else "" | |
| color = "#991B1B" if abs(d) > 0.5 * base * 0.15 else "#475569" | |
| return (f'<span style="color:{color};">' | |
| f'{fmt.format(recent)}{unit} ({sign}{fmt.format(d)})' | |
| f'</span>') | |
| triggers_html = "" | |
| if ev["triggers"]: | |
| triggers_html = "<ul style='margin:6px 0 0 18px; padding:0; color:#475569;'>" | |
| for t in ev["triggers"]: | |
| triggers_html += f"<li style='margin-bottom:3px;'>{TRIGGER_LABEL.get(t, t)}</li>" | |
| triggers_html += "</ul>" | |
| else: | |
| triggers_html = ('<div style="color:#16A34A; font-size:13px; ' | |
| 'margin-top:6px;">No clinical rule triggers fired.</div>') | |
| routing_role = "" | |
| routing_action = "" | |
| if ev["tier"] == "High risk": | |
| routing_role = "District Nurse (HSL)" | |
| routing_action = ("Immediate review; consider home visit within " | |
| "4 hours; document escalation in care record.") | |
| elif ev["tier"] == "Watch": | |
| routing_role = "Hemtjänst Coordinator (SoL)" | |
| routing_action = ("Note in next scheduled visit; flag for review at " | |
| "weekly care meeting; monitor over next 72 hours.") | |
| else: | |
| routing_role = "—" | |
| routing_action = ("No action required; baseline pattern within expected " | |
| "individual variation.") | |
| html = f""" | |
| <div style="font-family:'Segoe UI','SF Pro Text',-apple-system,sans-serif;"> | |
| <!-- Header card --> | |
| <div style="background:{style['bg']}; border-left:4px solid {style['border']}; | |
| padding:14px 18px; margin-bottom:14px; border-radius:6px;"> | |
| <div style="display:flex; justify-content:space-between; align-items:center;"> | |
| <div> | |
| <div style="font-size:11px; color:#64748B; letter-spacing:0.4px; | |
| text-transform:uppercase; margin-bottom:2px;"> | |
| Patient {pid} | |
| </div> | |
| <div style="font-size:18px; font-weight:600; color:#0F172A;"> | |
| {p['name']} | |
| </div> | |
| <div style="font-size:13px; color:#475569; margin-top:3px;"> | |
| {p['age']} years · {p['sex']} · {p['municipality']} · | |
| Activity: {p['activity_class']} | |
| </div> | |
| </div> | |
| <div style="text-align:right;"> | |
| <div style="font-size:11px; color:#64748B; letter-spacing:0.4px; | |
| text-transform:uppercase; margin-bottom:2px;"> | |
| Risk tier | |
| </div> | |
| <div style="font-size:20px; font-weight:700; color:{style['fg']};"> | |
| {style['icon']} {ev['tier']} | |
| </div> | |
| </div> | |
| </div> | |
| <div style="font-size:12px; color:#475569; margin-top:8px;"> | |
| <strong>Conditions:</strong> {p['conditions']} | |
| </div> | |
| <div style="font-size:12px; color:#475569; margin-top:4px;"> | |
| <strong>Devices:</strong> {p['devices']} | |
| </div> | |
| </div> | |
| <!-- Recent vs baseline --> | |
| <div style="background:#F8FAFC; border:1px solid #E2E8F0; | |
| border-radius:6px; padding:14px 18px; margin-bottom:14px;"> | |
| <div style="font-size:11px; color:#64748B; letter-spacing:0.4px; | |
| text-transform:uppercase; font-weight:600; margin-bottom:8px;"> | |
| Recent measurements vs personalised baseline | |
| </div> | |
| <table style="width:100%; font-size:13px; color:#0F172A; border-collapse:collapse;"> | |
| <tr style="border-bottom:1px solid #E2E8F0;"> | |
| <td style="padding:6px 0; color:#475569;">Resting HR (3-day avg)</td> | |
| <td style="padding:6px 0; text-align:right;"> | |
| {recent_rhr:.1f} bpm · baseline {rhr_mean:.1f} bpm | |
| </td> | |
| </tr> | |
| <tr style="border-bottom:1px solid #E2E8F0;"> | |
| <td style="padding:6px 0; color:#475569;">Daily steps (3-day avg)</td> | |
| <td style="padding:6px 0; text-align:right;"> | |
| {recent_steps:.0f} · baseline {steps_mean:.0f} | |
| </td> | |
| </tr> | |
| <tr style="border-bottom:1px solid #E2E8F0;"> | |
| <td style="padding:6px 0; color:#475569;">Sleep (7-day avg)</td> | |
| <td style="padding:6px 0; text-align:right;"> | |
| {recent_sleep:.2f} h · baseline {sleep_mean:.2f} h | |
| </td> | |
| </tr> | |
| <tr> | |
| <td style="padding:6px 0; color:#475569;">Adherence (7-day)</td> | |
| <td style="padding:6px 0; text-align:right;"> | |
| {recent_adh*100:.0f}% · baseline {adh_base*100:.0f}% | |
| </td> | |
| </tr> | |
| </table> | |
| </div> | |
| <!-- Detection signals --> | |
| <div style="background:#F8FAFC; border:1px solid #E2E8F0; | |
| border-radius:6px; padding:14px 18px; margin-bottom:14px;"> | |
| <div style="font-size:11px; color:#64748B; letter-spacing:0.4px; | |
| text-transform:uppercase; font-weight:600;"> | |
| Detection signals | |
| </div> | |
| <div style="font-size:12px; color:#475569; margin-top:6px;"> | |
| <strong>Isolation Forest:</strong> | |
| minimum score | |
| <span style="font-family:monospace; color:#0F172A;"> | |
| {ev['iforest_min_score']:.3f} | |
| </span> | |
| · {ev['iforest_flagged_days']} flagged day(s) in 14-day window | |
| </div> | |
| <div style="font-size:12px; color:#475569; margin-top:8px;"> | |
| <strong>Clinical rule triggers:</strong> | |
| {triggers_html} | |
| </div> | |
| </div> | |
| <!-- Routing --> | |
| <div style="background:#FFFFFF; border:1px solid {style['border']}; | |
| border-radius:6px; padding:14px 18px;"> | |
| <div style="font-size:11px; color:#64748B; letter-spacing:0.4px; | |
| text-transform:uppercase; font-weight:600; margin-bottom:6px;"> | |
| Suggested workflow routing | |
| </div> | |
| <div style="font-size:14px; color:#0F172A; font-weight:600; | |
| margin-bottom:4px;"> | |
| → {routing_role} | |
| </div> | |
| <div style="font-size:12px; color:#475569; line-height:1.5;"> | |
| {routing_action} | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| return html | |
| # ─── KPI summary cards ────────────────────────────────────────── | |
| def get_kpis(): | |
| counts = {"High risk": 0, "Watch": 0, "Stable": 0} | |
| for pid in patients.patient_id: | |
| counts[ALL_EVALS[pid]["tier"]] += 1 | |
| cards = [] | |
| for tier, n in counts.items(): | |
| s = TIER_STYLE[tier] | |
| cards.append(f""" | |
| <div style="flex:1; background:{s['bg']}; border-left:4px solid {s['border']}; | |
| border-radius:6px; padding:14px 18px;"> | |
| <div style="font-size:11px; color:#64748B; letter-spacing:0.4px; | |
| text-transform:uppercase; font-weight:600;"> | |
| {s['icon']} {tier} | |
| </div> | |
| <div style="font-size:28px; font-weight:700; color:{s['fg']}; | |
| margin-top:4px;"> | |
| {n} | |
| </div> | |
| <div style="font-size:11px; color:#64748B;"> | |
| of {len(patients)} patients | |
| </div> | |
| </div> | |
| """) | |
| return f'<div style="display:flex; gap:12px; margin:8px 0 16px;">{"".join(cards)}</div>' | |
| # ─── Build Gradio interface ───────────────────────────────────── | |
| LIGHT_THEME = gr.themes.Soft( | |
| primary_hue="blue", | |
| secondary_hue="slate", | |
| neutral_hue="slate", | |
| radius_size="md", | |
| font=[gr.themes.Font("Segoe UI"), gr.themes.Font("SF Pro Text"), | |
| gr.themes.Font("system-ui"), gr.themes.Font("sans-serif")], | |
| ).set( | |
| body_background_fill="#FFFFFF", | |
| background_fill_primary="#FFFFFF", | |
| background_fill_secondary="#F8FAFC", | |
| block_background_fill="#FFFFFF", | |
| block_border_width="1px", | |
| block_border_color="#E2E8F0", | |
| block_label_background_fill="#F8FAFC", | |
| block_label_text_color="#475569", | |
| button_primary_background_fill="#1E40AF", | |
| button_primary_background_fill_hover="#1E3A8A", | |
| button_primary_text_color="#FFFFFF", | |
| ) | |
| CSS = """ | |
| .gradio-container { max-width: 1280px !important; } | |
| .dashboard-header { | |
| background: linear-gradient(90deg, #F8FAFC 0%, #FFFFFF 100%); | |
| border-bottom: 1px solid #E2E8F0; | |
| padding: 20px 24px 16px; | |
| margin: -16px -16px 16px -16px; | |
| } | |
| .dashboard-title { | |
| font-size: 22px; font-weight: 700; color: #0F172A; | |
| margin: 0 0 4px 0; | |
| font-family: 'Segoe UI', 'SF Pro Display', -apple-system, sans-serif; | |
| } | |
| .dashboard-subtitle { | |
| font-size: 13px; color: #64748B; margin: 0; | |
| font-family: 'Segoe UI', 'SF Pro Text', -apple-system, sans-serif; | |
| } | |
| .demo-disclaimer { | |
| background: #FEF3C7; border-left: 3px solid #D97706; | |
| padding: 8px 12px; border-radius: 4px; | |
| font-size: 12px; color: #92400E; | |
| margin-top: 10px; line-height: 1.4; | |
| font-family: 'Segoe UI', sans-serif; | |
| } | |
| table { font-size: 13px !important; } | |
| .tab-nav button { font-size: 14px !important; } | |
| """ | |
| with gr.Blocks(title="VårdSignal Demo") as demo: | |
| gr.HTML(""" | |
| <div class="dashboard-header"> | |
| <div class="dashboard-title">VårdSignal — Care Workflow Intelligence</div> | |
| <div class="dashboard-subtitle"> | |
| Operator dashboard · Vendor-neutral RPM aggregation · Personalised AI baseline learning | |
| </div> | |
| <div class="demo-disclaimer"> | |
| <strong>Demonstration only.</strong> Synthetic data for 50 simulated patients. | |
| Per-patient Isolation Forest with clinical rule triggers. | |
| Not a medical device; not for clinical use. | |
| </div> | |
| </div> | |
| """) | |
| with gr.Tabs(): | |
| # ============= TAB 1: Cohort Overview ============= | |
| with gr.Tab("📋 Cohort Overview"): | |
| with gr.Accordion("ℹ️ How to read this dashboard", open=False): | |
| gr.HTML(get_definitions_card()) | |
| kpi_html = gr.HTML(get_kpis()) | |
| with gr.Row(): | |
| tier_filter = gr.Radio( | |
| choices=["All", "High risk", "Watch", "Stable"], | |
| value="All", | |
| label="Filter by tier", | |
| interactive=True, | |
| ) | |
| cohort_table = gr.Dataframe( | |
| value=get_cohort_table("All"), | |
| interactive=False, | |
| wrap=True, | |
| column_widths=["100px","75px","145px","55px","100px","190px","70px","90px","75px","160px"], | |
| ) | |
| tier_filter.change( | |
| fn=lambda t: get_cohort_table(t), | |
| inputs=tier_filter, | |
| outputs=cohort_table, | |
| ) | |
| # ============= TAB 2: Patient Detail ============= | |
| with gr.Tab("🩺 Patient Detail"): | |
| sorted_pids = sorted( | |
| patients.patient_id.tolist(), | |
| key=lambda p: ( | |
| {"High risk": 0, "Watch": 1, "Stable": 2}[ALL_EVALS[p]["tier"]], | |
| p, | |
| ), | |
| ) | |
| patient_choices = [ | |
| f'{TIER_STYLE[ALL_EVALS[p]["tier"]]["icon"]} {p} — ' | |
| f'{patients[patients.patient_id == p].iloc[0]["name"]}' | |
| for p in sorted_pids | |
| ] | |
| patient_dropdown = gr.Dropdown( | |
| choices=patient_choices, | |
| value=patient_choices[0], | |
| label="Select patient (sorted by tier)", | |
| interactive=True, | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=2): | |
| patient_panel = gr.HTML(render_patient_panel(sorted_pids[0])) | |
| with gr.Column(scale=3): | |
| patient_plot = gr.Image( | |
| value=render_patient_plot(sorted_pids[0]), | |
| label="14-day evaluation window vs personalised baseline", | |
| height=600, | |
| ) | |
| def update_patient(choice): | |
| pid = choice.split()[1] | |
| return render_patient_panel(pid), render_patient_plot(pid) | |
| patient_dropdown.change( | |
| fn=update_patient, | |
| inputs=patient_dropdown, | |
| outputs=[patient_panel, patient_plot], | |
| ) | |
| # ============= TAB 3: About ============= | |
| with gr.Tab("ℹ️ About this demo"): | |
| gr.HTML(""" | |
| <div style="font-family:'Segoe UI', sans-serif; max-width:820px; | |
| line-height:1.6; color:#334155; padding:8px;"> | |
| <h3 style="color:#0F172A; margin-top:0;">What this demo shows</h3> | |
| <p> | |
| A simulation of the VårdSignal platform's AI-driven care workflow | |
| intelligence layer. The system aggregates physiological and behavioural | |
| signals from multiple CE-marked clinical-grade RPM devices, learns | |
| each patient's individual baseline over a multi-week onboarding window, | |
| and routes prioritised alerts to the appropriate caregiver role under | |
| Sweden's split SoL/HSL care framework. | |
| </p> | |
| <h3 style="color:#0F172A;">Methodology</h3> | |
| <p> | |
| Each of the 50 simulated patients has individualised baseline parameters | |
| drawn from clinically plausible ranges for the 70–95 year-old demographic. | |
| Ninety days of daily aggregate signals (resting heart rate, daily step | |
| count, sleep duration, medication adherence) are generated per patient. | |
| Around 35 per cent of patients have a clinical anomaly scenario injected | |
| into the final 14 days (UTI onset, fall event, gradual decline, | |
| sundowning, adherence collapse). | |
| </p> | |
| <p> | |
| For each patient the platform trains an | |
| <em>Isolation Forest</em> on the first 76 days of normal baseline data, | |
| with a <em>StandardScaler</em> for feature normalisation. The remaining | |
| 14 days are scored against the trained baseline. A complementary set of | |
| <em>clinical rule triggers</em> (sustained HR elevation, large activity | |
| drop, sleep disturbance, possible-fall pattern, declining trend, | |
| adherence collapse) runs in parallel. The risk tier is derived from | |
| the combined output. | |
| </p> | |
| <h3 style="color:#0F172A;">Why this architecture</h3> | |
| <ul> | |
| <li> | |
| <strong>Per-patient personalisation</strong> directly addresses the | |
| alarm fatigue problem (Michels et al. 2025) by replacing | |
| population-level thresholds with individualised baselines. | |
| </li> | |
| <li> | |
| <strong>Isolation Forest + rules</strong> trades raw predictive power | |
| for interpretability. Each alert carries both an ML score and a list | |
| of triggered rules, supporting clinical trust and regulatory defensibility. | |
| </li> | |
| <li> | |
| <strong>Human in the loop.</strong> Tiers and triggers are advisory. | |
| Caregivers receive contextual prompts, not autonomous clinical | |
| determinations — keeping the platform outside EU MDR Class IIa | |
| classification. | |
| </li> | |
| </ul> | |
| <h3 style="color:#0F172A;">What this demo does <em>not</em> claim</h3> | |
| <ul> | |
| <li>Diagnostic intent or clinical validation against real outcomes.</li> | |
| <li>Pharmacological response monitoring (adherence is treated as | |
| behavioural confirmation only).</li> | |
| <li>Production-grade performance on real Patientdatalagen-protected data.</li> | |
| </ul> | |
| <hr style="border:none; border-top:1px solid #E2E8F0; margin:18px 0;"/> | |
| <p style="font-size:12px; color:#64748B;"> | |
| ICT Startups and High-Tech Entrepreneurship · Blekinge Institute of | |
| Technology · Synthetic data only · Demo not for clinical use. | |
| </p> | |
| </div> | |
| """) | |
| if __name__ == "__main__": | |
| demo.launch(server_name="0.0.0.0", server_port=7860, share=False, | |
| theme=LIGHT_THEME, css=CSS) | |