VardSignals / app.py
MUmairAB's picture
Minor UI changes made
13fe836
"""
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)