mgbam commited on
Commit
76d896f
·
verified ·
1 Parent(s): c34ce9a

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +724 -550
app.py CHANGED
@@ -1,550 +1,724 @@
1
- from __future__ import annotations
2
-
3
- """Sundew Diabetes Commons – holistic, open Streamlit experience.
4
-
5
- This app demonstrates a lightweight gating pipeline with an optional native
6
- Sundew integration, feature engineering over CGM-like time series, a simple
7
- logistic baseline, and a compact UI for overview, treatment, lifestyle, and
8
- telemetry.
9
-
10
- ⚠️ Medical disclaimer: This software is for research & educational purposes only
11
- and does *not* provide medical advice. Always consult qualified clinicians.
12
-
13
- Copyright (c) 2025 The Sundew Diabetes Commons authors
14
- SPDX-License-Identifier: Apache-2.0
15
- """
16
-
17
- from dataclasses import dataclass
18
- from datetime import datetime, timedelta
19
- from typing import Any, Dict, List, Optional, Tuple
20
-
21
- import json
22
- import logging
23
- import math
24
-
25
- import numpy as np
26
- import pandas as pd
27
- import streamlit as st
28
- from sklearn.linear_model import LogisticRegression
29
- from sklearn.pipeline import Pipeline
30
- from sklearn.preprocessing import StandardScaler
31
-
32
- # -----------------------------------------------------------------------------
33
- # Optional Sundew dependency (kept import-safe for open source distribution)
34
- # -----------------------------------------------------------------------------
35
- try:
36
- from sundew import SundewAlgorithm # type: ignore[attr-defined]
37
- from sundew.config import SundewConfig
38
- from sundew.config_presets import get_preset
39
-
40
- _HAS_SUNDEW = True
41
- except Exception: # pragma: no cover - fallback when package is unavailable
42
- SundewAlgorithm = None # type: ignore
43
- SundewConfig = object # type: ignore
44
-
45
- def get_preset(_: str) -> Any: # type: ignore
46
- return None
47
-
48
- _HAS_SUNDEW = False
49
-
50
-
51
- LOGGER = logging.getLogger("sundew.diabetes.commons")
52
- if not LOGGER.handlers:
53
- logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
54
-
55
-
56
- # -----------------------------------------------------------------------------
57
- # Config & Gate
58
- # -----------------------------------------------------------------------------
59
- @dataclass
60
- class SundewGateConfig:
61
- target_activation: float = 0.22
62
- temperature: float = 0.08
63
- mode: str = "tuned_v2"
64
- use_native: bool = True
65
- rng_seed: Optional[int] = 17
66
-
67
-
68
- def _build_sundew_runtime(config: SundewGateConfig) -> Optional["SundewAlgorithm"]:
69
- """Try multiple Sundew constructor forms; fall back to None if unavailable."""
70
- if not (config.use_native and _HAS_SUNDEW and SundewAlgorithm is not None):
71
- return None
72
- try:
73
- preset = get_preset(config.mode)
74
- except Exception:
75
- LOGGER.warning("Could not load preset %s; using bare SundewConfig", config.mode)
76
- preset = SundewConfig() # type: ignore
77
- # best-effort attribute binding
78
- for attr, value in (
79
- ("target_activation_rate", config.target_activation),
80
- ("gate_temperature", config.temperature),
81
- ):
82
- try:
83
- setattr(preset, attr, value)
84
- except Exception:
85
- LOGGER.debug("Preset missing attribute %s", attr)
86
- # try common constructor signatures
87
- for constructor in (
88
- lambda: SundewAlgorithm(preset), # type: ignore[arg-type]
89
- lambda: SundewAlgorithm(config=preset), # type: ignore[arg-type]
90
- lambda: SundewAlgorithm(),
91
- ):
92
- try:
93
- return constructor()
94
- except Exception as exc:
95
- LOGGER.debug("Sundew constructor failed: %s", exc)
96
- continue
97
- return None
98
-
99
-
100
- class AdaptiveGate:
101
- """Adapter that hides Sundew/Fallback branching.
102
-
103
- If native Sundew is not present, uses a simple logistic gate whose threshold
104
- self-adjusts via a moving target activation rate.
105
- """
106
-
107
- def __init__(self, config: SundewGateConfig) -> None:
108
- self.config = config
109
- self._ema = 0.0
110
- self._tau = float(np.clip(config.target_activation, 0.05, 0.95))
111
- self._alpha = 0.05
112
- self._rng = np.random.default_rng(config.rng_seed)
113
- self.sundew: Optional["SundewAlgorithm"] = _build_sundew_runtime(config)
114
-
115
- def decide(self, score: float) -> bool:
116
- if self.sundew is not None:
117
- for attr in ("decide", "step", "open"):
118
- fn = getattr(self.sundew, attr, None)
119
- if callable(fn):
120
- try:
121
- return bool(fn(score))
122
- except Exception as exc:
123
- LOGGER.debug("Sundew.%s failed: %s", attr, exc)
124
- continue
125
- # Fallback: temperatured logistic on a normalized score
126
- normalized = float(np.clip(score / 1.4, 0.0, 1.0))
127
- temperature = max(self.config.temperature, 0.02)
128
- probability = 1.0 / (1.0 + math.exp(-(normalized - self._tau) / temperature))
129
- fired = bool(self._rng.random() < probability)
130
- # EMA of activations and threshold nudging toward target rate
131
- self._ema = (1 - self._alpha) * self._ema + self._alpha * (1.0 if fired else 0.0)
132
- self._tau += 0.05 * (self.config.target_activation - self._ema)
133
- self._tau = float(np.clip(self._tau, 0.05, 0.95))
134
- return fired
135
-
136
-
137
- # -----------------------------------------------------------------------------
138
- # Data utilities
139
- # -----------------------------------------------------------------------------
140
-
141
- def load_example_dataset(n_rows: int = 720) -> pd.DataFrame:
142
- rng = np.random.default_rng(17)
143
- t0 = pd.Timestamp.utcnow().floor("5min") - pd.Timedelta(minutes=5 * n_rows)
144
- timestamps = [t0 + pd.Timedelta(minutes=5 * i) for i in range(n_rows)]
145
- base = 118 + 28 * np.sin(np.linspace(0, 7 * math.pi, n_rows))
146
- noise = rng.normal(0, 12, n_rows)
147
- meals = (rng.random(n_rows) < 0.05).astype(float) * rng.normal(50, 18, n_rows).clip(0, 150)
148
- insulin = (rng.random(n_rows) < 0.03).astype(float) * rng.normal(4.2, 1.5, n_rows).clip(0, 10)
149
- steps = rng.integers(0, 200, size=n_rows)
150
- heart_rate = 68 + (steps > 90) * rng.integers(20, 45, size=n_rows)
151
- sleep_flag = (rng.random(n_rows) < 0.12).astype(float)
152
- stress_index = rng.uniform(0, 1, n_rows)
153
-
154
- glucose = base + noise
155
- for i in range(n_rows):
156
- if i >= 6:
157
- glucose[i] += 0.4 * meals[i - 6 : i].sum() / 6
158
- if i >= 4:
159
- glucose[i] -= 1.2 * insulin[i - 4 : i].sum() / 4
160
- if steps[i] > 100:
161
- glucose[i] -= 15
162
- glucose[180:200] = rng.normal(62, 5, 20)
163
- glucose[350:365] = rng.normal(210, 10, 15)
164
-
165
- return pd.DataFrame(
166
- {
167
- "timestamp": timestamps,
168
- "glucose_mgdl": np.round(np.clip(glucose, 40, 350), 1),
169
- "carbs_g": np.round(meals, 1),
170
- "insulin_units": np.round(insulin, 1),
171
- "steps": steps.astype(int),
172
- "hr": (heart_rate + rng.normal(0, 5, n_rows)).round().astype(int),
173
- "sleep_flag": sleep_flag,
174
- "stress_index": stress_index,
175
- }
176
- )
177
-
178
-
179
- def compute_features(df: pd.DataFrame) -> pd.DataFrame:
180
- df = df.copy().sort_values("timestamp").reset_index(drop=True)
181
- df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
182
-
183
- # Time delta in minutes (robust vs. dtype casting)
184
- dt_min = df["timestamp"].diff().dt.total_seconds().div(60).fillna(5.0)
185
-
186
- # Rate of change and smoothed baseline deviation
187
- glucose_prev = df["glucose_mgdl"].shift(1)
188
- df["roc_mgdl_min"] = (df["glucose_mgdl"] - glucose_prev).div(dt_min)
189
- df["roc_mgdl_min"] = df["roc_mgdl_min"].replace([np.inf, -np.inf], 0.0).fillna(0.0)
190
-
191
- ema = df["glucose_mgdl"].ewm(span=48, adjust=False).mean()
192
- df["deviation"] = (df["glucose_mgdl"] - ema).fillna(0.0)
193
-
194
- df["iob_proxy"] = df["insulin_units"].rolling(12, min_periods=1).sum() / 12.0
195
- df["cob_proxy"] = df["carbs_g"].rolling(12, min_periods=1).sum() / 12.0
196
- df["variability"] = df["glucose_mgdl"].rolling(24, min_periods=2).std().fillna(0.0)
197
-
198
- df["activity_factor"] = (df["steps"].div(200.0) + df["hr"].div(160.0)).clip(0, 1)
199
- df["sleep_flag"] = df.get("sleep_flag", 0.0)
200
- df["stress_index"] = df.get("stress_index", 0.5)
201
-
202
- return df[
203
- [
204
- "timestamp",
205
- "glucose_mgdl",
206
- "roc_mgdl_min",
207
- "deviation",
208
- "iob_proxy",
209
- "cob_proxy",
210
- "variability",
211
- "activity_factor",
212
- "sleep_flag",
213
- "stress_index",
214
- ]
215
- ].copy()
216
-
217
-
218
- # -----------------------------------------------------------------------------
219
- # Scoring & Modeling
220
- # -----------------------------------------------------------------------------
221
-
222
- def lightweight_score(row: pd.Series) -> float:
223
- glucose = row["glucose_mgdl"]
224
- roc = row["roc_mgdl_min"]
225
- deviation = row["deviation"]
226
- iob = row["iob_proxy"]
227
- cob = row["cob_proxy"]
228
- stress = row["stress_index"]
229
-
230
- score = 0.0
231
- score += max(0.0, (glucose - 180) / 80)
232
- score += max(0.0, (70 - glucose) / 30)
233
- score += abs(roc) / 6.0
234
- score += abs(deviation) / 100.0
235
- score += stress * 0.4
236
- score += max(0.0, (cob - iob) * 0.04)
237
-
238
- return float(np.clip(score, 0.0, 1.4))
239
-
240
-
241
- def train_simple_model(df: pd.DataFrame) -> Optional[Pipeline]:
242
- features = df[[
243
- "glucose_mgdl",
244
- "roc_mgdl_min",
245
- "iob_proxy",
246
- "cob_proxy",
247
- "activity_factor",
248
- "variability",
249
- ]]
250
- labels = (df["glucose_mgdl"] > 180).astype(int)
251
-
252
- model: Pipeline = Pipeline(
253
- [
254
- ("scaler", StandardScaler()),
255
- ("clf", LogisticRegression(max_iter=400, class_weight="balanced")),
256
- ]
257
- )
258
- try:
259
- model.fit(features, labels)
260
- return model
261
- except Exception as exc:
262
- LOGGER.warning("Model training failed: %s", exc)
263
- return None
264
-
265
-
266
- # -----------------------------------------------------------------------------
267
- # UI rendering
268
- # -----------------------------------------------------------------------------
269
-
270
- def render_overview(
271
- results: pd.DataFrame,
272
- alerts: List[Dict[str, Any]],
273
- gate_config: SundewGateConfig,
274
- ) -> None:
275
- total = len(results)
276
- activations = int(results["activated"].sum())
277
- activation_rate = activations / max(total, 1)
278
- energy_savings = max(0.0, 1.0 - activation_rate)
279
-
280
- col_a, col_b, col_c, col_d = st.columns(4)
281
- col_a.metric("Events", f"{total}")
282
- col_b.metric("Heavy activations", f"{activations} ({activation_rate:.1%})")
283
- col_c.metric("Estimated energy saved", f"{energy_savings:.1%}")
284
- col_d.metric("Alerts", f"{len(alerts)}")
285
-
286
- if gate_config.use_native and _HAS_SUNDEW:
287
- st.caption(
288
- "Energy savings follow 1 − activation rate. With native Sundew gating we target "
289
- f"≈{gate_config.target_activation:.0%} activations, so savings approach "
290
- f"{1 - gate_config.target_activation:.0%}."
291
- )
292
- else:
293
- st.warning(
294
- "Fallback gate active – heavy inference runs frequently, so savings mirror the observed activation rate."
295
- )
296
-
297
- with st.expander("Recent alerts", expanded=False):
298
- if alerts:
299
- st.table(pd.DataFrame(alerts).tail(10))
300
- else:
301
- st.info("No high-risk alerts in this window.")
302
-
303
- st.area_chart(results.set_index("timestamp")["glucose_mgdl"], height=220)
304
-
305
-
306
- def render_treatment_plan(medications: Dict[str, Any], next_visit: str) -> None:
307
- st.subheader("Full-cycle treatment support")
308
- st.write(
309
- "Upload or edit medication schedules, insulin titration guidance, and clinician notes."
310
- )
311
- st.json(medications, expanded=False)
312
- st.caption(f"Next scheduled review: {next_visit}")
313
-
314
-
315
- def render_lifestyle_support(results: pd.DataFrame) -> None:
316
- st.subheader("Lifestyle & wellbeing")
317
- recent = results.tail(96).copy()
318
- avg_glucose = recent["glucose_mgdl"].mean()
319
- active_minutes = int((recent["activity_factor"] > 0.4).sum() * 5)
320
-
321
- col1, col2 = st.columns(2)
322
- col1.metric("Average glucose (8h)", f"{avg_glucose:.1f} mg/dL")
323
- col2.metric("Active minutes", f"{active_minutes} min")
324
-
325
- st.markdown(
326
- """
327
- - Aim for gentle movement every hour you are awake.
328
- - Pair carbohydrates with protein/fiber to smooth spikes.
329
- - Sleep flagged recently? Try 10‑minute breathing before bed.
330
- - Journal one gratitude moment — stress strongly shapes risk.
331
- """
332
- )
333
-
334
-
335
- def render_community_actions() -> Dict[str, List[str]]:
336
- st.subheader("Community impact")
337
- st.write(
338
- "Invite families, caregivers, and clinics to the commons. Set up alerts, shared logs, and outreach."
339
- )
340
- contact_list = [
341
- "SMS: +233-200-000-111",
342
- "WhatsApp: Care Circle Group",
343
- "Clinic portal: sundew.health/community",
344
- ]
345
- st.table(pd.DataFrame({"Support channel": contact_list}))
346
- return {
347
- "Desired partners": ["Rural clinics", "Youth ambassadors", "Nutrition co-ops"],
348
- "Needs": ["Smartphone grants", "Solar charging kits", "Translation volunteers"],
349
- }
350
-
351
-
352
- def render_telemetry(results: pd.DataFrame, telemetry: List[Dict[str, Any]]) -> None:
353
- st.subheader("Telemetry & export")
354
- st.write(
355
- "Download event-level telemetry for validation, research, or regulatory reporting."
356
- )
357
- st.caption(
358
- "Energy savings are computed as 1 minus the observed activation rate. When the gate stays mostly open, savings naturally trend toward zero."
359
- )
360
- json_payload = json.dumps(telemetry, default=str, indent=2)
361
- st.download_button(
362
- label="Download telemetry (JSON)",
363
- data=json_payload,
364
- file_name="sundew_diabetes_telemetry.json",
365
- mime="application/json",
366
- )
367
- st.dataframe(results.tail(100), use_container_width=True)
368
-
369
-
370
- # -----------------------------------------------------------------------------
371
- # App
372
- # -----------------------------------------------------------------------------
373
-
374
- def main() -> None:
375
- st.set_page_config(page_title="Sundew Diabetes Commons", layout="wide", page_icon="🩺")
376
- st.title("Sundew Diabetes Commons")
377
- st.caption("Open, compassionate diabetes care — monitoring, treatment, lifestyle, community.")
378
-
379
- # Sidebar data
380
- st.sidebar.header("Load data")
381
- uploaded = st.sidebar.file_uploader("CGM / diary CSV", type=["csv"])
382
- use_example = st.sidebar.checkbox("Use synthetic example", True)
383
-
384
- # Sidebar – config
385
- st.sidebar.header("Sundew configuration")
386
- use_native = st.sidebar.checkbox(
387
- "Use native Sundew gating",
388
- value=_HAS_SUNDEW,
389
- help="Disable to demo the lightweight fallback gate only.",
390
- )
391
- target_activation = st.sidebar.slider("Target activation", 0.05, 0.90, 0.22, 0.01)
392
- temperature = st.sidebar.slider("Gate temperature", 0.02, 0.50, 0.08, 0.01)
393
- mode = st.sidebar.selectbox("Preset", ["tuned_v2", "conservative", "aggressive", "auto_tuned"], index=0)
394
-
395
- # Data source
396
- if uploaded is not None:
397
- df = pd.read_csv(uploaded)
398
- elif use_example:
399
- df = load_example_dataset()
400
- else:
401
- st.stop()
402
-
403
- features = compute_features(df)
404
- model = train_simple_model(features)
405
-
406
- gate_config = SundewGateConfig(
407
- target_activation=target_activation,
408
- temperature=temperature,
409
- mode=mode,
410
- use_native=use_native,
411
- )
412
- gate = AdaptiveGate(gate_config)
413
-
414
- telemetry: List[Dict[str, Any]] = []
415
- records: List[Dict[str, Any]] = []
416
- alerts: List[Dict[str, Any]] = []
417
-
418
- progress = st.progress(0)
419
- status = st.empty()
420
-
421
- for idx, row in enumerate(features.itertuples(index=False), start=1):
422
- row_s = pd.Series(row._asdict())
423
- score = lightweight_score(row_s)
424
- should_run = gate.decide(score)
425
- risk_proba: Optional[float] = None
426
-
427
- if should_run and model is not None:
428
- sample = np.array([[
429
- row.glucose_mgdl,
430
- row.roc_mgdl_min,
431
- row.iob_proxy,
432
- row.cob_proxy,
433
- row.activity_factor,
434
- row.variability,
435
- ]])
436
- try:
437
- risk_proba = float(model.predict_proba(sample)[0, 1]) # type: ignore[index]
438
- except Exception as exc:
439
- LOGGER.debug("predict_proba failed: %s", exc)
440
- risk_proba = None
441
-
442
- if (risk_proba is not None) and (risk_proba >= 0.6):
443
- alerts.append({
444
- "timestamp": row.timestamp,
445
- "glucose": row.glucose_mgdl,
446
- "risk": risk_proba,
447
- "message": "Check CGM, hydrate, plan balanced snack/insulin",
448
- })
449
-
450
- records.append({
451
- "timestamp": row.timestamp,
452
- "glucose_mgdl": row.glucose_mgdl,
453
- "roc_mgdl_min": row.roc_mgdl_min,
454
- "deviation": row.deviation,
455
- "iob_proxy": row.iob_proxy,
456
- "cob_proxy": row.cob_proxy,
457
- "variability": row.variability,
458
- "activity_factor": row.activity_factor,
459
- "score": score,
460
- "activated": should_run,
461
- "risk_proba": risk_proba,
462
- })
463
-
464
- telemetry.append({
465
- "timestamp": str(row.timestamp),
466
- "score": score,
467
- "activated": should_run,
468
- "risk_proba": risk_proba,
469
- })
470
-
471
- progress.progress(idx / len(features))
472
- status.text(f"Processing event {idx}/{len(features)}")
473
-
474
- progress.empty()
475
- status.empty()
476
-
477
- results = pd.DataFrame(records)
478
-
479
- tabs = st.tabs(["Overview", "Treatment", "Lifestyle", "Community", "Telemetry"])
480
-
481
- with tabs[0]:
482
- render_overview(results, alerts, gate_config)
483
-
484
- with tabs[1]:
485
- st.subheader("Full-cycle treatment support")
486
- default_plan = {
487
- "Insulin": {
488
- "Basal": "14u glargine at 21:00",
489
- "Bolus": "1u per 10g carbs + correction 1u per 40 mg/dL over 140",
490
- },
491
- "Oral medications": {
492
- "Metformin": "500mg breakfast + 500mg dinner",
493
- "Empagliflozin": "10mg once daily (if eGFR > 45)",
494
- },
495
- "Monitoring": [
496
- "CGM sensor change every 10 days",
497
- "Morning fasted CGM calibration",
498
- "Weekly telehealth coaching",
499
- "Quarterly in-person clinician review",
500
- ],
501
- "Safety plan": [
502
- "Carry glucose tabs + glucagon kit",
503
- "Emergency contact: +233-200-000-888",
504
- ],
505
- "Lifestyle": [
506
- "30 min brisk walk 5x/week",
507
- "Bedtime snack if glucose < 110 mg/dL",
508
- "Hydrate 2L water daily unless contraindicated",
509
- ],
510
- }
511
- st.caption("Upload or edit schedules, medication titration guidance, and clinician notes.")
512
- uploaded_plan = st.file_uploader("Optional plan JSON", type=["json"], key="plan_uploader")
513
- plan_text = st.text_area("Edit plan JSON", json.dumps(default_plan, indent=2), height=240)
514
-
515
- plan_data = default_plan
516
- if uploaded_plan is not None:
517
- try:
518
- plan_data = json.load(uploaded_plan)
519
- except Exception as exc:
520
- st.error(f"Could not parse uploaded plan JSON: {exc}")
521
- plan_data = default_plan
522
- else:
523
- try:
524
- plan_data = json.loads(plan_text)
525
- except Exception as exc:
526
- st.warning(f"Using default plan because text could not be parsed: {exc}")
527
- plan_data = default_plan
528
-
529
- next_visit = (datetime.utcnow() + timedelta(days=30)).strftime("%Y-%m-%d (telehealth)")
530
- render_treatment_plan(plan_data, next_visit=next_visit)
531
-
532
- with tabs[2]:
533
- render_lifestyle_support(results)
534
-
535
- with tabs[3]:
536
- community_items = render_community_actions()
537
- st.json(community_items, expanded=False)
538
-
539
- with tabs[4]:
540
- render_telemetry(results, telemetry)
541
-
542
- st.sidebar.markdown("---")
543
- status_text = (
544
- "native gating" if gate_config.use_native and gate.sundew is not None else "fallback gate"
545
- )
546
- st.sidebar.caption(f"Sundew status: {status_text}")
547
-
548
-
549
- if __name__ == "__main__":
550
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+
4
+ from datetime import datetime, timedelta
5
+
6
+
7
+ """Sundew Diabetes Commons – holistic, open Streamlit experience."""
8
+
9
+
10
+ import json
11
+
12
+
13
+ import logging
14
+
15
+
16
+ import math
17
+
18
+
19
+ import time
20
+
21
+
22
+ from dataclasses import dataclass
23
+
24
+
25
+ from typing import Any, Dict, List, Optional, Tuple
26
+
27
+
28
+ import numpy as np
29
+
30
+
31
+ import pandas as pd
32
+
33
+
34
+ import streamlit as st
35
+
36
+
37
+ from sklearn.linear_model import LogisticRegression
38
+
39
+
40
+ from sklearn.pipeline import Pipeline
41
+
42
+
43
+ from sklearn.preprocessing import StandardScaler
44
+
45
+
46
+ try:
47
+
48
+ from sundew import SundewAlgorithm # type: ignore[attr-defined]
49
+
50
+ from sundew.config import SundewConfig
51
+
52
+ from sundew.config_presets import get_preset
53
+
54
+ _HAS_SUNDEW = True
55
+
56
+
57
+ except Exception: # fallback when package is unavailable
58
+
59
+ SundewAlgorithm = None # type: ignore
60
+
61
+ SundewConfig = object # type: ignore
62
+
63
+ def get_preset(_: str) -> Any: # type: ignore
64
+
65
+ return None
66
+
67
+ _HAS_SUNDEW = False
68
+
69
+
70
+ LOGGER = logging.getLogger("sundew.diabetes.commons")
71
+
72
+
73
+ @dataclass
74
+ class SundewGateConfig:
75
+
76
+ target_activation: float = 0.22
77
+
78
+ temperature: float = 0.08
79
+
80
+ mode: str = "tuned_v2"
81
+
82
+ use_native: bool = True
83
+
84
+
85
+ def _build_sundew_runtime(config: SundewGateConfig) -> Optional[SundewAlgorithm]:
86
+
87
+ if not (config.use_native and _HAS_SUNDEW and SundewAlgorithm is not None):
88
+
89
+ return None
90
+
91
+ try:
92
+
93
+ preset = get_preset(config.mode)
94
+
95
+ except Exception:
96
+
97
+ preset = SundewConfig() # type: ignore
98
+
99
+ for attr, value in (
100
+ ("target_activation_rate", config.target_activation),
101
+ ("gate_temperature", config.temperature),
102
+ ):
103
+
104
+ try:
105
+
106
+ setattr(preset, attr, value)
107
+
108
+ except Exception:
109
+
110
+ pass
111
+
112
+ for constructor in (
113
+ lambda: SundewAlgorithm(preset), # type: ignore[arg-type]
114
+ lambda: SundewAlgorithm(config=preset), # type: ignore[arg-type]
115
+ lambda: SundewAlgorithm(),
116
+ ):
117
+
118
+ try:
119
+
120
+ return constructor()
121
+
122
+ except Exception:
123
+
124
+ continue
125
+
126
+ return None
127
+
128
+
129
+ class AdaptiveGate:
130
+ """Adapter that hides Sundew/Fallback branching."""
131
+
132
+ def __init__(self, config: SundewGateConfig) -> None:
133
+
134
+ self.config = config
135
+
136
+ self._ema = 0.0
137
+
138
+ self._tau = float(np.clip(config.target_activation, 0.05, 0.95))
139
+
140
+ self._alpha = 0.05
141
+
142
+ self.sundew: Optional[SundewAlgorithm] = _build_sundew_runtime(config)
143
+
144
+ def decide(self, score: float) -> bool:
145
+
146
+ if self.sundew is not None:
147
+
148
+ for attr in ("decide", "step", "open"):
149
+
150
+ fn = getattr(self.sundew, attr, None)
151
+
152
+ if callable(fn):
153
+
154
+ try:
155
+
156
+ return bool(fn(score))
157
+
158
+ except Exception:
159
+
160
+ continue
161
+
162
+ normalized = float(np.clip(score / 1.4, 0.0, 1.0))
163
+
164
+ temperature = max(self.config.temperature, 0.02)
165
+
166
+ probability = 1.0 / (1.0 + math.exp(-(normalized - self._tau) / temperature))
167
+
168
+ fired = bool(np.random.rand() < probability)
169
+
170
+ self._ema = (1 - self._alpha) * self._ema + self._alpha * (
171
+ 1.0 if fired else 0.0
172
+ )
173
+
174
+ self._tau += 0.05 * (self.config.target_activation - self._ema)
175
+
176
+ self._tau = float(np.clip(self._tau, 0.05, 0.95))
177
+
178
+ return fired
179
+
180
+
181
+ def load_example_dataset(n_rows: int = 720) -> pd.DataFrame:
182
+
183
+ rng = np.random.default_rng(17)
184
+
185
+ t0 = pd.Timestamp.utcnow().floor("5min") - pd.Timedelta(minutes=5 * n_rows)
186
+
187
+ timestamps = [t0 + pd.Timedelta(minutes=5 * i) for i in range(n_rows)]
188
+
189
+ base = 118 + 28 * np.sin(np.linspace(0, 7 * math.pi, n_rows))
190
+
191
+ noise = rng.normal(0, 12, n_rows)
192
+
193
+ meals = (rng.random(n_rows) < 0.05).astype(float) * rng.normal(50, 18, n_rows).clip(
194
+ 0, 150
195
+ )
196
+
197
+ insulin = (rng.random(n_rows) < 0.03).astype(float) * rng.normal(
198
+ 4.2, 1.5, n_rows
199
+ ).clip(0, 10)
200
+
201
+ steps = rng.integers(0, 200, size=n_rows)
202
+
203
+ heart_rate = 68 + (steps > 90) * rng.integers(20, 45, size=n_rows)
204
+
205
+ sleep_flag = (rng.random(n_rows) < 0.12).astype(float)
206
+
207
+ stress_index = rng.uniform(0, 1, n_rows)
208
+
209
+ glucose = base + noise
210
+
211
+ for i in range(n_rows):
212
+
213
+ if i >= 6:
214
+
215
+ glucose[i] += 0.4 * meals[i - 6 : i].sum() / 6
216
+
217
+ if i >= 4:
218
+
219
+ glucose[i] -= 1.2 * insulin[i - 4 : i].sum() / 4
220
+
221
+ if steps[i] > 100:
222
+
223
+ glucose[i] -= 15
224
+
225
+ glucose[180:200] = rng.normal(62, 5, 20)
226
+
227
+ glucose[350:365] = rng.normal(210, 10, 15)
228
+
229
+ return pd.DataFrame(
230
+ {
231
+ "timestamp": timestamps,
232
+ "glucose_mgdl": np.round(np.clip(glucose, 40, 350), 1),
233
+ "carbs_g": np.round(meals, 1),
234
+ "insulin_units": np.round(insulin, 1),
235
+ "steps": steps.astype(int),
236
+ "hr": (heart_rate + rng.normal(0, 5, n_rows)).round().astype(int),
237
+ "sleep_flag": sleep_flag,
238
+ "stress_index": stress_index,
239
+ }
240
+ )
241
+
242
+
243
+ def compute_features(df: pd.DataFrame) -> pd.DataFrame:
244
+
245
+ df = df.copy().sort_values("timestamp").reset_index(drop=True)
246
+
247
+ df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
248
+
249
+ df["glucose_prev"] = df["glucose_mgdl"].shift(1)
250
+
251
+ dt = (
252
+ df["timestamp"].astype("int64") - df["timestamp"].shift(1).astype("int64")
253
+ ) / 60e9
254
+
255
+ df["roc_mgdl_min"] = (df["glucose_mgdl"] - df["glucose_prev"]) / dt
256
+
257
+ df["roc_mgdl_min"] = df["roc_mgdl_min"].replace([np.inf, -np.inf], 0.0).fillna(0.0)
258
+
259
+ ema = df["glucose_mgdl"].ewm(span=48, adjust=False).mean()
260
+
261
+ df["deviation"] = (df["glucose_mgdl"] - ema).fillna(0.0)
262
+
263
+ df["iob_proxy"] = df["insulin_units"].rolling(12, min_periods=1).sum() / 12.0
264
+
265
+ df["cob_proxy"] = df["carbs_g"].rolling(12, min_periods=1).sum() / 12.0
266
+
267
+ df["variability"] = df["glucose_mgdl"].rolling(24, min_periods=2).std().fillna(0.0)
268
+
269
+ df["activity_factor"] = (df["steps"] / 200.0 + df["hr"] / 160.0).clip(0, 1)
270
+
271
+ df["sleep_flag"] = df["sleep_flag"].fillna(0.0) if "sleep_flag" in df else 0.0
272
+
273
+ df["stress_index"] = df["stress_index"].fillna(0.5) if "stress_index" in df else 0.5
274
+
275
+ return df[
276
+ [
277
+ "timestamp",
278
+ "glucose_mgdl",
279
+ "roc_mgdl_min",
280
+ "deviation",
281
+ "iob_proxy",
282
+ "cob_proxy",
283
+ "variability",
284
+ "activity_factor",
285
+ "sleep_flag",
286
+ "stress_index",
287
+ ]
288
+ ].copy()
289
+
290
+
291
+ def lightweight_score(row: pd.Series) -> float:
292
+
293
+ glucose = row["glucose_mgdl"]
294
+
295
+ roc = row["roc_mgdl_min"]
296
+
297
+ deviation = row["deviation"]
298
+
299
+ iob = row["iob_proxy"]
300
+
301
+ cob = row["cob_proxy"]
302
+
303
+ stress = row["stress_index"]
304
+
305
+ score = 0.0
306
+
307
+ score += max(0.0, (glucose - 180) / 80)
308
+
309
+ score += max(0.0, (70 - glucose) / 30)
310
+
311
+ score += abs(roc) / 6.0
312
+
313
+ score += abs(deviation) / 100.0
314
+
315
+ score += stress * 0.4
316
+
317
+ score += max(0.0, (cob - iob) * 0.04)
318
+
319
+ return float(np.clip(score, 0.0, 1.4))
320
+
321
+
322
+ def train_simple_model(df: pd.DataFrame):
323
+
324
+ features = df[
325
+ [
326
+ "glucose_mgdl",
327
+ "roc_mgdl_min",
328
+ "iob_proxy",
329
+ "cob_proxy",
330
+ "activity_factor",
331
+ "variability",
332
+ ]
333
+ ]
334
+
335
+ labels = (df["glucose_mgdl"] > 180).astype(int)
336
+
337
+ model = Pipeline(
338
+ [
339
+ ("scaler", StandardScaler()),
340
+ ("clf", LogisticRegression(max_iter=400, class_weight="balanced")),
341
+ ]
342
+ )
343
+
344
+ try:
345
+
346
+ model.fit(features, labels)
347
+
348
+ return model
349
+
350
+ except Exception:
351
+
352
+ return None
353
+
354
+
355
+ def render_overview(
356
+ results: pd.DataFrame,
357
+ alerts: List[Dict[str, Any]],
358
+ gate_config: SundewGateConfig,
359
+ ) -> None:
360
+
361
+ total = len(results)
362
+
363
+ activations = int(results["activated"].sum())
364
+
365
+ activation_rate = activations / max(total, 1)
366
+
367
+ energy_savings = max(0.0, 1.0 - activation_rate)
368
+
369
+ col_a, col_b, col_c, col_d = st.columns(4)
370
+
371
+ col_a.metric("Events", f"{total}")
372
+
373
+ col_b.metric("Heavy activations", f"{activations} ({activation_rate:.1%})")
374
+
375
+ col_c.metric("Estimated energy saved", f"{energy_savings:.1%}")
376
+
377
+ col_d.metric("Alerts", f"{len(alerts)}")
378
+
379
+ if gate_config.use_native and _HAS_SUNDEW:
380
+
381
+ st.caption(
382
+ "Energy savings follow 1 − activation rate. With native Sundew gating we target "
383
+ f"≈{gate_config.target_activation:.0%} activations, so savings approach "
384
+ f"{1 - gate_config.target_activation:.0%}."
385
+ )
386
+
387
+ else:
388
+
389
+ st.warning(
390
+ "Fallback gate active – heavy inference runs frequently, so savings mirror the observed activation rate."
391
+ )
392
+
393
+ with st.expander("Recent alerts", expanded=False):
394
+
395
+ if alerts:
396
+
397
+ st.table(pd.DataFrame(alerts).tail(10))
398
+
399
+ else:
400
+
401
+ st.info("No high-risk alerts in this window.")
402
+
403
+ st.area_chart(results.set_index("timestamp")["glucose_mgdl"], height=220)
404
+
405
+
406
+ def render_treatment_plan(medications: Dict[str, Any], next_visit: str) -> None:
407
+ """Display medication plan guidance within the treatment tab."""
408
+ st.subheader("Full-cycle treatment support")
409
+ st.write(
410
+ "Upload or edit medication schedules, insulin titration guidance, and clinician notes."
411
+ )
412
+ st.json(medications, expanded=False)
413
+ st.caption(f"Next scheduled review: {next_visit}")
414
+
415
+
416
+ def render_lifestyle_support(results: pd.DataFrame) -> None:
417
+
418
+ st.subheader("Lifestyle & wellbeing")
419
+
420
+ recent = results.tail(96).copy()
421
+
422
+ avg_glucose = recent["glucose_mgdl"].mean()
423
+
424
+ active_minutes = int((recent["activity_factor"] > 0.4).sum() * 5)
425
+
426
+ col1, col2 = st.columns(2)
427
+
428
+ col1.metric("Average glucose (8h)", f"{avg_glucose:.1f} mg/dL")
429
+
430
+ col2.metric("Active minutes", f"{active_minutes} min")
431
+
432
+ st.markdown(
433
+ """
434
+
435
+
436
+
437
+
438
+
439
+ - Aim for gentle movement every hour you are awake.
440
+
441
+
442
+
443
+
444
+
445
+ - Pair carbohydrates with protein/fiber to smooth spikes.
446
+
447
+
448
+
449
+
450
+
451
+ - Sleep flagged recently? Try 10-minute breathing before bed.
452
+
453
+
454
+
455
+
456
+
457
+ - Journal one gratitude moment—stress strongly shapes risk.
458
+
459
+
460
+
461
+
462
+
463
+ """
464
+ )
465
+
466
+
467
+ def render_community_actions() -> Dict[str, List[str]]:
468
+
469
+ st.subheader("Community impact")
470
+
471
+ st.write(
472
+ "Invite families, caregivers, and clinics to the commons. Set up alerts, shared logs, and outreach."
473
+ )
474
+
475
+ contact_list = [
476
+ "SMS: +233-200-000-111",
477
+ "WhatsApp: Care Circle Group",
478
+ "Clinic portal: sundew.health/community",
479
+ ]
480
+
481
+ st.table(pd.DataFrame({"Support channel": contact_list}))
482
+
483
+ return {
484
+ "Desired partners": ["Rural clinics", "Youth ambassadors", "Nutrition co-ops"],
485
+ "Needs": ["Smartphone grants", "Solar charging kits", "Translation volunteers"],
486
+ }
487
+
488
+
489
+ def render_telemetry(results: pd.DataFrame, telemetry: List[Dict[str, Any]]) -> None:
490
+ """Allow operators to export telemetry and inspect recent events."""
491
+ st.subheader("Telemetry & export")
492
+ st.write(
493
+ "Download event-level telemetry for validation, research, or regulatory reporting."
494
+ )
495
+ st.caption(
496
+ "Energy savings are computed as 1 minus the observed activation rate. When the gate stays mostly open, savings naturally trend toward zero."
497
+ )
498
+ json_payload = json.dumps(telemetry, default=str, indent=2)
499
+ st.download_button(
500
+ label="Download telemetry (JSON)",
501
+ data=json_payload,
502
+ file_name="sundew_diabetes_telemetry.json",
503
+ mime="application/json",
504
+ )
505
+ st.dataframe(results.tail(100), use_container_width=True)
506
+
507
+
508
+ def main() -> None:
509
+ """Streamlit entry point for the Sundew diabetes commons demo."""
510
+ st.set_page_config(
511
+ page_title="Sundew Diabetes Commons",
512
+ layout="wide",
513
+ page_icon="🕊",
514
+ )
515
+ st.title("Sundew Diabetes Commons")
516
+ st.caption(
517
+ "Open, compassionate diabetes care—monitoring, treatment, lifestyle, community."
518
+ )
519
+
520
+ st.sidebar.header("Load data")
521
+ uploaded = st.sidebar.file_uploader("CGM / diary CSV", type=["csv"])
522
+ use_example = st.sidebar.checkbox("Use synthetic example", value=True)
523
+
524
+ st.sidebar.header("Sundew configuration")
525
+ use_native = st.sidebar.checkbox(
526
+ "Use native Sundew gating",
527
+ value=_HAS_SUNDEW,
528
+ help="Disable to demo the lightweight fallback gate only.",
529
+ )
530
+ target_activation = st.sidebar.slider("Target activation", 0.05, 0.90, 0.22, 0.01)
531
+ temperature = st.sidebar.slider("Gate temperature", 0.02, 0.50, 0.08, 0.01)
532
+ mode = st.sidebar.selectbox(
533
+ "Preset", ["tuned_v2", "conservative", "aggressive", "auto_tuned"], index=0
534
+ )
535
+
536
+ if uploaded is not None:
537
+ df = pd.read_csv(uploaded)
538
+ elif use_example:
539
+ df = load_example_dataset()
540
+ else:
541
+ st.info("Upload a CSV file or enable the synthetic example to continue.")
542
+ st.stop()
543
+
544
+ features = compute_features(df)
545
+ model = train_simple_model(features)
546
+
547
+ gate_config = SundewGateConfig(
548
+ target_activation=target_activation,
549
+ temperature=temperature,
550
+ mode=mode,
551
+ use_native=use_native,
552
+ )
553
+ gate = AdaptiveGate(gate_config)
554
+
555
+ telemetry: List[Dict[str, Any]] = []
556
+ records: List[Dict[str, Any]] = []
557
+ alerts: List[Dict[str, Any]] = []
558
+
559
+ total_events = len(features)
560
+ progress = st.progress(0.0)
561
+ status = st.empty()
562
+
563
+ for idx, row in enumerate(features.itertuples(index=False), start=1):
564
+ event = row._asdict()
565
+ score = lightweight_score(pd.Series(event))
566
+ should_run = gate.decide(score)
567
+ risk_proba: Optional[float] = None
568
+
569
+ if should_run and model is not None:
570
+ sample_df = pd.DataFrame(
571
+ [
572
+ [
573
+ event["glucose_mgdl"],
574
+ event["roc_mgdl_min"],
575
+ event["iob_proxy"],
576
+ event["cob_proxy"],
577
+ event["activity_factor"],
578
+ event["variability"],
579
+ ]
580
+ ],
581
+ columns=[
582
+ "glucose_mgdl",
583
+ "roc_mgdl_min",
584
+ "iob_proxy",
585
+ "cob_proxy",
586
+ "activity_factor",
587
+ "variability",
588
+ ],
589
+ )
590
+ try:
591
+ risk_proba = float(model.predict_proba(sample_df)[0, 1]) # type: ignore[index]
592
+ except Exception as exc:
593
+ LOGGER.debug("Risk model inference failed: %s", exc)
594
+ risk_proba = None
595
+
596
+ if risk_proba is not None and risk_proba >= 0.6:
597
+ alerts.append(
598
+ {
599
+ "timestamp": event["timestamp"],
600
+ "glucose": event["glucose_mgdl"],
601
+ "risk": risk_proba,
602
+ "message": "Check CGM, hydrate, plan balanced snack/insulin",
603
+ }
604
+ )
605
+
606
+ records.append(
607
+ {
608
+ "timestamp": event["timestamp"],
609
+ "glucose_mgdl": event["glucose_mgdl"],
610
+ "roc_mgdl_min": event["roc_mgdl_min"],
611
+ "deviation": event["deviation"],
612
+ "iob_proxy": event["iob_proxy"],
613
+ "cob_proxy": event["cob_proxy"],
614
+ "variability": event["variability"],
615
+ "activity_factor": event["activity_factor"],
616
+ "score": score,
617
+ "activated": should_run,
618
+ "risk_proba": risk_proba,
619
+ }
620
+ )
621
+
622
+ telemetry.append(
623
+ {
624
+ "timestamp": str(event["timestamp"]),
625
+ "score": score,
626
+ "activated": should_run,
627
+ "risk_proba": risk_proba,
628
+ }
629
+ )
630
+
631
+ progress.progress(idx / max(total_events, 1))
632
+ status.text(f"Processing event {idx}/{total_events}")
633
+
634
+ progress.empty()
635
+ status.empty()
636
+
637
+ results = pd.DataFrame(records)
638
+ tabs = st.tabs(["Overview", "Treatment", "Lifestyle", "Community", "Telemetry"])
639
+
640
+ with tabs[0]:
641
+ render_overview(results, alerts, gate_config)
642
+
643
+ with tabs[1]:
644
+ default_plan = {
645
+ "Insulin": {
646
+ "Basal": "14u glargine at 21:00",
647
+ "Bolus": "1u per 10g carbs + correction 1u per 40 mg/dL over 140",
648
+ },
649
+ "Oral medications": {
650
+ "Metformin": "500mg breakfast + 500mg dinner",
651
+ "Empagliflozin": "10mg once daily (if eGFR > 45)",
652
+ },
653
+ "Monitoring": [
654
+ "CGM sensor change every 10 days",
655
+ "Morning fasted CGM calibration",
656
+ "Weekly telehealth coaching",
657
+ "Quarterly in-person clinician review",
658
+ ],
659
+ "Safety plan": [
660
+ "Carry glucose tabs + glucagon kit",
661
+ "Emergency contact: +233-200-000-888",
662
+ ],
663
+ "Lifestyle": [
664
+ "30 min brisk walk 5x/week",
665
+ "Bedtime snack if glucose < 110 mg/dL",
666
+ "Hydrate 2L water daily unless contraindicated",
667
+ ],
668
+ }
669
+ st.caption(
670
+ "Upload or edit schedules, medication titration guidance, and clinician notes."
671
+ )
672
+ uploaded_plan = st.file_uploader(
673
+ "Optional plan JSON", type=["json"], key="plan_uploader"
674
+ )
675
+ plan_text = st.text_area(
676
+ "Edit plan JSON",
677
+ json.dumps(default_plan, indent=2),
678
+ height=240,
679
+ key="plan_editor",
680
+ )
681
+
682
+ plan_data = default_plan
683
+ if uploaded_plan is not None:
684
+ try:
685
+ plan_data = json.load(uploaded_plan)
686
+ except Exception as exc:
687
+ st.error(f"Could not parse uploaded plan JSON: {exc}")
688
+ plan_data = default_plan
689
+ else:
690
+ try:
691
+ plan_data = json.loads(plan_text)
692
+ except Exception as exc:
693
+ st.warning(
694
+ f"Using default plan because text could not be parsed: {exc}"
695
+ )
696
+ plan_data = default_plan
697
+
698
+ next_visit = (datetime.utcnow() + timedelta(days=30)).strftime(
699
+ "%Y-%m-%d (telehealth)"
700
+ )
701
+ render_treatment_plan(plan_data, next_visit=next_visit)
702
+
703
+ with tabs[2]:
704
+ render_lifestyle_support(results)
705
+
706
+ with tabs[3]:
707
+ community_items = render_community_actions()
708
+ st.json(community_items, expanded=False)
709
+
710
+ with tabs[4]:
711
+ render_telemetry(results, telemetry)
712
+
713
+ st.sidebar.markdown("---")
714
+ status_text = (
715
+ "native gating"
716
+ if gate_config.use_native and gate.sundew is not None
717
+ else "fallback gate"
718
+ )
719
+ st.sidebar.caption(f"Sundew status: {status_text}")
720
+
721
+
722
+ if __name__ == "__main__":
723
+
724
+ main()