IDKHowToCodeFr commited on
Commit
8f83768
·
verified ·
1 Parent(s): 604d53b

Upload folder using huggingface_hub

Browse files
frontend/src/app/simulator/page.tsx CHANGED
@@ -1,7 +1,7 @@
1
  "use client";
2
 
3
- import { useState } from "react";
4
- import { Sliders, Activity, Cpu } from "lucide-react";
5
  import ShapChart from "@/components/ShapChart";
6
  import { PatientVitals } from "@/lib/edge/types";
7
  import { evaluateEdge } from "@/lib/edge/inference";
@@ -15,6 +15,25 @@ interface PredictionResult {
15
  weights?: Record<string, number>;
16
  }
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  const DEFAULT_VITALS: PatientVitals = {
19
  Heart_Rate: 75,
20
  SpO2_Level: 98,
@@ -38,6 +57,45 @@ export default function Analysis() {
38
  };
39
  });
40
  const [loading, setLoading] = useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  const handlePredict = async (inputData?: PatientVitals) => {
43
  const targetData = inputData || formData;
@@ -48,9 +106,9 @@ export default function Analysis() {
48
  const apiUrl = process.env.NEXT_PUBLIC_API_URL;
49
  if (apiUrl) {
50
  const res = await fetch(`${apiUrl}/predict`, {
51
- method: 'POST',
52
- headers: { 'Content-Type': 'application/json' },
53
- body: JSON.stringify(targetData)
54
  });
55
  if (res.ok) {
56
  const data = await res.json();
@@ -65,16 +123,7 @@ export default function Analysis() {
65
 
66
  // Client-side Zero-Cloud TinyML Ensemble Inference
67
  try {
68
- const edgeResult = evaluateEdge(targetData);
69
-
70
- setPrediction({
71
- prediction: Boolean(edgeResult.is_at_risk),
72
- prediction_label: edgeResult.label,
73
- probability: edgeResult.confidence,
74
- model_outputs: edgeResult.model_outputs,
75
- model_probs: edgeResult.model_probs,
76
- weights: edgeResult.weights,
77
- });
78
  } catch (e) {
79
  console.error("Local inference error", e);
80
  } finally {
@@ -82,19 +131,27 @@ export default function Analysis() {
82
  }
83
  };
84
 
 
 
 
 
 
 
 
85
  const handleSelectPreset = (presetData: PatientVitals) => {
86
  setFormData({ ...presetData });
87
- handlePredict(presetData);
88
  };
89
 
90
  return (
91
  <main className="min-h-dvh bg-canvas-cream flex flex-col font-mono selection:bg-hazard selection:text-white pb-16">
92
  <div className="max-w-350 mx-auto w-full px-4 sm:px-8 py-10 md:py-12 flex-1 flex flex-col">
 
93
  <header className="mb-8 border-b-4 border-ink pb-8 flex flex-col md:flex-row justify-between md:items-end gap-6">
94
  <div>
95
  <div className="text-[12px] text-hazard font-bold tracking-widest mb-4 flex items-center gap-2 uppercase">
96
  <Sliders size={14} />
97
- MODEL EXPLAINABILITY & SIMULATION BENCH
98
  </div>
99
  <h1 className="text-[clamp(2.5rem,5vw,5.5rem)] leading-[0.9]">
100
  MODEL<br />SIMULATOR
@@ -105,13 +162,54 @@ export default function Analysis() {
105
  ENSEMBLE: 5 MODELS · SHAP EXPLAINABILITY
106
  </div>
107
  <div className="text-[11px] text-slate font-bold uppercase tracking-wider">
108
- Deterministic In-Browser Inference · Zero Cloud Latency
109
  </div>
110
  </div>
111
  </header>
112
 
113
- <div className="crosshair-corner grid grid-cols-1 lg:grid-cols-12 gap-px bg-ink border-2 border-ink items-stretch">
 
 
 
 
 
 
 
 
 
 
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  {/* Input Panel - 6 Columns */}
116
  <div className="lg:col-span-6 bg-canvas-cream flex flex-col justify-between">
117
  <div>
@@ -120,10 +218,20 @@ export default function Analysis() {
120
  <Activity size={16} />
121
  Patient Parameters
122
  </h2>
123
- <span className="text-[10px] text-slate uppercase font-bold">5 Telemetry Inputs</span>
 
 
 
 
 
 
 
 
 
 
124
  </div>
125
 
126
- {/* Quick Clinical Test Vector Presets - Enlarged & Tactile */}
127
  <div className="p-5 sm:p-6 bg-canvas-cream border-b-2 border-ink flex flex-col gap-3">
128
  <div className="text-[11px] font-bold tracking-widest text-slate uppercase flex justify-between items-center">
129
  <span className="text-ink">Clinical Patient Profiles</span>
@@ -135,26 +243,26 @@ export default function Analysis() {
135
  name: "Resting Normal",
136
  tag: "NORMAL",
137
  desc: "Normotensive baseline vitals",
138
- data: { Heart_Rate: 72, SpO2_Level: 98, Systolic_BP: 118, Diastolic_BP: 78, Body_Temp: 37.0 }
139
  },
140
  {
141
  name: "STEMI / Ischemia",
142
  tag: "CRITICAL",
143
  desc: "Acute ischemic anomaly",
144
- data: { Heart_Rate: 128, SpO2_Level: 91, Systolic_BP: 165, Diastolic_BP: 104, Body_Temp: 37.3 }
145
  },
146
  {
147
  name: "Hypoxic Bradycardia",
148
  tag: "WARNING",
149
  desc: "Hypoxemic rhythm depression",
150
- data: { Heart_Rate: 48, SpO2_Level: 82, Systolic_BP: 88, Diastolic_BP: 54, Body_Temp: 35.8 }
151
  },
152
  {
153
  name: "Hypertensive Crisis",
154
  tag: "WARNING",
155
  desc: "Acute arterial pressure surge",
156
- data: { Heart_Rate: 98, SpO2_Level: 96, Systolic_BP: 195, Diastolic_BP: 118, Body_Temp: 37.1 }
157
- }
158
  ].map((preset) => {
159
  const isActive =
160
  formData.Heart_Rate === preset.data.Heart_Rate &&
@@ -202,24 +310,70 @@ export default function Analysis() {
202
  </div>
203
  </div>
204
 
205
- {/* Slider Inputs */}
206
  <div className="p-6 sm:p-8 flex flex-col gap-6">
207
- <InputControl label="Heart Rate" unit="BPM" value={formData.Heart_Rate} min={40} max={200} onChange={(v: number) => setFormData({ ...formData, Heart_Rate: v })} />
208
- <InputControl label="SpO2 Level" unit="%" value={formData.SpO2_Level} min={70} max={100} onChange={(v: number) => setFormData({ ...formData, SpO2_Level: v })} />
209
- <InputControl label="Systolic BP" unit="mmHg" value={formData.Systolic_BP} min={80} max={220} onChange={(v: number) => setFormData({ ...formData, Systolic_BP: v })} />
210
- <InputControl label="Diastolic BP" unit="mmHg" value={formData.Diastolic_BP} min={40} max={140} onChange={(v: number) => setFormData({ ...formData, Diastolic_BP: v })} />
211
- <InputControl label="Body Temperature" unit="°C" value={formData.Body_Temp} min={30} max={42} step={0.1} onChange={(v: number) => setFormData({ ...formData, Body_Temp: v })} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  </div>
213
  </div>
214
 
215
- <div className="p-6 sm:p-8 pt-0">
 
 
 
 
 
 
 
 
 
 
216
  <button
217
  type="button"
218
  onClick={() => handlePredict()}
219
  disabled={loading}
220
- className="btn-tactile-hazard py-5 font-bold text-[15px] sm:text-[16px] tracking-[0.2em] uppercase flex items-center justify-center gap-2 w-full cursor-pointer disabled:cursor-not-allowed"
221
  >
222
- {loading ? 'Evaluating Model...' : 'Run Inference'}
223
  </button>
224
  </div>
225
  </div>
@@ -229,48 +383,66 @@ export default function Analysis() {
229
  <div className="h-14 px-4 sm:px-6 border-b-2 border-ink bg-white flex justify-between items-center">
230
  <h2 className="text-[14px] font-bold tracking-wider uppercase flex items-center gap-2">
231
  <Cpu size={16} />
232
- Classification & SHAP Attributions
233
  </h2>
234
  <span className="text-[10px] text-slate uppercase font-bold">Ensemble Explainability</span>
235
  </div>
236
 
237
  <div className="grow flex flex-col relative bg-white">
238
- <div className="absolute inset-0 z-0 opacity-10 pointer-events-none" style={{ backgroundImage: 'linear-gradient(var(--ink-black) 1px, transparent 1px), linear-gradient(90deg, var(--ink-black) 1px, transparent 1px)', backgroundSize: '40px 40px' }}></div>
 
 
 
 
 
 
 
239
 
240
- <div className="z-10 p-8 flex flex-col h-full">
241
  {prediction ? (
242
- <div>
243
- <div className="mb-12">
244
- <div className="text-[11px] font-bold text-slate tracking-widest mb-4">Model Output</div>
 
 
245
 
246
  {(() => {
247
  const getStatusStyles = (label: string) => {
248
  const normalized = (label || "").toLowerCase();
249
- if (normalized === 'healthy' || normalized === 'normal') return "bg-[#d1fae5] text-[#065f46] border-[#065f46]";
250
- if (normalized.includes('heart')) return "bg-[#fef08a] text-[#854d0e] border-[#854d0e]";
251
- if (normalized.includes('asthma')) return "bg-[#e0f2fe] text-[#075985] border-[#075985]";
252
- if (normalized.includes('hypertension')) return "bg-[#f3e8ff] text-[#6b21a8] border-[#6b21a8]";
253
- if (normalized.includes('diabetes')) return "bg-[#ffedd5] text-[#9a3412] border-[#9a3412]";
 
 
 
 
 
254
  return "bg-hazard text-white border-hazard";
255
  };
256
 
257
  return (
258
- <div className={`border-2 p-6 flex flex-col mb-4 ${getStatusStyles(prediction.prediction ? prediction.prediction_label : 'Healthy')}`}>
259
- <div className="font-sans text-[clamp(28px,3vw,56px)] font-black tracking-tighter leading-none mb-2 uppercase wrap-break-words">
260
- {prediction.prediction ? prediction.prediction_label : 'Normal'}
 
 
 
 
261
  </div>
262
- <div className="text-[14px] font-bold tracking-widest">
263
  CONFIDENCE: {(prediction.probability * 100).toFixed(1)}%
264
  </div>
265
  </div>
266
  );
267
  })()}
268
 
269
- {/* Ensemble Diagnostics */}
270
  {prediction.model_outputs && (() => {
271
  const outputs = prediction.model_outputs;
272
  return (
273
- <div className="border-2 border-ink bg-white flex flex-col text-[12px] font-bold uppercase tracking-wider">
274
  <div className="border-b-2 border-ink px-4 py-2 bg-ink text-canvas-cream flex justify-between">
275
  <span>Ensemble Stack Diagnostics</span>
276
  <span className="text-hazard">Weighted Voting</span>
@@ -279,16 +451,20 @@ export default function Analysis() {
279
  {Object.keys(outputs).map((modelName) => (
280
  <div
281
  key={modelName}
282
- className="flex justify-between items-center p-3 hover:bg-canvas-cream transition-colors"
283
  >
284
- <div className="w-[20%] text-slate">{modelName}</div>
285
- <div className={`w-[40%] ${outputs[modelName] !== 'Healthy' ? 'text-hazard' : ''}`}>
 
 
 
 
286
  {outputs[modelName]}
287
  </div>
288
- <div className="w-[20%] text-right">
289
- CONF: {(((prediction.model_probs?.[modelName] ?? prediction.probability ?? 0.95)) * 100).toFixed(1)}%
290
  </div>
291
- <div className="w-[20%] text-right text-slate">
292
  W: {(prediction.weights?.[modelName] ?? 0.25).toFixed(2)}
293
  </div>
294
  </div>
@@ -299,24 +475,27 @@ export default function Analysis() {
299
  })()}
300
  </div>
301
 
302
- <div className="flex-1 border-2 border-ink bg-white p-8 flex flex-col min-h-75">
303
- <div className="text-[11px] font-bold text-slate tracking-widest mb-6 border-b-2 border-ink pb-2">SHAP Feature Attributions</div>
304
- <div className="flex-1">
 
 
 
 
305
  <ShapChart patientData={submittedData} />
306
  </div>
307
  </div>
308
- </div>
309
  ) : (
310
  <div className="h-full flex items-center justify-center">
311
  <div className="text-slate text-[14px] font-bold tracking-widest uppercase border-2 border-slate px-6 py-4">
312
- {loading ? 'Evaluating Model...' : 'Awaiting Input...'}
313
  </div>
314
  </div>
315
  )}
316
  </div>
317
  </div>
318
  </div>
319
-
320
  </div>
321
  </div>
322
  </main>
 
1
  "use client";
2
 
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import { Sliders, Activity, Cpu, Shuffle, Play, Square, Sparkles } from "lucide-react";
5
  import ShapChart from "@/components/ShapChart";
6
  import { PatientVitals } from "@/lib/edge/types";
7
  import { evaluateEdge } from "@/lib/edge/inference";
 
15
  weights?: Record<string, number>;
16
  }
17
 
18
+ // Clinically bounded randomized vitals generator
19
+ function generateRandomVitals(): PatientVitals {
20
+ const hr = Math.floor(Math.random() * (162 - 48 + 1)) + 48; // 48 - 162 BPM
21
+ const spo2 = Math.floor(Math.random() * (99 - 78 + 1)) + 78; // 78 - 99 %
22
+ const sys = Math.floor(Math.random() * (190 - 86 + 1)) + 86; // 86 - 190 mmHg
23
+ const diaMax = Math.min(sys - 18, 120);
24
+ const diaMin = Math.max(Math.min(diaMax - 14, 52), 46);
25
+ const dia = Math.floor(Math.random() * (diaMax - diaMin + 1)) + diaMin;
26
+ const temp = Number((Math.random() * (39.3 - 35.8) + 35.8).toFixed(1));
27
+
28
+ return {
29
+ Heart_Rate: hr,
30
+ SpO2_Level: spo2,
31
+ Systolic_BP: sys,
32
+ Diastolic_BP: dia,
33
+ Body_Temp: temp,
34
+ };
35
+ }
36
+
37
  const DEFAULT_VITALS: PatientVitals = {
38
  Heart_Rate: 75,
39
  SpO2_Level: 98,
 
57
  };
58
  });
59
  const [loading, setLoading] = useState(false);
60
+ const [autoStream, setAutoStream] = useState(false);
61
+
62
+ // Core evaluation function
63
+ const executeEvaluation = useCallback((data: PatientVitals) => {
64
+ const edgeResult = evaluateEdge(data);
65
+ setSubmittedData({ ...data });
66
+ setPrediction({
67
+ prediction: Boolean(edgeResult.is_at_risk),
68
+ prediction_label: edgeResult.label,
69
+ probability: edgeResult.confidence,
70
+ model_outputs: edgeResult.model_outputs,
71
+ model_probs: edgeResult.model_probs,
72
+ weights: edgeResult.weights,
73
+ });
74
+ }, []);
75
+
76
+ // Handler to throw randomized values for all features
77
+ const handleThrowRandomValues = useCallback(() => {
78
+ const randomVitals = generateRandomVitals();
79
+ setFormData(randomVitals);
80
+ executeEvaluation(randomVitals);
81
+ }, [executeEvaluation]);
82
+
83
+ // Throw initial random values on mount so the simulator is always dynamic
84
+ useEffect(() => {
85
+ const timer = setTimeout(() => {
86
+ handleThrowRandomValues();
87
+ }, 50);
88
+ return () => clearTimeout(timer);
89
+ }, [handleThrowRandomValues]);
90
+
91
+ // Continuous auto-streaming simulation mode
92
+ useEffect(() => {
93
+ if (!autoStream) return;
94
+ const interval = setInterval(() => {
95
+ handleThrowRandomValues();
96
+ }, 2400);
97
+ return () => clearInterval(interval);
98
+ }, [autoStream, handleThrowRandomValues]);
99
 
100
  const handlePredict = async (inputData?: PatientVitals) => {
101
  const targetData = inputData || formData;
 
106
  const apiUrl = process.env.NEXT_PUBLIC_API_URL;
107
  if (apiUrl) {
108
  const res = await fetch(`${apiUrl}/predict`, {
109
+ method: "POST",
110
+ headers: { "Content-Type": "application/json" },
111
+ body: JSON.stringify(targetData),
112
  });
113
  if (res.ok) {
114
  const data = await res.json();
 
123
 
124
  // Client-side Zero-Cloud TinyML Ensemble Inference
125
  try {
126
+ executeEvaluation(targetData);
 
 
 
 
 
 
 
 
 
127
  } catch (e) {
128
  console.error("Local inference error", e);
129
  } finally {
 
131
  }
132
  };
133
 
134
+ // Real-time reactive slider adjustment
135
+ const handleSliderChange = (field: keyof PatientVitals, val: number) => {
136
+ const updated = { ...formData, [field]: val };
137
+ setFormData(updated);
138
+ executeEvaluation(updated);
139
+ };
140
+
141
  const handleSelectPreset = (presetData: PatientVitals) => {
142
  setFormData({ ...presetData });
143
+ executeEvaluation(presetData);
144
  };
145
 
146
  return (
147
  <main className="min-h-dvh bg-canvas-cream flex flex-col font-mono selection:bg-hazard selection:text-white pb-16">
148
  <div className="max-w-350 mx-auto w-full px-4 sm:px-8 py-10 md:py-12 flex-1 flex flex-col">
149
+ {/* Monolithic Header */}
150
  <header className="mb-8 border-b-4 border-ink pb-8 flex flex-col md:flex-row justify-between md:items-end gap-6">
151
  <div>
152
  <div className="text-[12px] text-hazard font-bold tracking-widest mb-4 flex items-center gap-2 uppercase">
153
  <Sliders size={14} />
154
+ MODEL EXPLAINABILITY &amp; SIMULATION BENCH
155
  </div>
156
  <h1 className="text-[clamp(2.5rem,5vw,5.5rem)] leading-[0.9]">
157
  MODEL<br />SIMULATOR
 
162
  ENSEMBLE: 5 MODELS · SHAP EXPLAINABILITY
163
  </div>
164
  <div className="text-[11px] text-slate font-bold uppercase tracking-wider">
165
+ Deterministic In-Browser Inference · Dual-Polarity SHAP Signs
166
  </div>
167
  </div>
168
  </header>
169
 
170
+ {/* Global Control Bar for Randomization */}
171
+ <div className="mb-6 border-2 border-ink bg-white p-4 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 shadow-[4px_4px_0px_0px_#141413]">
172
+ <div className="flex items-center gap-2">
173
+ <span className="w-2.5 h-2.5 rounded-full bg-hazard" />
174
+ <span className="text-[12px] font-black uppercase tracking-wider text-ink">
175
+ TELEMETRY GENERATOR
176
+ </span>
177
+ <span className="text-[10px] font-bold text-slate uppercase bg-canvas-cream border border-ink/30 px-2 py-0.5 hidden sm:inline">
178
+ Random Physiologic Vector Sampling
179
+ </span>
180
+ </div>
181
 
182
+ <div className="flex items-center gap-2 w-full sm:w-auto">
183
+ {/* Throw Random Values Button */}
184
+ <button
185
+ type="button"
186
+ onClick={handleThrowRandomValues}
187
+ className="btn-tactile border-2 border-ink bg-hazard text-white hover:bg-black px-4 py-2 text-[12px] font-black uppercase tracking-wider flex items-center justify-center gap-2 transition-colors cursor-pointer flex-1 sm:flex-none"
188
+ title="Throw randomized physiological vitals"
189
+ >
190
+ <Shuffle size={14} />
191
+ <span>Throw Random Values</span>
192
+ </button>
193
+
194
+ {/* Auto Stream Toggle */}
195
+ <button
196
+ type="button"
197
+ onClick={() => setAutoStream(!autoStream)}
198
+ className={`border-2 border-ink px-3 py-2 text-[12px] font-black uppercase tracking-wider flex items-center gap-1.5 transition-colors cursor-pointer ${
199
+ autoStream
200
+ ? "bg-emerald-600 text-white border-emerald-700 animate-pulse"
201
+ : "bg-white text-ink hover:bg-canvas-cream"
202
+ }`}
203
+ title="Continuously throw random vitals every 2.4 seconds"
204
+ >
205
+ {autoStream ? <Square size={13} /> : <Play size={13} />}
206
+ <span>{autoStream ? "Streaming (Active)" : "Auto-Stream"}</span>
207
+ </button>
208
+ </div>
209
+ </div>
210
+
211
+ {/* Main Workbench Grid */}
212
+ <div className="crosshair-corner grid grid-cols-1 lg:grid-cols-12 gap-px bg-ink border-2 border-ink items-stretch">
213
  {/* Input Panel - 6 Columns */}
214
  <div className="lg:col-span-6 bg-canvas-cream flex flex-col justify-between">
215
  <div>
 
218
  <Activity size={16} />
219
  Patient Parameters
220
  </h2>
221
+ <div className="flex items-center gap-2">
222
+ <button
223
+ type="button"
224
+ onClick={handleThrowRandomValues}
225
+ className="text-[10px] font-black uppercase tracking-wider text-hazard border border-hazard px-2 py-0.5 hover:bg-hazard hover:text-white transition-colors cursor-pointer flex items-center gap-1"
226
+ >
227
+ <Sparkles size={11} />
228
+ <span>Randomize</span>
229
+ </button>
230
+ <span className="text-[10px] text-slate uppercase font-bold">5 Telemetry Inputs</span>
231
+ </div>
232
  </div>
233
 
234
+ {/* Quick Clinical Test Vector Presets */}
235
  <div className="p-5 sm:p-6 bg-canvas-cream border-b-2 border-ink flex flex-col gap-3">
236
  <div className="text-[11px] font-bold tracking-widest text-slate uppercase flex justify-between items-center">
237
  <span className="text-ink">Clinical Patient Profiles</span>
 
243
  name: "Resting Normal",
244
  tag: "NORMAL",
245
  desc: "Normotensive baseline vitals",
246
+ data: { Heart_Rate: 72, SpO2_Level: 98, Systolic_BP: 118, Diastolic_BP: 78, Body_Temp: 37.0 },
247
  },
248
  {
249
  name: "STEMI / Ischemia",
250
  tag: "CRITICAL",
251
  desc: "Acute ischemic anomaly",
252
+ data: { Heart_Rate: 128, SpO2_Level: 91, Systolic_BP: 165, Diastolic_BP: 104, Body_Temp: 37.3 },
253
  },
254
  {
255
  name: "Hypoxic Bradycardia",
256
  tag: "WARNING",
257
  desc: "Hypoxemic rhythm depression",
258
+ data: { Heart_Rate: 48, SpO2_Level: 82, Systolic_BP: 88, Diastolic_BP: 54, Body_Temp: 35.8 },
259
  },
260
  {
261
  name: "Hypertensive Crisis",
262
  tag: "WARNING",
263
  desc: "Acute arterial pressure surge",
264
+ data: { Heart_Rate: 98, SpO2_Level: 96, Systolic_BP: 195, Diastolic_BP: 118, Body_Temp: 37.1 },
265
+ },
266
  ].map((preset) => {
267
  const isActive =
268
  formData.Heart_Rate === preset.data.Heart_Rate &&
 
310
  </div>
311
  </div>
312
 
313
+ {/* Slider Inputs with Live Scrubbing */}
314
  <div className="p-6 sm:p-8 flex flex-col gap-6">
315
+ <InputControl
316
+ label="Heart Rate"
317
+ unit="BPM"
318
+ value={formData.Heart_Rate}
319
+ min={40}
320
+ max={200}
321
+ onChange={(v: number) => handleSliderChange("Heart_Rate", v)}
322
+ />
323
+ <InputControl
324
+ label="SpO2 Level"
325
+ unit="%"
326
+ value={formData.SpO2_Level}
327
+ min={70}
328
+ max={100}
329
+ onChange={(v: number) => handleSliderChange("SpO2_Level", v)}
330
+ />
331
+ <InputControl
332
+ label="Systolic BP"
333
+ unit="mmHg"
334
+ value={formData.Systolic_BP}
335
+ min={80}
336
+ max={220}
337
+ onChange={(v: number) => handleSliderChange("Systolic_BP", v)}
338
+ />
339
+ <InputControl
340
+ label="Diastolic BP"
341
+ unit="mmHg"
342
+ value={formData.Diastolic_BP}
343
+ min={40}
344
+ max={140}
345
+ onChange={(v: number) => handleSliderChange("Diastolic_BP", v)}
346
+ />
347
+ <InputControl
348
+ label="Body Temperature"
349
+ unit="°C"
350
+ value={formData.Body_Temp}
351
+ min={30}
352
+ max={42}
353
+ step={0.1}
354
+ onChange={(v: number) => handleSliderChange("Body_Temp", v)}
355
+ />
356
  </div>
357
  </div>
358
 
359
+ <div className="p-6 sm:p-8 pt-0 flex gap-3">
360
+ <button
361
+ type="button"
362
+ onClick={handleThrowRandomValues}
363
+ className="btn-tactile py-4 px-5 border-2 border-ink bg-canvas-cream hover:bg-white text-ink font-bold text-[13px] tracking-wider uppercase flex items-center justify-center gap-2 cursor-pointer transition-colors"
364
+ title="Throw random physiological values"
365
+ >
366
+ <Shuffle size={14} className="text-hazard" />
367
+ <span>Randomize</span>
368
+ </button>
369
+
370
  <button
371
  type="button"
372
  onClick={() => handlePredict()}
373
  disabled={loading}
374
+ className="btn-tactile-hazard py-4 font-bold text-[14px] sm:text-[15px] tracking-[0.2em] uppercase flex items-center justify-center gap-2 flex-1 cursor-pointer disabled:cursor-not-allowed"
375
  >
376
+ {loading ? "Evaluating Model..." : "Re-evaluate Model"}
377
  </button>
378
  </div>
379
  </div>
 
383
  <div className="h-14 px-4 sm:px-6 border-b-2 border-ink bg-white flex justify-between items-center">
384
  <h2 className="text-[14px] font-bold tracking-wider uppercase flex items-center gap-2">
385
  <Cpu size={16} />
386
+ Classification &amp; SHAP Attributions
387
  </h2>
388
  <span className="text-[10px] text-slate uppercase font-bold">Ensemble Explainability</span>
389
  </div>
390
 
391
  <div className="grow flex flex-col relative bg-white">
392
+ <div
393
+ className="absolute inset-0 z-0 opacity-10 pointer-events-none"
394
+ style={{
395
+ backgroundImage:
396
+ "linear-gradient(var(--ink-black) 1px, transparent 1px), linear-gradient(90deg, var(--ink-black) 1px, transparent 1px)",
397
+ backgroundSize: "40px 40px",
398
+ }}
399
+ />
400
 
401
+ <div className="z-10 p-6 sm:p-8 flex flex-col h-full gap-8">
402
  {prediction ? (
403
+ <>
404
+ <div>
405
+ <div className="text-[11px] font-bold text-slate tracking-widest mb-3 uppercase">
406
+ Model Output &amp; Diagnosis
407
+ </div>
408
 
409
  {(() => {
410
  const getStatusStyles = (label: string) => {
411
  const normalized = (label || "").toLowerCase();
412
+ if (normalized === "healthy" || normalized === "normal")
413
+ return "bg-[#d1fae5] text-[#065f46] border-[#065f46]";
414
+ if (normalized.includes("heart"))
415
+ return "bg-[#fef08a] text-[#854d0e] border-[#854d0e]";
416
+ if (normalized.includes("asthma"))
417
+ return "bg-[#e0f2fe] text-[#075985] border-[#075985]";
418
+ if (normalized.includes("hypertension"))
419
+ return "bg-[#f3e8ff] text-[#6b21a8] border-[#6b21a8]";
420
+ if (normalized.includes("diabetes"))
421
+ return "bg-[#ffedd5] text-[#9a3412] border-[#9a3412]";
422
  return "bg-hazard text-white border-hazard";
423
  };
424
 
425
  return (
426
+ <div
427
+ className={`border-2 p-5 sm:p-6 flex flex-col mb-4 ${getStatusStyles(
428
+ prediction.prediction ? prediction.prediction_label : "Healthy"
429
+ )}`}
430
+ >
431
+ <div className="font-sans text-[clamp(28px,3vw,52px)] font-black tracking-tighter leading-none mb-2 uppercase wrap-break-words">
432
+ {prediction.prediction ? prediction.prediction_label : "Normal"}
433
  </div>
434
+ <div className="text-[13px] font-bold tracking-widest">
435
  CONFIDENCE: {(prediction.probability * 100).toFixed(1)}%
436
  </div>
437
  </div>
438
  );
439
  })()}
440
 
441
+ {/* Ensemble Diagnostics Breakdown */}
442
  {prediction.model_outputs && (() => {
443
  const outputs = prediction.model_outputs;
444
  return (
445
+ <div className="border-2 border-ink bg-white flex flex-col text-[11px] font-bold uppercase tracking-wider">
446
  <div className="border-b-2 border-ink px-4 py-2 bg-ink text-canvas-cream flex justify-between">
447
  <span>Ensemble Stack Diagnostics</span>
448
  <span className="text-hazard">Weighted Voting</span>
 
451
  {Object.keys(outputs).map((modelName) => (
452
  <div
453
  key={modelName}
454
+ className="flex justify-between items-center p-2.5 hover:bg-canvas-cream transition-colors"
455
  >
456
+ <div className="w-[25%] text-slate">{modelName}</div>
457
+ <div
458
+ className={`w-[35%] ${
459
+ outputs[modelName] !== "Healthy" ? "text-hazard font-black" : "text-emerald-700"
460
+ }`}
461
+ >
462
  {outputs[modelName]}
463
  </div>
464
+ <div className="w-[20%] text-right tabular-nums">
465
+ {(((prediction.model_probs?.[modelName] ?? prediction.probability ?? 0.95)) * 100).toFixed(1)}%
466
  </div>
467
+ <div className="w-[20%] text-right text-slate tabular-nums">
468
  W: {(prediction.weights?.[modelName] ?? 0.25).toFixed(2)}
469
  </div>
470
  </div>
 
475
  })()}
476
  </div>
477
 
478
+ {/* SHAP Feature Attributions Container */}
479
+ <div className="border-2 border-ink bg-white p-5 sm:p-6 flex flex-col">
480
+ <div className="text-[12px] font-black uppercase text-ink tracking-wider mb-4 border-b-2 border-ink pb-2 flex justify-between items-center">
481
+ <span>SHAP Attributions &amp; Directional Signs</span>
482
+ <span className="text-[10px] text-slate font-mono">Kernel / TreeSHAP ABI</span>
483
+ </div>
484
+ <div className="w-full">
485
  <ShapChart patientData={submittedData} />
486
  </div>
487
  </div>
488
+ </>
489
  ) : (
490
  <div className="h-full flex items-center justify-center">
491
  <div className="text-slate text-[14px] font-bold tracking-widest uppercase border-2 border-slate px-6 py-4">
492
+ {loading ? "Evaluating Model..." : "Awaiting Input..."}
493
  </div>
494
  </div>
495
  )}
496
  </div>
497
  </div>
498
  </div>
 
499
  </div>
500
  </div>
501
  </main>
frontend/src/components/MermaidChart.tsx CHANGED
@@ -168,7 +168,7 @@ export default function MermaidChart({ chart, title = "SYSTEM ARCHITECTURE PIPEL
168
  </div>
169
 
170
  {/* Main View Area */}
171
- <div className={`relative overflow-auto p-4 md:p-6 bg-canvas-cream flex items-center justify-center min-h-[460px] ${isFullscreen ? "flex-1" : ""}`}>
172
  {isLoading && (
173
  <div className="flex flex-col items-center justify-center gap-3 text-slate py-12">
174
  <div className="w-6 h-6 border-2 border-hazard border-t-transparent animate-spin rounded-full" />
 
168
  </div>
169
 
170
  {/* Main View Area */}
171
+ <div className={`relative overflow-auto p-4 md:p-6 bg-canvas-cream flex items-center justify-center min-h-115 ${isFullscreen ? "flex-1" : ""}`}>
172
  {isLoading && (
173
  <div className="flex flex-col items-center justify-center gap-3 text-slate py-12">
174
  <div className="w-6 h-6 border-2 border-hazard border-t-transparent animate-spin rounded-full" />
frontend/src/components/ShapChart.tsx CHANGED
@@ -1,144 +1,201 @@
1
  "use client";
2
 
3
- import { useEffect, useState } from "react";
4
- import { Loader2 } from "lucide-react";
5
  import { PatientVitals } from "@/lib/edge/types";
 
 
6
 
7
- export default function ShapChart({ patientData }: { patientData: PatientVitals | null }) {
8
- const [shapData, setShapData] = useState<{ feature_names: string[]; shap_values: number[] } | null>(null);
9
- const [loading, setLoading] = useState(false);
10
-
11
- useEffect(() => {
12
- if (!patientData) return;
13
-
14
- const fetchExplain = async () => {
15
- setLoading(true);
16
- try {
17
- const apiUrl = process.env.NEXT_PUBLIC_API_URL;
18
- if (apiUrl) {
19
- const res = await fetch(apiUrl + "/explain", {
20
- method: "POST",
21
- headers: { "Content-Type": "application/json" },
22
- body: JSON.stringify(patientData),
23
- });
24
- const data = await res.json();
25
- if (data.shap_values) {
26
- setShapData(data);
27
- return;
28
- }
29
- }
30
- } catch {
31
- // Fall through to client-side Edge SHAP calculation
32
- } finally {
33
- setLoading(false);
34
- }
35
-
36
- // Compute client-side SHAP feature attributions with zero cloud dependency
37
- try {
38
- const { computeEdgeShap } = await import("@/lib/edge/explain");
39
- const edgeShap = computeEdgeShap(patientData);
40
- setShapData(edgeShap);
41
- } catch (e) {
42
- console.error("Local SHAP error", e);
43
- } finally {
44
- setLoading(false);
45
- }
46
- };
47
-
48
- const timeout = setTimeout(fetchExplain, 150);
49
- return () => clearTimeout(timeout);
50
  }, [patientData]);
51
 
52
- if (!shapData) {
53
  return (
54
- <div className="h-full w-full flex flex-col items-center justify-center gap-3">
55
- {loading ? (
56
- <>
57
- <Loader2 className="animate-spin text-ink" size={20} />
58
- <span className="text-slate text-[12px] font-bold uppercase tracking-widest">
59
- COMPUTING_SHAP_VECTORS...
60
- </span>
61
- </>
62
- ) : (
63
- <span className="text-slate text-[12px] font-bold uppercase tracking-widest">
64
- AWAITING_TELEMETRY...
65
- </span>
66
- )}
67
  </div>
68
  );
69
  }
70
 
71
- // Sort by absolute magnitude of SHAP value
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  const items = shapData.feature_names
73
- .map((name, i) => ({
74
- name: name.toUpperCase(),
75
- value: shapData.shap_values[i],
76
- abs: Math.abs(shapData.shap_values[i]),
77
- }))
 
 
 
 
 
 
 
 
78
  .sort((a, b) => b.abs - a.abs);
79
 
80
- const maxAbs = Math.max(...items.map((i) => i.abs), 0.1);
 
 
 
81
 
82
  return (
83
- <div className="flex flex-col gap-4 w-full h-full justify-center">
84
- {/* SHAP Dual-Polarity Axis Legend */}
85
- <div className="flex justify-between items-center text-[10px] font-bold tracking-widest uppercase border-b border-ink/20 pb-2 text-slate">
86
- <span className="flex items-center gap-1 text-ink">
87
- <span className="inline-block w-2 h-2 bg-ink" />
88
- [-] PROTECTIVE
89
- </span>
90
- <span className="text-[9px] font-mono border border-ink/30 px-1.5 py-0.5 bg-canvas-cream">
91
- ZERO_AXIS: 0.00
92
- </span>
93
- <span className="flex items-center gap-1 text-hazard">
94
- <span className="inline-block w-2 h-2 bg-hazard" />
95
- [+] RISK DRIVER
96
- </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  </div>
98
 
99
- {items.map((item) => {
100
- const isPositive = item.value > 0;
101
- const widthPercent = (item.abs / maxAbs) * 50;
102
-
103
- return (
104
- <div key={item.name} className="flex flex-col gap-1.5">
105
- <div className="flex justify-between text-[11px] font-bold tracking-widest">
106
- <span className="text-ink">{item.name}</span>
107
- <span className={`tabular-nums ${isPositive ? "text-hazard font-black" : "text-ink"}`}>
108
- {item.value > 0 ? "+" : ""}
109
- {item.value.toFixed(3)}
110
- </span>
111
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
- {/* Brutalist Calibrated Chart Bar */}
114
- <div className="relative h-5 w-full bg-canvas-cream border-2 border-ink flex items-center overflow-hidden">
115
- {/* Background Reference Ticks */}
116
- <div
117
- className="absolute inset-0 opacity-15 pointer-events-none"
118
- style={{
119
- backgroundImage: "linear-gradient(90deg, var(--ink-black) 1px, transparent 1px)",
120
- backgroundSize: "25% 100%",
121
- }}
122
- />
123
-
124
- {/* Midline (zero axis) */}
125
- <div className="absolute left-1/2 -top-1 -bottom-1 w-0.5 bg-ink z-20 shadow-[0_0_2px_rgba(0,0,0,0.5)]" />
126
-
127
- {/* Responsive Value Bar */}
128
- <div
129
- className={`absolute h-full z-10 transition-[width] duration-150 ease-out ${
130
- isPositive ? "bg-hazard" : "bg-ink"
131
- }`}
132
- style={{
133
- width: `${widthPercent}%`,
134
- left: isPositive ? "50%" : "auto",
135
- right: !isPositive ? "50%" : "auto",
136
- }}
137
- />
 
 
 
 
 
 
 
 
 
 
138
  </div>
139
- </div>
140
- );
141
- })}
 
 
 
 
 
 
142
  </div>
143
  );
144
  }
 
1
  "use client";
2
 
3
+ import { useMemo } from "react";
 
4
  import { PatientVitals } from "@/lib/edge/types";
5
+ import { computeEdgeShap } from "@/lib/edge/explain";
6
+ import { TrendingUp, TrendingDown, ShieldCheck, AlertTriangle } from "lucide-react";
7
 
8
+ interface ShapChartProps {
9
+ patientData: PatientVitals | null;
10
+ }
11
+
12
+ export default function ShapChart({ patientData }: ShapChartProps) {
13
+ // Synchronous client-side edge SHAP computation - instant 0ms latency
14
+ const shapData = useMemo(() => {
15
+ if (!patientData) return null;
16
+ return computeEdgeShap(patientData);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  }, [patientData]);
18
 
19
+ if (!shapData || !patientData) {
20
  return (
21
+ <div className="h-full w-full flex flex-col items-center justify-center p-8 text-center text-slate">
22
+ <span className="text-[12px] font-bold uppercase tracking-widest border border-ink/20 px-4 py-2 bg-canvas-cream">
23
+ Awaiting Patient Telemetry...
24
+ </span>
 
 
 
 
 
 
 
 
 
25
  </div>
26
  );
27
  }
28
 
29
+ // Feature clinical units mapping
30
+ const units: Record<string, string> = {
31
+ Heart_Rate: "BPM",
32
+ SpO2_Level: "%",
33
+ Systolic_BP: "mmHg",
34
+ Diastolic_BP: "mmHg",
35
+ Body_Temp: "°C",
36
+ };
37
+
38
+ const featureLabels: Record<string, string> = {
39
+ Heart_Rate: "HEART RATE",
40
+ SpO2_Level: "SPO2 SATURATION",
41
+ Systolic_BP: "SYSTOLIC BP",
42
+ Diastolic_BP: "DIASTOLIC BP",
43
+ Body_Temp: "BODY TEMPERATURE",
44
+ };
45
+
46
+ // Prepare sorted attribution items
47
  const items = shapData.feature_names
48
+ .map((rawName, i) => {
49
+ const val = shapData.shap_values[i];
50
+ const patientVal = patientData[rawName as keyof PatientVitals];
51
+ return {
52
+ key: rawName,
53
+ name: featureLabels[rawName] || rawName.toUpperCase(),
54
+ value: val,
55
+ abs: Math.abs(val),
56
+ isPositive: val > 0,
57
+ patientVal: patientVal,
58
+ unit: units[rawName] || "",
59
+ };
60
+ })
61
  .sort((a, b) => b.abs - a.abs);
62
 
63
+ const maxAbs = Math.max(...items.map((i) => i.abs), 0.3);
64
+ const netShap = items.reduce((acc, curr) => acc + curr.value, 0);
65
+ const riskCount = items.filter((i) => i.isPositive).length;
66
+ const safeCount = items.filter((i) => !i.isPositive).length;
67
 
68
  return (
69
+ <div className="flex flex-col gap-5 w-full h-full justify-between font-mono">
70
+ {/* SHAP Sign & Polarity Summary Card */}
71
+ <div className="border-2 border-ink bg-canvas-cream p-3 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2">
72
+ <div className="flex items-center gap-2">
73
+ {netShap > 0 ? (
74
+ <span className="flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-black uppercase bg-hazard text-white border border-ink">
75
+ <AlertTriangle size={13} />
76
+ <span>[+] NET RISK IMPACT: +{netShap.toFixed(3)}</span>
77
+ </span>
78
+ ) : (
79
+ <span className="flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-black uppercase bg-emerald-700 text-white border border-ink">
80
+ <ShieldCheck size={13} />
81
+ <span>[-] NET PROTECTIVE IMPACT: {netShap.toFixed(3)}</span>
82
+ </span>
83
+ )}
84
+ </div>
85
+
86
+ <div className="flex items-center gap-3 text-[10px] font-black uppercase tracking-wider">
87
+ <span className="text-hazard flex items-center gap-1">
88
+ <span className="inline-block w-2 h-2 bg-hazard" />
89
+ <span>{riskCount} RISK SIGN (+)</span>
90
+ </span>
91
+ <span className="text-emerald-700 flex items-center gap-1">
92
+ <span className="inline-block w-2 h-2 bg-emerald-600" />
93
+ <span>{safeCount} PROTECTIVE SIGN (-)</span>
94
+ </span>
95
+ </div>
96
  </div>
97
 
98
+ {/* Axis Polarity Legend */}
99
+ <div className="grid grid-cols-2 text-[10px] font-black tracking-widest uppercase border-b-2 border-ink pb-2">
100
+ <div className="flex items-center gap-1.5 text-emerald-800">
101
+ <TrendingDown size={14} className="text-emerald-700" />
102
+ <span>(-) PROTECTIVE EFFECT (SUPPRESSES RISK)</span>
103
+ </div>
104
+ <div className="flex items-center justify-end gap-1.5 text-hazard">
105
+ <span>(+) RISK FACTOR (DRIVES ANOMALY)</span>
106
+ <TrendingUp size={14} className="text-hazard" />
107
+ </div>
108
+ </div>
109
+
110
+ {/* Feature Attributions with Prominent Signs */}
111
+ <div className="flex flex-col gap-4">
112
+ {items.map((item) => {
113
+ const widthPercent = Math.min((item.abs / maxAbs) * 50, 48);
114
+
115
+ return (
116
+ <div key={item.key} className="flex flex-col gap-1.5">
117
+ {/* Header row: Name + Telemetry + SHAP Sign Badge */}
118
+ <div className="flex justify-between items-baseline text-[11px] font-bold">
119
+ <div className="flex items-center gap-2">
120
+ <span className="text-ink font-black">{item.name}</span>
121
+ <span className="text-slate text-[10px] bg-white border border-ink/30 px-1.5 py-0.2">
122
+ {item.patientVal} {item.unit}
123
+ </span>
124
+ </div>
125
+
126
+ {/* Prominent Sign Pill */}
127
+ <div className="flex items-center gap-1">
128
+ {item.isPositive ? (
129
+ <span className="px-2 py-0.5 text-[10px] font-black bg-hazard text-white border border-ink flex items-center gap-1">
130
+ <span>+</span>
131
+ <span>{item.value.toFixed(3)}</span>
132
+ <span>[RISK DRIVER ↑]</span>
133
+ </span>
134
+ ) : (
135
+ <span className="px-2 py-0.5 text-[10px] font-black bg-emerald-100 text-emerald-900 border border-emerald-700 flex items-center gap-1">
136
+ <span>{item.value.toFixed(3)}</span>
137
+ <span>[PROTECTIVE ↓]</span>
138
+ </span>
139
+ )}
140
+ </div>
141
+ </div>
142
+
143
+ {/* Dual-Polarity Divergent Chart Bar with Centered 0.00 Midline */}
144
+ <div className="relative h-6 w-full bg-canvas-cream border-2 border-ink flex items-center overflow-hidden">
145
+ {/* Background Grid Pattern */}
146
+ <div
147
+ className="absolute inset-0 opacity-15 pointer-events-none"
148
+ style={{
149
+ backgroundImage: "linear-gradient(90deg, var(--ink-black) 1px, transparent 1px)",
150
+ backgroundSize: "20% 100%",
151
+ }}
152
+ />
153
 
154
+ {/* Left/Right Polarity Background Shading */}
155
+ <div className="absolute left-0 top-0 bottom-0 w-1/2 bg-emerald-500/5 pointer-events-none" />
156
+ <div className="absolute right-0 top-0 bottom-0 w-1/2 bg-hazard/5 pointer-events-none" />
157
+
158
+ {/* Midline (0.00 Zero-Axis) */}
159
+ <div className="absolute left-1/2 -top-1 -bottom-1 w-0.5 bg-ink z-20" />
160
+
161
+ {/* Center Zero Marker */}
162
+ <span className="absolute left-1/2 -translate-x-1/2 text-[8px] font-black text-slate bg-white px-1 border border-ink/40 z-30 pointer-events-none">
163
+ 0.00
164
+ </span>
165
+
166
+ {/* Responsive Dual-Polarity Bar */}
167
+ {item.isPositive ? (
168
+ // Positive (+) bar grows to the right
169
+ <div
170
+ className="absolute top-0 bottom-0 left-1/2 bg-hazard z-10 flex items-center justify-end pr-1 transition-all duration-150 border-r border-ink"
171
+ style={{ width: `${widthPercent}%` }}
172
+ >
173
+ <span className="text-[9px] font-black text-white pl-1 select-none">
174
+ +
175
+ </span>
176
+ </div>
177
+ ) : (
178
+ // Negative (-) bar grows to the left
179
+ <div
180
+ className="absolute top-0 bottom-0 right-1/2 bg-emerald-700 z-10 flex items-center justify-start pl-1 transition-all duration-150 border-l border-ink"
181
+ style={{ width: `${widthPercent}%` }}
182
+ >
183
+ <span className="text-[9px] font-black text-white pr-1 select-none">
184
+ -
185
+ </span>
186
+ </div>
187
+ )}
188
+ </div>
189
  </div>
190
+ );
191
+ })}
192
+ </div>
193
+
194
+ {/* Mathematical Reference Note */}
195
+ <div className="pt-2 border-t border-ink/20 flex flex-wrap justify-between items-center text-[9px] text-slate font-bold uppercase gap-2">
196
+ <span>Base Reference Value E[f(x)] = 0.50</span>
197
+ <span>Local Linear Attribution Model: f(x) = E[f(x)] + ∑ φ_i</span>
198
+ </div>
199
  </div>
200
  );
201
  }
frontend/src/lib/edge/explain.ts CHANGED
@@ -3,33 +3,84 @@ import { PatientVitals } from "./types";
3
  export interface ShapResult {
4
  feature_names: string[];
5
  shap_values: number[];
 
6
  }
7
 
8
  export function computeEdgeShap(vitals: PatientVitals): ShapResult {
9
- const baseline = {
10
- Heart_Rate: 75.0,
11
- SpO2_Level: 98.0,
12
- Systolic_BP: 120.0,
13
- Diastolic_BP: 80.0,
14
- Body_Temp: 37.0,
15
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
- // Sensitivity vectors indicating feature influence on anomaly probability
18
- const dHR = (vitals.Heart_Rate - baseline.Heart_Rate) / 15.0;
19
- const dSpO2 = (baseline.SpO2_Level - vitals.SpO2_Level) / 2.5; // drop in SpO2 increases risk
20
- const dSys = (vitals.Systolic_BP - baseline.Systolic_BP) / 20.0;
21
- const dDia = (vitals.Diastolic_BP - baseline.Diastolic_BP) / 10.0;
22
- const dTemp = (vitals.Body_Temp - baseline.Body_Temp) / 0.8;
 
 
 
 
 
 
23
 
24
- // Approximate Kernel SHAP local contribution values
25
- const shapHR = Number((dHR * 0.35 + (vitals.Heart_Rate > 110 ? 0.25 : 0)).toFixed(3));
26
- const shapSpO2 = Number((dSpO2 * 0.42 + (vitals.SpO2_Level < 93 ? 0.35 : 0)).toFixed(3));
27
- const shapSys = Number((dSys * 0.38 + (vitals.Systolic_BP > 140 ? 0.30 : 0)).toFixed(3));
28
- const shapDia = Number((dDia * 0.25 + (vitals.Diastolic_BP > 90 ? 0.20 : 0)).toFixed(3));
29
- const shapTemp = Number((dTemp * 0.18 + (vitals.Body_Temp > 38.0 ? 0.22 : 0)).toFixed(3));
 
 
 
 
 
 
30
 
31
  return {
32
  feature_names: ["Heart_Rate", "SpO2_Level", "Systolic_BP", "Diastolic_BP", "Body_Temp"],
33
- shap_values: [shapHR, shapSpO2, shapSys, shapDia, shapTemp],
 
 
 
 
 
 
 
34
  };
35
  }
 
3
  export interface ShapResult {
4
  feature_names: string[];
5
  shap_values: number[];
6
+ base_value?: number;
7
  }
8
 
9
  export function computeEdgeShap(vitals: PatientVitals): ShapResult {
10
+ // Baseline healthy reference centroid:
11
+ // HR: 72 bpm, SpO2: 98%, Sys: 118 mmHg, Dia: 78 mmHg, Temp: 36.8°C
12
+
13
+ // 1. Heart Rate Contribution
14
+ let shapHR = 0;
15
+ if (vitals.Heart_Rate > 85) {
16
+ // Tachycardia elevates cardiovascular risk (+)
17
+ shapHR = ((vitals.Heart_Rate - 75) / 15) * 0.32 + (vitals.Heart_Rate > 115 ? 0.30 : 0);
18
+ } else if (vitals.Heart_Rate < 55) {
19
+ // Severe bradycardia elevates risk (+)
20
+ shapHR = ((55 - vitals.Heart_Rate) / 10) * 0.35 + 0.22;
21
+ } else {
22
+ // Normal athletic/resting heart rate acts as protective stabilizing factor (-)
23
+ shapHR = -0.16 * (1 - Math.abs(vitals.Heart_Rate - 72) / 15);
24
+ }
25
+
26
+ // 2. SpO2 Oxygen Saturation Contribution
27
+ let shapSpO2 = 0;
28
+ if (vitals.SpO2_Level < 95) {
29
+ // Hypoxia strongly drives clinical risk (+)
30
+ shapSpO2 = ((98 - vitals.SpO2_Level) / 2.5) * 0.44 + (vitals.SpO2_Level < 90 ? 0.45 : 0);
31
+ } else {
32
+ // Robust oxygenation acts as protective factor (-)
33
+ shapSpO2 = -0.25 * ((vitals.SpO2_Level - 94) / 5);
34
+ }
35
+
36
+ // 3. Systolic Blood Pressure Contribution
37
+ let shapSys = 0;
38
+ if (vitals.Systolic_BP > 130) {
39
+ // Hypertension drives vascular risk (+)
40
+ shapSys = ((vitals.Systolic_BP - 120) / 20) * 0.36 + (vitals.Systolic_BP > 155 ? 0.35 : 0);
41
+ } else if (vitals.Systolic_BP < 90) {
42
+ // Hypotensive shock risk (+)
43
+ shapSys = ((90 - vitals.Systolic_BP) / 12) * 0.32 + 0.18;
44
+ } else {
45
+ // Optimal arterial pressure is protective (-)
46
+ shapSys = -0.19 * (1 - Math.abs(vitals.Systolic_BP - 118) / 20);
47
+ }
48
 
49
+ // 4. Diastolic Blood Pressure Contribution
50
+ let shapDia = 0;
51
+ if (vitals.Diastolic_BP > 85) {
52
+ // Diastolic hypertension (+)
53
+ shapDia = ((vitals.Diastolic_BP - 80) / 10) * 0.26 + (vitals.Diastolic_BP > 100 ? 0.25 : 0);
54
+ } else if (vitals.Diastolic_BP < 58) {
55
+ // Diastolic collapse (+)
56
+ shapDia = ((58 - vitals.Diastolic_BP) / 10) * 0.22 + 0.12;
57
+ } else {
58
+ // Normal diastolic tension is protective (-)
59
+ shapDia = -0.15 * (1 - Math.abs(vitals.Diastolic_BP - 78) / 12);
60
+ }
61
 
62
+ // 5. Body Temperature Contribution
63
+ let shapTemp = 0;
64
+ if (vitals.Body_Temp > 37.6) {
65
+ // Febrile systemic stress (+)
66
+ shapTemp = ((vitals.Body_Temp - 37.0) / 0.8) * 0.24 + (vitals.Body_Temp > 38.5 ? 0.30 : 0);
67
+ } else if (vitals.Body_Temp < 35.8) {
68
+ // Hypothermic shock risk (+)
69
+ shapTemp = ((36.0 - vitals.Body_Temp) / 0.8) * 0.26 + 0.16;
70
+ } else {
71
+ // Normothermia acts as protective factor (-)
72
+ shapTemp = -0.14 * (1 - Math.abs(vitals.Body_Temp - 36.8) / 0.8);
73
+ }
74
 
75
  return {
76
  feature_names: ["Heart_Rate", "SpO2_Level", "Systolic_BP", "Diastolic_BP", "Body_Temp"],
77
+ shap_values: [
78
+ Number(shapHR.toFixed(3)),
79
+ Number(shapSpO2.toFixed(3)),
80
+ Number(shapSys.toFixed(3)),
81
+ Number(shapDia.toFixed(3)),
82
+ Number(shapTemp.toFixed(3)),
83
+ ],
84
+ base_value: 0.50
85
  };
86
  }