mgbam commited on
Commit
e26e4ca
·
verified ·
1 Parent(s): fec89fc

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +478 -478
app.py CHANGED
@@ -1,478 +1,478 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """
4
- Sundew Diabetes Watch — Streamlit App (multilingual, model backends, Sundew v0.6/v0.7 compatible)
5
-
6
- - Multilingual UI: English, French, Swahili, Hausa (DeepTranslate API or deep_translator fallback)
7
- - Model backends: Demo(LogReg), XGBoost(.json), PyTorch TorchScript(.pt/.pth), ONNX(.onnx)
8
- - Sundew selective-activation gate with compatibility wrapper across package versions
9
- - Robust timestamp parsing (handles tz-aware), ROC calculation, KPIs, charts, and alerts
10
- - Research prototype — not medical advice
11
- """
12
- from __future__ import annotations
13
-
14
- import math
15
- import os
16
- import inspect
17
- from dataclasses import dataclass
18
- from typing import Dict, Tuple, Optional, Callable
19
-
20
- import numpy as np
21
- import pandas as pd
22
- import streamlit as st
23
-
24
- # ------------------------------ Sundew import (tolerant) ------------------------------
25
- try:
26
- from sundew import SundewAlgorithm # provided by sundew-algorithms
27
- _HAS_SUNDEW = True
28
- except Exception:
29
- SundewAlgorithm = None # type: ignore
30
- _HAS_SUNDEW = False
31
-
32
- # ------------------------------ Optional model backends ------------------------------
33
- _HAS_XGB = False
34
- try:
35
- import xgboost as xgb # type: ignore
36
- _HAS_XGB = True
37
- except Exception:
38
- pass
39
-
40
- _HAS_TORCH = False
41
- try:
42
- import torch # type: ignore
43
- _HAS_TORCH = True
44
- except Exception:
45
- pass
46
-
47
- _HAS_ONNX = False
48
- try:
49
- import onnxruntime as ort # type: ignore
50
- _HAS_ONNX = True
51
- except Exception:
52
- pass
53
-
54
- # ------------------------------ Translation utils ------------------------------
55
- import requests
56
- from deep_translator import GoogleTranslator
57
-
58
- DT_KEY = os.getenv("DEEPTRANSLATE_API_KEY", "").strip()
59
- DT_ENDPOINT = os.getenv(
60
- "DEEPTRANSLATE_ENDPOINT",
61
- "https://deep-translate1.p.rapidapi.com/language/translate/v2",
62
- ).strip()
63
-
64
- @st.cache_data(show_spinner=False)
65
- def _translate_deeptranslate(text: str, target_lang: str, source_lang: str = "en") -> str:
66
- """Translate via DeepTranslate (RapidAPI-style). Caches results."""
67
- if not DT_KEY:
68
- raise RuntimeError("Missing DEEPTRANSLATE_API_KEY")
69
- headers = {
70
- "content-type": "application/json",
71
- "X-RapidAPI-Key": DT_KEY,
72
- "X-RapidAPI-Host": "deep-translate1.p.rapidapi.com",
73
- }
74
- payload = {"q": text, "source": source_lang, "target": target_lang}
75
- r = requests.post(DT_ENDPOINT, json=payload, headers=headers, timeout=10)
76
- r.raise_for_status()
77
- data = r.json()
78
- return data.get("data", {}).get("translations", {}).get("translatedText", text)
79
-
80
- @st.cache_data(show_spinner=False)
81
- def _translate_fallback(text: str, target_lang: str, source_lang: str = "en") -> str:
82
- """Fallback using deep_translator (Google)."""
83
- try:
84
- return GoogleTranslator(source=source_lang, target=target_lang).translate(text)
85
- except Exception:
86
- return text
87
-
88
- _translation_cache: Dict[Tuple[str, str], str] = {}
89
-
90
- def tr(text: str, target_lang: str, source_lang: str = "en") -> str:
91
- """Translate with DeepTranslate if key set, else fallback, with an in-session cache."""
92
- key = (text, target_lang)
93
- if key in _translation_cache:
94
- return _translation_cache[key]
95
- if target_lang.lower() in ("en", "eng", "english"):
96
- _translation_cache[key] = text
97
- return text
98
- try:
99
- out = _translate_deeptranslate(text, target_lang, source_lang)
100
- except Exception:
101
- out = _translate_fallback(text, target_lang, source_lang)
102
- _translation_cache[key] = out
103
- return out
104
-
105
- LANGS = {
106
- "English": "en",
107
- "Français (French)": "fr",
108
- "Kiswahili (Swahili)": "sw",
109
- "Hausa": "ha",
110
- }
111
-
112
- # ------------------------------ Sundew wrapper (v0.6 + v0.7) ------------------------------
113
- @dataclass
114
- class SundewGate:
115
- target_activation: float = 0.25
116
- temperature: float = 0.08
117
- mode: str = "tuned_v2"
118
-
119
- def __post_init__(self):
120
- self.sd = None
121
- if _HAS_SUNDEW and SundewAlgorithm is not None:
122
- cfg = {
123
- "target_activation": self.target_activation,
124
- "temperature": self.temperature,
125
- "mode": self.mode,
126
- }
127
- try:
128
- sig = inspect.signature(SundewAlgorithm)
129
- if "config" in sig.parameters:
130
- # 0.7.x style
131
- try:
132
- self.sd = SundewAlgorithm(config=cfg)
133
- except TypeError:
134
- self.sd = SundewAlgorithm(cfg) # positional
135
- else:
136
- # 0.6.x style kwargs
137
- self.sd = SundewAlgorithm(
138
- target_activation=self.target_activation,
139
- temperature=self.temperature,
140
- mode=self.mode,
141
- )
142
- except Exception:
143
- pass
144
- # try factory helpers if constructor failed
145
- if self.sd is None:
146
- for factory in ("from_config", "create", "build"):
147
- if hasattr(SundewAlgorithm, factory):
148
- try:
149
- self.sd = getattr(SundewAlgorithm, factory)(cfg)
150
- break
151
- except Exception:
152
- continue
153
-
154
- # fallback gate state (keeps app usable even if Sundew not available)
155
- self._tau = 0.5
156
- self._ema = 0.0
157
- self._alpha = 0.02 # EMA smoothing
158
-
159
- def decide(self, score: float) -> bool:
160
- score = float(max(0.0, min(1.0, score)))
161
- if self.sd is not None:
162
- for method_name in ("decide", "step", "open"):
163
- if hasattr(self.sd, method_name):
164
- try:
165
- return bool(getattr(self.sd, method_name)(score))
166
- except Exception:
167
- pass
168
- # fallback stochastic logistic gate targeting activation rate
169
- p_open = 1.0 / (1.0 + math.exp(-(score - self._tau) / max(1e-6, self.temperature)))
170
- fired = np.random.rand() < p_open
171
- self._ema = (1 - self._alpha) * self._ema + self._alpha * (1.0 if fired else 0.0)
172
- self._tau += 0.01 * (self.target_activation - self._ema)
173
- self._tau = min(0.95, max(0.05, self._tau))
174
- return fired
175
-
176
- # ------------------------------ Risk scoring ------------------------------
177
- def compute_lightweight_score(row: pd.Series) -> float:
178
- """Heuristic risk proxy in [0,1] using glucose, rate-of-change, insulin, carbs, heart rate."""
179
- g = float(row.get("glucose_mgdl", np.nan))
180
- roc = float(row.get("roc_mgdl_min", 0.0))
181
- insulin = float(row.get("insulin_units", 0.0))
182
- carbs = float(row.get("carbs_g", 0.0))
183
- hr = float(row.get("hr", 0.0))
184
-
185
- low_gap = max(0.0, 80 - g)
186
- high_gap = max(0.0, g - 140)
187
- base = (low_gap + high_gap) / 120.0 # ~[0,1]
188
-
189
- roc_term = min(1.0, abs(roc) / 3.0) # 3 mg/dL/min ~ strong trend
190
- insulin_term = min(1.0, insulin / 6.0) * (1.0 if roc < -0.5 else 0.3)
191
- carbs_term = min(1.0, carbs / 50.0) * (1.0 if roc > 0.5 else 0.3)
192
- activity_term = min(1.0, max(0.0, hr - 100) / 60.0) * (1.0 if insulin > 0.5 else 0.2)
193
-
194
- score = base + 0.7 * roc_term + 0.5 * insulin_term + 0.4 * carbs_term + 0.3 * activity_term
195
- return float(max(0.0, min(1.0, score)))
196
-
197
- # ------------------------------ Heavy model backends ------------------------------
198
- def build_demo_model(df: pd.DataFrame):
199
- """Session-trained logistic regression demo model (portable)."""
200
- from sklearn.linear_model import LogisticRegression
201
- from sklearn.preprocessing import StandardScaler
202
- from sklearn.pipeline import Pipeline
203
-
204
- model = Pipeline([("scaler", StandardScaler()), ("clf", LogisticRegression(max_iter=1000))])
205
- tmp = df.copy()
206
- # label: 30-min ahead hypo (<70) OR hyper (>180)
207
- tmp["future_glucose"] = tmp["glucose_mgdl"].shift(-6) # assuming 5-min cadence
208
- tmp["label"] = ((tmp["future_glucose"] < 70) | (tmp["future_glucose"] > 180)).astype(int)
209
- tmp = tmp.dropna(subset=["label"]).copy()
210
-
211
- X = tmp[["glucose_mgdl", "roc_mgdl_min", "insulin_units", "carbs_g", "hr"]].fillna(0.0).values
212
- y = tmp["label"].values
213
- if len(np.unique(y)) < 2:
214
- # ensure fit works even with degenerate labels
215
- y = np.array([0, 1] * (len(X) // 2 + 1))[: len(X)]
216
- model.fit(X, y)
217
- def _predict(Xarr: np.ndarray) -> float:
218
- try:
219
- return float(model.predict_proba(Xarr)[0, 1])
220
- except Exception:
221
- return float(model.predict(Xarr)[0])
222
- return _predict
223
-
224
- def load_xgb_predictor(file_bytes: bytes) -> Callable[[np.ndarray], float]:
225
- if not _HAS_XGB:
226
- raise RuntimeError("XGBoost not installed in this environment.")
227
- import tempfile
228
- with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
229
- f.write(file_bytes)
230
- path = f.name
231
- booster = xgb.XGBClassifier()
232
- booster.load_model(path)
233
- def _predict(Xarr: np.ndarray) -> float:
234
- return float(booster.predict_proba(Xarr)[0, 1])
235
- return _predict
236
-
237
- def load_torch_predictor(file_bytes: bytes) -> Callable[[np.ndarray], float]:
238
- if not _HAS_TORCH:
239
- raise RuntimeError("PyTorch not installed in this environment.")
240
- import io
241
- model = torch.jit.load(io.BytesIO(file_bytes), map_location="cpu")
242
- model.eval()
243
- @torch.no_grad()
244
- def _predict(Xarr: np.ndarray) -> float:
245
- t = torch.tensor(Xarr, dtype=torch.float32)
246
- out = model(t)
247
- # accept logits or probabilities
248
- if out.ndim == 2 and out.shape[1] == 1:
249
- out = out.squeeze(1)
250
- out = torch.sigmoid(out) if (out.ndim == 1 or out.shape[1] == 1) else torch.softmax(out, dim=1)[:, 1]
251
- return float(out[0].cpu().item())
252
- return _predict
253
-
254
- def load_onnx_predictor(file_bytes: bytes) -> Callable[[np.ndarray], float]:
255
- if not _HAS_ONNX:
256
- raise RuntimeError("onnxruntime not installed in this environment.")
257
- import tempfile
258
- with tempfile.NamedTemporaryFile(suffix=".onnx", delete=False) as f:
259
- f.write(file_bytes)
260
- path = f.name
261
- sess = ort.InferenceSession(path, providers=["CPUExecutionProvider"])
262
- input_name = sess.get_inputs()[0].name
263
- def _predict(Xarr: np.ndarray) -> float:
264
- y = sess.run(None, {input_name: Xarr.astype(np.float32)})[0]
265
- if y.ndim == 2 and y.shape[1] == 2:
266
- return float(y[0, 1])
267
- if y.ndim == 2 and y.shape[1] == 1:
268
- return float(y[0, 0])
269
- return float(np.ravel(y)[0])
270
- return _predict
271
-
272
- # ------------------------------ Streamlit UI ------------------------------
273
- st.set_page_config(page_title="Sundew Diabetes Watch", layout="wide")
274
-
275
- # Language selector
276
- lang_name = st.sidebar.selectbox("Language / Lugha / Taal / Harshe", list(LANGS.keys()), index=0)
277
- LANG = LANGS[lang_name]
278
- T = lambda s: tr(s, LANG, "en")
279
-
280
- st.title("🌿 " + T("Sundew Diabetes Watch"))
281
- st.caption(T("Energy-aware selective activation for diabetes monitoring — research demo (not medical advice)."))
282
-
283
- # File upload / controls
284
- left, right = st.columns([2, 1])
285
- with left:
286
- uploaded = st.file_uploader(
287
- T("Upload CGM CSV (timestamp, glucose_mgdl, carbs_g, insulin_units, steps, hr)"),
288
- type=["csv"],
289
- )
290
- use_synth = st.checkbox(T("Use synthetic example if no file uploaded"), value=True)
291
- with right:
292
- target_activation = st.slider(T("Target heavy-activation rate"), 0.05, 0.9, 0.25, 0.01)
293
- temperature = st.slider(T("Gate temperature"), 0.02, 0.5, 0.08, 0.01)
294
- mode = st.selectbox(T("Sundew mode"), ["tuned_v2", "conservative", "aggressive", "auto_tuned"], index=0)
295
-
296
- # Backend selector with stable internal keys
297
- backend_options = [
298
- ("demo", T("Demo (Logistic Regression)")),
299
- ("xgb", "XGBoost"),
300
- ("torch", "PyTorch"),
301
- ("onnx", "ONNX"),
302
- ]
303
- backend_label = st.sidebar.selectbox(T("Model backend"), [lbl for _, lbl in backend_options], index=0)
304
- BACKEND_KEY = next(k for k, lbl in backend_options if lbl == backend_label)
305
-
306
- model_file = None
307
- if BACKEND_KEY in ("xgb", "torch", "onnx"):
308
- model_file = st.sidebar.file_uploader(T("Upload trained model file"), type=["json", "bin", "pt", "pth", "onnx"], key="model")
309
-
310
- # ------------------------------ Load/synthesize data ------------------------------
311
- if uploaded is not None:
312
- df = pd.read_csv(uploaded)
313
- else:
314
- if not use_synth:
315
- st.stop()
316
- rng = np.random.default_rng(7)
317
- n = 600 # ~50 hours if 5-min cadence
318
- t0 = pd.Timestamp.utcnow().floor("min")
319
- times = [t0 + pd.Timedelta(minutes=5 * i) for i in range(n)]
320
- base = 120 + 25 * np.sin(np.linspace(0, 10 * np.pi, n))
321
- noise = rng.normal(0, 10, n)
322
- meals = (rng.random(n) < 0.04).astype(float) * rng.normal(45, 15, n).clip(0, 120)
323
- insulin = (rng.random(n) < 0.03).astype(float) * rng.normal(4, 1.2, n).clip(0, 8)
324
- steps = rng.integers(0, 150, size=n)
325
- hr = 70 + (steps > 80) * rng.integers(30, 60, size=n)
326
- glucose = base + noise + 0.3 * meals - 0.8 * insulin
327
- df = pd.DataFrame(
328
- {
329
- "timestamp": times,
330
- "glucose_mgdl": np.round(glucose, 1),
331
- "carbs_g": np.round(meals, 1),
332
- "insulin_units": np.round(insulin, 1),
333
- "steps": steps,
334
- "hr": hr,
335
- }
336
- )
337
-
338
- # Robust timestamp parsing (handles tz-aware, strings, epoch)
339
- from pandas.api.types import is_datetime64_any_dtype
340
- if "timestamp" not in df.columns:
341
- st.error(T("CSV must include a 'timestamp' column."))
342
- st.stop()
343
-
344
- if not is_datetime64_any_dtype(df["timestamp"]):
345
- df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True, errors="coerce")
346
-
347
- # Localize if naive
348
- if getattr(df["timestamp"].dt, "tz", None) is None:
349
- df["timestamp"] = df["timestamp"].dt.tz_localize("UTC")
350
-
351
- df = df.sort_values("timestamp").reset_index(drop=True)
352
-
353
- # Rate-of-change mg/dL per minute
354
- df["dt_min"] = df["timestamp"].diff().dt.total_seconds() / 60.0
355
- df["glucose_prev"] = df["glucose_mgdl"].shift(1)
356
- df["roc_mgdl_min"] = (df["glucose_mgdl"] - df["glucose_prev"]) / df["dt_min"]
357
- df["roc_mgdl_min"] = df["roc_mgdl_min"].replace([np.inf, -np.inf], 0.0).fillna(0.0)
358
-
359
- # ------------------------------ Heavy predictor selection ------------------------------
360
- predict_proba: Optional[Callable[[np.ndarray], float]] = None
361
- header_note = ""
362
-
363
- if BACKEND_KEY == "demo":
364
- predict_proba = build_demo_model(df)
365
- header_note = T("Demo model trains per session for portability.")
366
- elif BACKEND_KEY == "xgb" and model_file is not None:
367
- try:
368
- predict_proba = load_xgb_predictor(model_file.read())
369
- header_note = T("XGBoost model loaded from file.")
370
- except Exception as e:
371
- st.warning(T("Could not load XGBoost model; falling back to Demo."))
372
- predict_proba = build_demo_model(df)
373
- header_note = T("Demo model used (no external file).")
374
- elif BACKEND_KEY == "torch" and model_file is not None:
375
- try:
376
- predict_proba = load_torch_predictor(model_file.read())
377
- header_note = T("PyTorch TorchScript model loaded.")
378
- except Exception:
379
- st.warning(T("Could not load PyTorch model; falling back to Demo."))
380
- predict_proba = build_demo_model(df)
381
- header_note = T("Demo model used (no external file).")
382
- elif BACKEND_KEY == "onnx" and model_file is not None:
383
- try:
384
- predict_proba = load_onnx_predictor(model_file.read())
385
- header_note = T("ONNX model loaded via onnxruntime.")
386
- except Exception:
387
- st.warning(T("Could not load ONNX model; falling back to Demo."))
388
- predict_proba = build_demo_model(df)
389
- header_note = T("Demo model used (no external file).")
390
- else:
391
- st.warning(T("Selected backend requires a model file. Falling back to Demo."))
392
- predict_proba = build_demo_model(df)
393
- header_note = T("Demo model used (no external file).")
394
-
395
- st.info(header_note)
396
-
397
- # ------------------------------ Gate + streaming loop ------------------------------
398
- gate = SundewGate(target_activation=target_activation, temperature=temperature, mode=mode)
399
-
400
- def make_X(row: pd.Series) -> np.ndarray:
401
- return np.array(
402
- [
403
- [
404
- row.get("glucose_mgdl", 0.0),
405
- row.get("roc_mgdl_min", 0.0),
406
- row.get("insulin_units", 0.0),
407
- row.get("carbs_g", 0.0),
408
- row.get("hr", 0.0),
409
- ]
410
- ],
411
- dtype=np.float32,
412
- )
413
-
414
- records = []
415
- alerts = []
416
- for _, row in df.iterrows():
417
- score = compute_lightweight_score(row)
418
- open_gate = gate.decide(score)
419
- decision = "SKIP"
420
- proba = None
421
- if open_gate and predict_proba is not None:
422
- X = make_X(row)
423
- try:
424
- proba = float(predict_proba(X))
425
- except Exception:
426
- proba = None
427
- decision = "RUN"
428
- if proba is not None and proba >= 0.6:
429
- alerts.append(
430
- {
431
- "timestamp": row["timestamp"],
432
- "glucose": row["glucose_mgdl"],
433
- "risk_proba": proba,
434
- "note": T("⚠ Elevated 30-min risk — please check CGM and plan carbs/insulin."),
435
- }
436
- )
437
- records.append(
438
- {
439
- "timestamp": row["timestamp"],
440
- "glucose": row["glucose_mgdl"],
441
- "roc": row["roc_mgdl_min"],
442
- "score": score,
443
- "gate": decision,
444
- "risk_proba": proba,
445
- }
446
- )
447
-
448
- out = pd.DataFrame(records)
449
- events = len(out)
450
- activations = int((out["gate"] == "RUN").sum())
451
- rate = activations / max(events, 1)
452
-
453
- c1, c2, c3 = st.columns(3)
454
- c1.metric(T("Events"), f"{events}")
455
- c2.metric(T("Heavy activations"), f"{activations}")
456
- c3.metric(T("Activation rate"), f"{rate:.2%}")
457
-
458
- st.line_chart(out.set_index("timestamp")["glucose"], height=220)
459
- st.line_chart(out.set_index("timestamp")["score"], height=220)
460
-
461
- st.subheader(T("Decisions (tail)"))
462
- st.dataframe(out.tail(50))
463
-
464
- st.subheader(T("Alerts"))
465
- if alerts:
466
- st.dataframe(pd.DataFrame(alerts))
467
- else:
468
- st.info(T("No high-risk alerts triggered in this window."))
469
-
470
- # Footer: show Sundew version & engine status
471
- try:
472
- from importlib.metadata import version as _ver
473
- _sundew_ver = _ver("sundew-algorithms")
474
- except Exception:
475
- _sundew_ver = "unknown"
476
-
477
- engine_txt = f"sundew-algorithms {_sundew_ver}" if _HAS_SUNDEW else T("fallback gate (install sundew-algorithms)")
478
- st.caption(T("Engine: ") + engine_txt)
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Sundew Diabetes Watch — Streamlit App (multilingual, model backends, Sundew v0.6/v0.7 compatible)
5
+
6
+ - Multilingual UI: English, French, Swahili, Hausa (DeepTranslate API or deep_translator fallback)
7
+ - Model backends: Demo(LogReg), XGBoost(.json), PyTorch TorchScript(.pt/.pth), ONNX(.onnx)
8
+ - Sundew selective-activation gate with compatibility wrapper across package versions
9
+ - Robust timestamp parsing (handles tz-aware), ROC calculation, KPIs, charts, and alerts
10
+ - Research prototype — not medical advice
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ import os
16
+ import inspect
17
+ from dataclasses import dataclass
18
+ from typing import Dict, Tuple, Optional, Callable
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+ import streamlit as st
23
+
24
+ # ------------------------------ Sundew import (tolerant) ------------------------------
25
+ try:
26
+ from sundew import SundewAlgorithm # provided by sundew-algorithms
27
+ _HAS_SUNDEW = True
28
+ except Exception:
29
+ SundewAlgorithm = None # type: ignore
30
+ _HAS_SUNDEW = False
31
+
32
+ # ------------------------------ Optional model backends ------------------------------
33
+ _HAS_XGB = False
34
+ try:
35
+ import xgboost as xgb # type: ignore
36
+ _HAS_XGB = True
37
+ except Exception:
38
+ pass
39
+
40
+ _HAS_TORCH = False
41
+ try:
42
+ import torch # type: ignore
43
+ _HAS_TORCH = True
44
+ except Exception:
45
+ pass
46
+
47
+ _HAS_ONNX = False
48
+ try:
49
+ import onnxruntime as ort # type: ignore
50
+ _HAS_ONNX = True
51
+ except Exception:
52
+ pass
53
+
54
+ # ------------------------------ Translation utils ------------------------------
55
+ import requests
56
+ from deep_translator import GoogleTranslator
57
+
58
+ DT_KEY = os.getenv("DEEPTRANSLATE_API_KEY", "").strip()
59
+ DT_ENDPOINT = os.getenv(
60
+ "DEEPTRANSLATE_ENDPOINT",
61
+ "https://deep-translate1.p.rapidapi.com/language/translate/v2",
62
+ ).strip()
63
+
64
+ @st.cache_data(show_spinner=False)
65
+ def _translate_deeptranslate(text: str, target_lang: str, source_lang: str = "en") -> str:
66
+ """Translate via DeepTranslate (RapidAPI-style). Caches results."""
67
+ if not DT_KEY:
68
+ raise RuntimeError("Missing DEEPTRANSLATE_API_KEY")
69
+ headers = {
70
+ "content-type": "application/json",
71
+ "X-RapidAPI-Key": DT_KEY,
72
+ "X-RapidAPI-Host": "deep-translate1.p.rapidapi.com",
73
+ }
74
+ payload = {"q": text, "source": source_lang, "target": target_lang}
75
+ r = requests.post(DT_ENDPOINT, json=payload, headers=headers, timeout=10)
76
+ r.raise_for_status()
77
+ data = r.json()
78
+ return data.get("data", {}).get("translations", {}).get("translatedText", text)
79
+
80
+ @st.cache_data(show_spinner=False)
81
+ def _translate_fallback(text: str, target_lang: str, source_lang: str = "en") -> str:
82
+ """Fallback using deep_translator (Google)."""
83
+ try:
84
+ return GoogleTranslator(source=source_lang, target=target_lang).translate(text)
85
+ except Exception:
86
+ return text
87
+
88
+ _translation_cache: Dict[Tuple[str, str], str] = {}
89
+
90
+ def tr(text: str, target_lang: str, source_lang: str = "en") -> str:
91
+ """Translate with DeepTranslate if key set, else fallback, with an in-session cache."""
92
+ key = (text, target_lang)
93
+ if key in _translation_cache:
94
+ return _translation_cache[key]
95
+ if target_lang.lower() in ("en", "eng", "english"):
96
+ _translation_cache[key] = text
97
+ return text
98
+ try:
99
+ out = _translate_deeptranslate(text, target_lang, source_lang)
100
+ except Exception:
101
+ out = _translate_fallback(text, target_lang, source_lang)
102
+ _translation_cache[key] = out
103
+ return out
104
+
105
+ LANGS = {
106
+ "English": "en",
107
+ "Français (French)": "fr",
108
+ "Kiswahili (Swahili)": "sw",
109
+ "Hausa": "ha",
110
+ }
111
+
112
+ # ------------------------------ Sundew wrapper (v0.6 + v0.7) ------------------------------
113
+ @dataclass
114
+ class SundewGate:
115
+ target_activation: float = 0.25
116
+ temperature: float = 0.08
117
+ mode: str = "tuned_v2"
118
+
119
+ def __post_init__(self):
120
+ self.sd = None
121
+ if _HAS_SUNDEW and SundewAlgorithm is not None:
122
+ cfg = {
123
+ "target_activation": self.target_activation,
124
+ "temperature": self.temperature,
125
+ "mode": self.mode,
126
+ }
127
+ try:
128
+ sig = inspect.signature(SundewAlgorithm)
129
+ if "config" in sig.parameters:
130
+ # 0.7.x style
131
+ try:
132
+ self.sd = SundewAlgorithm(config=cfg)
133
+ except TypeError:
134
+ self.sd = SundewAlgorithm(cfg) # positional
135
+ else:
136
+ # 0.6.x style kwargs
137
+ self.sd = SundewAlgorithm(
138
+ target_activation=self.target_activation,
139
+ temperature=self.temperature,
140
+ mode=self.mode,
141
+ )
142
+ except Exception:
143
+ pass
144
+ # try factory helpers if constructor failed
145
+ if self.sd is None:
146
+ for factory in ("from_config", "create", "build"):
147
+ if hasattr(SundewAlgorithm, factory):
148
+ try:
149
+ self.sd = getattr(SundewAlgorithm, factory)(cfg)
150
+ break
151
+ except Exception:
152
+ continue
153
+
154
+ # fallback gate state (keeps app usable even if Sundew not available)
155
+ self._tau = 0.5
156
+ self._ema = 0.0
157
+ self._alpha = 0.02 # EMA smoothing
158
+
159
+ def decide(self, score: float) -> bool:
160
+ score = float(max(0.0, min(1.0, score)))
161
+ if self.sd is not None:
162
+ for method_name in ("decide", "step", "open"):
163
+ if hasattr(self.sd, method_name):
164
+ try:
165
+ return bool(getattr(self.sd, method_name)(score))
166
+ except Exception:
167
+ pass
168
+ # fallback stochastic logistic gate targeting activation rate
169
+ p_open = 1.0 / (1.0 + math.exp(-(score - self._tau) / max(1e-6, self.temperature)))
170
+ fired = np.random.rand() < p_open
171
+ self._ema = (1 - self._alpha) * self._ema + self._alpha * (1.0 if fired else 0.0)
172
+ self._tau += 0.01 * (self.target_activation - self._ema)
173
+ self._tau = min(0.95, max(0.05, self._tau))
174
+ return fired
175
+
176
+ # ------------------------------ Risk scoring ------------------------------
177
+ def compute_lightweight_score(row: pd.Series) -> float:
178
+ """Heuristic risk proxy in [0,1] using glucose, rate-of-change, insulin, carbs, heart rate."""
179
+ g = float(row.get("glucose_mgdl", np.nan))
180
+ roc = float(row.get("roc_mgdl_min", 0.0))
181
+ insulin = float(row.get("insulin_units", 0.0))
182
+ carbs = float(row.get("carbs_g", 0.0))
183
+ hr = float(row.get("hr", 0.0))
184
+
185
+ low_gap = max(0.0, 80 - g)
186
+ high_gap = max(0.0, g - 140)
187
+ base = (low_gap + high_gap) / 120.0 # ~[0,1]
188
+
189
+ roc_term = min(1.0, abs(roc) / 3.0) # 3 mg/dL/min ~ strong trend
190
+ insulin_term = min(1.0, insulin / 6.0) * (1.0 if roc < -0.5 else 0.3)
191
+ carbs_term = min(1.0, carbs / 50.0) * (1.0 if roc > 0.5 else 0.3)
192
+ activity_term = min(1.0, max(0.0, hr - 100) / 60.0) * (1.0 if insulin > 0.5 else 0.2)
193
+
194
+ score = base + 0.7 * roc_term + 0.5 * insulin_term + 0.4 * carbs_term + 0.3 * activity_term
195
+ return float(max(0.0, min(1.0, score)))
196
+
197
+ # ------------------------------ Heavy model backends ------------------------------
198
+ def build_demo_model(df: pd.DataFrame):
199
+ """Session-trained logistic regression demo model (portable)."""
200
+ from sklearn.linear_model import LogisticRegression
201
+ from sklearn.preprocessing import StandardScaler
202
+ from sklearn.pipeline import Pipeline
203
+
204
+ model = Pipeline([("scaler", StandardScaler()), ("clf", LogisticRegression(max_iter=1000))])
205
+ tmp = df.copy()
206
+ # label: 30-min ahead hypo (<70) OR hyper (>180)
207
+ tmp["future_glucose"] = tmp["glucose_mgdl"].shift(-6) # assuming 5-min cadence
208
+ tmp["label"] = ((tmp["future_glucose"] < 70) | (tmp["future_glucose"] > 180)).astype(int)
209
+ tmp = tmp.dropna(subset=["label"]).copy()
210
+
211
+ X = tmp[["glucose_mgdl", "roc_mgdl_min", "insulin_units", "carbs_g", "hr"]].fillna(0.0).values
212
+ y = tmp["label"].values
213
+ if len(np.unique(y)) < 2:
214
+ # ensure fit works even with degenerate labels
215
+ y = np.array([0, 1] * (len(X) // 2 + 1))[: len(X)]
216
+ model.fit(X, y)
217
+ def _predict(Xarr: np.ndarray) -> float:
218
+ try:
219
+ return float(model.predict_proba(Xarr)[0, 1])
220
+ except Exception:
221
+ return float(model.predict(Xarr)[0])
222
+ return _predict
223
+
224
+ def load_xgb_predictor(file_bytes: bytes) -> Callable[[np.ndarray], float]:
225
+ if not _HAS_XGB:
226
+ raise RuntimeError("XGBoost not installed in this environment.")
227
+ import tempfile
228
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
229
+ f.write(file_bytes)
230
+ path = f.name
231
+ booster = xgb.XGBClassifier()
232
+ booster.load_model(path)
233
+ def _predict(Xarr: np.ndarray) -> float:
234
+ return float(booster.predict_proba(Xarr)[0, 1])
235
+ return _predict
236
+
237
+ def load_torch_predictor(file_bytes: bytes) -> Callable[[np.ndarray], float]:
238
+ if not _HAS_TORCH:
239
+ raise RuntimeError("PyTorch not installed in this environment.")
240
+ import io
241
+ model = torch.jit.load(io.BytesIO(file_bytes), map_location="cpu")
242
+ model.eval()
243
+ @torch.no_grad()
244
+ def _predict(Xarr: np.ndarray) -> float:
245
+ t = torch.tensor(Xarr, dtype=torch.float32)
246
+ out = model(t)
247
+ # accept logits or probabilities
248
+ if out.ndim == 2 and out.shape[1] == 1:
249
+ out = out.squeeze(1)
250
+ out = torch.sigmoid(out) if (out.ndim == 1 or out.shape[1] == 1) else torch.softmax(out, dim=1)[:, 1]
251
+ return float(out[0].cpu().item())
252
+ return _predict
253
+
254
+ def load_onnx_predictor(file_bytes: bytes) -> Callable[[np.ndarray], float]:
255
+ if not _HAS_ONNX:
256
+ raise RuntimeError("onnxruntime not installed in this environment.")
257
+ import tempfile
258
+ with tempfile.NamedTemporaryFile(suffix=".onnx", delete=False) as f:
259
+ f.write(file_bytes)
260
+ path = f.name
261
+ sess = ort.InferenceSession(path, providers=["CPUExecutionProvider"])
262
+ input_name = sess.get_inputs()[0].name
263
+ def _predict(Xarr: np.ndarray) -> float:
264
+ y = sess.run(None, {input_name: Xarr.astype(np.float32)})[0]
265
+ if y.ndim == 2 and y.shape[1] == 2:
266
+ return float(y[0, 1])
267
+ if y.ndim == 2 and y.shape[1] == 1:
268
+ return float(y[0, 0])
269
+ return float(np.ravel(y)[0])
270
+ return _predict
271
+
272
+ # ------------------------------ Streamlit UI ------------------------------
273
+ st.set_page_config(page_title="Sundew Diabetes Watch", layout="wide")
274
+
275
+ # Language selector
276
+ lang_name = st.sidebar.selectbox("Language / Lugha / Taal / Harshe", list(LANGS.keys()), index=0)
277
+ LANG = LANGS[lang_name]
278
+ T = lambda s: tr(s, LANG, "en")
279
+
280
+ st.title("🌿 " + T("Sundew Diabetes Watch"))
281
+ st.caption(T("Energy-aware selective activation for diabetes monitoring — research demo (not medical advice)."))
282
+
283
+ # File upload / controls
284
+ left, right = st.columns([2, 1])
285
+ with left:
286
+ uploaded = st.file_uploader(
287
+ T("Upload CGM CSV (timestamp, glucose_mgdl, carbs_g, insulin_units, steps, hr)"),
288
+ type=["csv"],
289
+ )
290
+ use_synth = st.checkbox(T("Use synthetic example if no file uploaded"), value=True)
291
+ with right:
292
+ target_activation = st.slider(T("Target heavy-activation rate"), 0.05, 0.9, 0.25, 0.01)
293
+ temperature = st.slider(T("Gate temperature"), 0.02, 0.5, 0.08, 0.01)
294
+ mode = st.selectbox(T("Sundew mode"), ["tuned_v2", "conservative", "aggressive", "auto_tuned"], index=0)
295
+
296
+ # Backend selector with stable internal keys
297
+ backend_options = [
298
+ ("demo", T("Demo (Logistic Regression)")),
299
+ ("xgb", "XGBoost"),
300
+ ("torch", "PyTorch"),
301
+ ("onnx", "ONNX"),
302
+ ]
303
+ backend_label = st.sidebar.selectbox(T("Model backend"), [lbl for _, lbl in backend_options], index=0)
304
+ BACKEND_KEY = next(k for k, lbl in backend_options if lbl == backend_label)
305
+
306
+ model_file = None
307
+ if BACKEND_KEY in ("xgb", "torch", "onnx"):
308
+ model_file = st.sidebar.file_uploader(T("Upload trained model file"), type=["json", "bin", "pt", "pth", "onnx"], key="model")
309
+
310
+ # ------------------------------ Load/synthesize data ------------------------------
311
+ if uploaded is not None:
312
+ df = pd.read_csv(uploaded)
313
+ else:
314
+ if not use_synth:
315
+ st.stop()
316
+ rng = np.random.default_rng(7)
317
+ n = 600 # ~50 hours if 5-min cadence
318
+ t0 = pd.Timestamp.utcnow().floor("min")
319
+ times = [t0 + pd.Timedelta(minutes=5 * i) for i in range(n)]
320
+ base = 120 + 25 * np.sin(np.linspace(0, 10 * np.pi, n))
321
+ noise = rng.normal(0, 10, n)
322
+ meals = (rng.random(n) < 0.04).astype(float) * rng.normal(45, 15, n).clip(0, 120)
323
+ insulin = (rng.random(n) < 0.03).astype(float) * rng.normal(4, 1.2, n).clip(0, 8)
324
+ steps = rng.integers(0, 150, size=n)
325
+ hr = 70 + (steps > 80) * rng.integers(30, 60, size=n)
326
+ glucose = base + noise + 0.3 * meals - 0.8 * insulin
327
+ df = pd.DataFrame(
328
+ {
329
+ "timestamp": times,
330
+ "glucose_mgdl": np.round(glucose, 1),
331
+ "carbs_g": np.round(meals, 1),
332
+ "insulin_units": np.round(insulin, 1),
333
+ "steps": steps,
334
+ "hr": hr,
335
+ }
336
+ )
337
+
338
+ # Robust timestamp parsing (handles tz-aware, strings, epoch)
339
+ from pandas.api.types import is_datetime64_any_dtype
340
+ if "timestamp" not in df.columns:
341
+ st.error(T("CSV must include a 'timestamp' column."))
342
+ st.stop()
343
+
344
+ if not is_datetime64_any_dtype(df["timestamp"]):
345
+ df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True, errors="coerce")
346
+
347
+ # Localize if naive
348
+ if getattr(df["timestamp"].dt, "tz", None) is None:
349
+ df["timestamp"] = df["timestamp"].dt.tz_localize("UTC")
350
+
351
+ df = df.sort_values("timestamp").reset_index(drop=True)
352
+
353
+ # Rate-of-change mg/dL per minute
354
+ df["dt_min"] = df["timestamp"].diff().dt.total_seconds() / 60.0
355
+ df["glucose_prev"] = df["glucose_mgdl"].shift(1)
356
+ df["roc_mgdl_min"] = (df["glucose_mgdl"] - df["glucose_prev"]) / df["dt_min"]
357
+ df["roc_mgdl_min"] = df["roc_mgdl_min"].replace([np.inf, -np.inf], 0.0).fillna(0.0)
358
+
359
+ # ------------------------------ Heavy predictor selection ------------------------------
360
+ predict_proba: Optional[Callable[[np.ndarray], float]] = None
361
+ header_note = ""
362
+
363
+ if BACKEND_KEY == "demo":
364
+ predict_proba = build_demo_model(df)
365
+ header_note = T("Demo model trains per session for portability.")
366
+ elif BACKEND_KEY == "xgb" and model_file is not None:
367
+ try:
368
+ predict_proba = load_xgb_predictor(model_file.read())
369
+ header_note = T("XGBoost model loaded from file.")
370
+ except Exception as e:
371
+ st.warning(T("Could not load XGBoost model; falling back to Demo."))
372
+ predict_proba = build_demo_model(df)
373
+ header_note = T("Demo model used (no external file).")
374
+ elif BACKEND_KEY == "torch" and model_file is not None:
375
+ try:
376
+ predict_proba = load_torch_predictor(model_file.read())
377
+ header_note = T("PyTorch TorchScript model loaded.")
378
+ except Exception:
379
+ st.warning(T("Could not load PyTorch model; falling back to Demo."))
380
+ predict_proba = build_demo_model(df)
381
+ header_note = T("Demo model used (no external file).")
382
+ elif BACKEND_KEY == "onnx" and model_file is not None:
383
+ try:
384
+ predict_proba = load_onnx_predictor(model_file.read())
385
+ header_note = T("ONNX model loaded via onnxruntime.")
386
+ except Exception:
387
+ st.warning(T("Could not load ONNX model; falling back to Demo."))
388
+ predict_proba = build_demo_model(df)
389
+ header_note = T("Demo model used (no external file).")
390
+ else:
391
+ st.warning(T("Selected backend requires a model file. Falling back to Demo."))
392
+ predict_proba = build_demo_model(df)
393
+ header_note = T("Demo model used (no external file).")
394
+
395
+ st.info(header_note)
396
+
397
+ # ------------------------------ Gate + streaming loop ------------------------------
398
+ gate = SundewGate(target_activation=target_activation, temperature=temperature, mode=mode)
399
+
400
+ def make_X(row: pd.Series) -> np.ndarray:
401
+ return np.array(
402
+ [
403
+ [
404
+ row.get("glucose_mgdl", 0.0),
405
+ row.get("roc_mgdl_min", 0.0),
406
+ row.get("insulin_units", 0.0),
407
+ row.get("carbs_g", 0.0),
408
+ row.get("hr", 0.0),
409
+ ]
410
+ ],
411
+ dtype=np.float32,
412
+ )
413
+
414
+ records = []
415
+ alerts = []
416
+ for _, row in df.iterrows():
417
+ score = compute_lightweight_score(row)
418
+ open_gate = gate.decide(score)
419
+ decision = "SKIP"
420
+ proba = None
421
+ if open_gate and predict_proba is not None:
422
+ X = make_X(row)
423
+ try:
424
+ proba = float(predict_proba(X))
425
+ except Exception:
426
+ proba = None
427
+ decision = "RUN"
428
+ if proba is not None and proba >= 0.6:
429
+ alerts.append(
430
+ {
431
+ "timestamp": row["timestamp"],
432
+ "glucose": row["glucose_mgdl"],
433
+ "risk_proba": proba,
434
+ "note": T("⚠ Elevated 30-min risk — please check CGM and plan carbs/insulin."),
435
+ }
436
+ )
437
+ records.append(
438
+ {
439
+ "timestamp": row["timestamp"],
440
+ "glucose": row["glucose_mgdl"],
441
+ "roc": row["roc_mgdl_min"],
442
+ "score": score,
443
+ "gate": decision,
444
+ "risk_proba": proba,
445
+ }
446
+ )
447
+
448
+ out = pd.DataFrame(records)
449
+ events = len(out)
450
+ activations = int((out["gate"] == "RUN").sum())
451
+ rate = activations / max(events, 1)
452
+
453
+ c1, c2, c3 = st.columns(3)
454
+ c1.metric(T("Events"), f"{events}")
455
+ c2.metric(T("Heavy activations"), f"{activations}")
456
+ c3.metric(T("Activation rate"), f"{rate:.2%}")
457
+
458
+ st.line_chart(out.set_index("timestamp")["glucose"], height=220)
459
+ st.line_chart(out.set_index("timestamp")["score"], height=220)
460
+
461
+ st.subheader(T("Decisions (tail)"))
462
+ st.dataframe(out.tail(50))
463
+
464
+ st.subheader(T("Alerts"))
465
+ if alerts:
466
+ st.dataframe(pd.DataFrame(alerts))
467
+ else:
468
+ st.info(T("No high-risk alerts triggered in this window."))
469
+
470
+ # Footer: show Sundew version & engine status
471
+ try:
472
+ from importlib.metadata import version as _ver
473
+ _sundew_ver = _ver("sundew-algorithms")
474
+ except Exception:
475
+ _sundew_ver = "unknown"
476
+
477
+ engine_txt = f"sundew-algorithms {_sundew_ver}" if _HAS_SUNDEW else T("fallback gate (install sundew-algorithms)")
478
+ st.caption(T("Engine: ") + engine_txt)