SChodavarpu commited on
Commit
b4359b0
·
verified ·
1 Parent(s): 413fd37

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +181 -88
app.py CHANGED
@@ -1,24 +1,61 @@
1
  # app.py
2
  import math
3
- import numpy as np
 
4
  import gradio as gr
5
 
6
  # ==========================================
7
  # CARPL Multi-Use-Case ROI Calculators (MMG / FFR-CT / MSK AI)
8
  # Shared UI/UX: Inter font, white cards, pill header, Overall + Tabs + Waterfall + Evidence + CTA
 
9
  # ==========================================
10
 
11
  USE_CASES = ["Mammography AI (MMG)", "FFR-CT AI", "MSK AI (ER/Trauma)"]
12
- CTA_URL = "https://carpl.ai/contact-us" # swap to HubSpot/Calendly if you want
13
  CTA_LABEL = "Book a 15-min walkthrough"
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  # ---------- Helpers ----------
16
  def usd(x: float, digits: int = 0) -> str:
17
  if x == math.inf:
18
  return "∞"
19
  try:
20
- fmt = f"{{x:,.{digits}f}}"
21
- return "$" + fmt.format(x=x)
22
  except Exception:
23
  return "$0"
24
 
@@ -31,8 +68,17 @@ def pct(x: float, digits: int = 1) -> str:
31
  def clamp_nonneg(x: float) -> float:
32
  return max(0.0, float(x))
33
 
34
- def label_pct(delta: float) -> str:
35
- return f"{max(0.0, delta)*100:.1f}%"
 
 
 
 
 
 
 
 
 
36
 
37
  # ---------- MMG (Mammography) ----------
38
  def compute_mmg(
@@ -55,17 +101,14 @@ def compute_mmg(
55
  integration_overhead_monthly: float,
56
  cloud_compute_monthly: float,
57
  ):
58
- # Uptake fixed to 100% for MMG (can be toggled in backend if needed)
59
  monthly_ai_cases = clamp_nonneg(monthly_volume)
60
  annual_ai_cases = monthly_ai_cases * 12.0
61
 
62
- # Clinical deltas
63
  errors_reduced = clamp_nonneg(monthly_ai_cases * (base_ppr - ai_ppr))
64
  discrepant_flags = clamp_nonneg(monthly_ai_cases * (ai_audit_rate - base_audit_rate))
65
  recalls_avoided = clamp_nonneg(monthly_ai_cases * (base_recall_rate - ai_recall_rate))
66
  earlier_detections = clamp_nonneg(monthly_ai_cases * (early_detect_uplift_per_1000 / 1000.0))
67
 
68
- # Operational
69
  base_read_seconds = read_minutes * 60.0
70
  hours_saved = clamp_nonneg(monthly_ai_cases * (base_read_seconds * read_reduction_pct) / 3600.0)
71
  workload_reduction_pct = read_reduction_pct
@@ -73,7 +116,6 @@ def compute_mmg(
73
  capacity_increase_pct = (1.0 / max(1e-6, (1.0 - read_reduction_pct)) - 1.0)
74
  value_time_saved_month = hours_saved * radiologist_hourly_cost
75
 
76
- # Financial
77
  baseline_monthly_cost = monthly_ai_cases * base_cost_per_scan
78
  new_monthly_cost = baseline_monthly_cost * (1.0 - cost_reduction_pct)
79
  per_scan_cost_savings_month = baseline_monthly_cost - new_monthly_cost
@@ -82,7 +124,6 @@ def compute_mmg(
82
  recall_cost_savings_month = recalls_avoided * recall_cost_per_case
83
  early_detection_savings_month = earlier_detections * treatment_cost_delta_early_vs_late
84
 
85
- # Program costs (backend)
86
  vendor_cost_month = monthly_ai_cases * vendor_per_case_fee
87
  platform_cost_month = platform_annual_fee / 12.0
88
  other_costs_month = integration_overhead_monthly + cloud_compute_monthly
@@ -92,16 +133,12 @@ def compute_mmg(
92
  ops_value_month = value_time_saved_month + per_scan_cost_savings_month + recall_cost_savings_month + early_detection_savings_month
93
 
94
  net_impact_month = incr_revenue_month - incr_costs_month + ops_value_month
95
- roi_pct_val = (net_impact_month / incr_costs_month) if incr_costs_month > 0 else float("nan")
96
-
97
- # Annual KPIs
98
- net_impact_annual = net_impact_month * 12.0
99
- roi_pct_annual = (net_impact_annual / (incr_costs_month*12.0)) if incr_costs_month > 0 else float("nan")
100
- annual_program_cost = platform_annual_fee + vendor_per_case_fee*annual_ai_cases + other_costs_month*12.0
101
- net_impact_per_ai_case_month = net_impact_month / max(1.0, monthly_ai_cases)
102
- months_to_payback = (annual_program_cost / max(1e-6, net_impact_per_ai_case_month)) / max(1.0, monthly_ai_cases)
103
 
104
- # Evidence snapshot (neutral placeholders)
105
  evidence = """
106
  <ul class='evidence'>
107
  <li>Modeled reductions in recalls and false positives reduce unnecessary follow-ups and costs.</li>
@@ -110,20 +147,18 @@ def compute_mmg(
110
  </ul>
111
  """
112
 
113
- # Clinical bullet for one-liner
114
  clinical_bullet = (
115
  f"~{int(round(recalls_avoided))} recalls avoided, "
116
  f"{earlier_detections:.1f} earlier cancers detected, "
117
  f"{int(round(errors_reduced))} fewer missed positives"
118
  )
119
 
120
- # Pack the standard structure
121
  return {
122
  "summary": f"For your practice with {int(monthly_volume):,} mammography scans/month, modeled net benefit is {usd(net_impact_month)} per month. Clinical: {clinical_bullet}.",
123
  "financial": {
124
  "rows": [
125
  ("Additional follow-up scans (count/mo)", f"{int(round(addl_followups))}"),
126
- ("Additional follow-up revenue (mo)", usd(incr_revenue_month)),
127
  ("Value of time saved (mo)", usd(value_time_saved_month)),
128
  ("Per-scan radiologist cost savings (mo)", usd(per_scan_cost_savings_month)),
129
  ("Savings from avoided recalls (mo)", usd(recall_cost_savings_month)),
@@ -132,7 +167,7 @@ def compute_mmg(
132
  ("Platform license (mo)", usd(platform_cost_month)),
133
  ("Integration & cloud (mo)", usd(other_costs_month)),
134
  ("Net impact (mo)", f"<b>{usd(net_impact_month)}</b>"),
135
- ("Net impact (annual)", f"<b>{usd(net_impact_annual)}</b>"),
136
  ("ROI % (annual)", f"<b>{'' if math.isnan(roi_pct_annual) else f'{roi_pct_annual*100:.1f}%'}</b>"),
137
  ("Months to payback", f"<b>{months_to_payback:.1f}</b>"),
138
  ]
@@ -157,12 +192,12 @@ def compute_mmg(
157
  ("Effective capacity increase", pct(capacity_increase_pct)),
158
  ]
159
  },
160
- "waterfall": [("Incremental revenue", incr_revenue_month), ("Incremental costs", -incr_costs_month), ("Operational value", ops_value_month)],
161
  "annual_card": {
162
- "incr_rev": incr_revenue_month * 12.0,
163
- "incr_costs": incr_costs_month * 12.0,
164
  "ops_value": ops_value_month * 12.0,
165
- "net": net_impact_annual,
166
  "roi_pct": roi_pct_annual,
167
  "payback": months_to_payback,
168
  },
@@ -176,16 +211,13 @@ def compute_ffrct(
176
  uptake_pct: float,
177
  avg_time_to_decision_today_hours: float,
178
  baseline_clinician_touch_min: float,
179
- # Assumptions (kept visible for clarity; feel free to tuck into gr.State if you prefer)
180
  reimb_ccta: float, reimb_ffrct: float, reimb_ai_qpa: float, pct_billed_ai_qpa: float,
181
  one_test_dx_pct: float, dec_unnec_ica_pct: float, more_likely_revasc_pct: float, revasc_prevalence_pct: float,
182
  vendor_per_case_cost: float, platform_annual_cost: float, stress_test_cost: float,
183
  bed_hour_value: float, clinician_hour_cost: float, ai_time_to_decision_min: float,
184
  clinician_touch_reduction_pct: float, baseline_diag_ica_rate_pct: float, baseline_additional_testing_rate_pct: float,
185
- # Sensitivity
186
  sens_uptake_factor_pct: float, sens_dec_unnec_ica_factor_pct: float, sens_vendor_cost_factor_pct: float,
187
  ):
188
- # Site-specific net cost per diagnostic ICA (rough anchors; adjust as needed)
189
  if site_type == "Hospital / Health System":
190
  net_cost_per_diag_ica = 5000.0
191
  elif site_type == "Imaging Center":
@@ -198,18 +230,16 @@ def compute_ffrct(
198
  annual_eligible = monthly_eligible_ccta * 12.0
199
  annual_uptake_cases = annual_eligible * uptake
200
 
201
- # Normalize %
202
- pct_ai_qpa = max(0.0, min(1.0, pct_billed_ai_qpa/100.0))
203
- one_test_dx = max(0.0, min(1.0, one_test_dx_pct/100.0))
204
  need_addl_with_ai = 1.0 - one_test_dx
205
- dec_unnec_ica = max(0.0, min(1.0, dec_unnec_ica_pct/100.0 * max(0.0, min(1.0, sens_dec_unnec_ica_factor_pct/100.0))))
206
  more_likely_revasc = max(0.0, min(1.0, more_likely_revasc_pct/100.0))
207
- revasc_prev = max(0.0, min(1.0, revasc_prevalence_pct/100.0))
208
- vendor_cost = float(vendor_per_case_cost) * max(0.0, min(1.0, sens_vendor_cost_factor_pct/100.0))
209
  platform_annual_cost = float(platform_annual_cost)
210
- stress_test_cost = float(stress_test_cost)
211
 
212
- # Baseline vs With-AI
213
  baseline_revenue = annual_eligible * reimb_ccta
214
  baseline_additional_tests = annual_eligible * max(0.0, min(1.0, baseline_additional_testing_rate_pct/100.0))
215
  baseline_additional_tests_cost = baseline_additional_tests * stress_test_cost
@@ -220,7 +250,6 @@ def compute_ffrct(
220
  baseline_ops_value = 0.0
221
  baseline_costs = baseline_additional_tests_cost + baseline_unnecessary_ica_cost
222
 
223
- # With AI
224
  with_ai_revenue = (
225
  annual_eligible * reimb_ccta
226
  + annual_uptake_cases * reimb_ffrct
@@ -230,7 +259,7 @@ def compute_ffrct(
230
  with_ai_platform_costs = platform_annual_cost
231
 
232
  baseline_addl_tests_in_ai_cohort = annual_uptake_cases * max(0.0, min(1.0, baseline_additional_testing_rate_pct/100.0))
233
- with_ai_additional_tests = annual_uptake_cases * need_addl_with_ai
234
  with_ai_additional_tests_cost = with_ai_additional_tests * stress_test_cost
235
  avoided_additional_tests = max(0.0, baseline_addl_tests_in_ai_cohort - with_ai_additional_tests)
236
 
@@ -238,7 +267,6 @@ def compute_ffrct(
238
  with_ai_unnecessary_ica = max(0.0, baseline_unnecessary_ica - avoided_unnec_ica)
239
  with_ai_unnecessary_ica_cost = with_ai_unnecessary_ica * net_cost_per_diag_ica
240
 
241
- # Ops value
242
  ai_saved_hours_per_case = min(max(0.0, ai_time_to_decision_min/60.0), max(0.0, float(avg_time_to_decision_today_hours)))
243
  bed_hours_saved = annual_uptake_cases * ai_saved_hours_per_case
244
  bed_hours_value = bed_hours_saved * bed_hour_value
@@ -247,9 +275,6 @@ def compute_ffrct(
247
  with_ai_ops_value = bed_hours_value + clinician_hours_value
248
 
249
  with_ai_costs = with_ai_vendor_costs + with_ai_platform_costs + with_ai_additional_tests_cost + with_ai_unnecessary_ica_cost
250
- with_ai_net = with_ai_revenue - with_ai_costs + with_ai_ops_value
251
-
252
- # Incremental
253
  incr_revenue = with_ai_revenue - baseline_revenue
254
  incr_costs = with_ai_costs - baseline_costs
255
  incr_ops = with_ai_ops_value - baseline_ops_value
@@ -260,7 +285,7 @@ def compute_ffrct(
260
 
261
  per_case_net_impact = (net_impact / annual_uptake_cases) if annual_uptake_cases > 0 else 0.0
262
  cases_to_payback = (ai_program_costs / per_case_net_impact) if per_case_net_impact > 0 else math.inf
263
- months_to_payback = ( (cases_to_payback / (monthly_eligible_ccta * (uptake))) if (monthly_eligible_ccta * uptake) > 0 and cases_to_payback != math.inf else math.inf )
264
 
265
  evidence = """
266
  <ul class='evidence'>
@@ -316,26 +341,21 @@ def compute_ffrct(
316
  "evidence": evidence,
317
  }
318
 
319
- # ---------- MSK (ER/Trauma) ----------
320
  def compute_msk(
321
  scans_per_day: float,
322
  reading_time_min: float,
323
  er_time_to_treatment_min: float,
324
  radiologist_hourly_cost: float = 180.0,
325
  ):
326
- # Port of your MSK calc logic into our standard structure
327
  scans_per_month = clamp_nonneg(scans_per_day) * 30.0
328
- # Clinical
329
- errors_reduced_per_month = int(scans_per_month * 0.05 * 0.20) # example: 5% incidence, 20% improvement
330
  discrepant_cases_flagged = int(scans_per_month * 0.05)
331
- # Operational
332
  hrs_saved_per_visit = max(0.0, er_time_to_treatment_min) * 0.50 / 60.0
333
  time_saved_per_scan_min = max(0.0, reading_time_min) * 0.30
334
  total_time_saved_hours = (scans_per_month * time_saved_per_scan_min) / 60.0
335
  value_time_saved_month = total_time_saved_hours * radiologist_hourly_cost
336
- # Financial (simple proxy)
337
- radiologist_cost_savings = scans_per_month * 4.0 # your original per-scan proxy
338
- # Net (no vendor/platform modeled here; keep backend if you want later)
339
  incr_revenue_month = 0.0
340
  incr_costs_month = 0.0
341
  ops_value_month = value_time_saved_month + radiologist_cost_savings
@@ -364,24 +384,11 @@ def compute_msk(
364
  ("Discrepant cases flagged (est.)", f"{discrepant_cases_flagged} /mo"),
365
  ("Hours saved per ED visit (modeled)", f"{hrs_saved_per_visit:.2f}"),
366
  ],
367
- "bars": [
368
- ("Touch-time reduction", 0.30),
369
- ]
370
- },
371
- "operational": {
372
- "rows": [
373
- ("Radiologist hours saved / month", f"{total_time_saved_hours:.1f}"),
374
- ]
375
- },
376
- "waterfall": [("Incremental revenue", incr_revenue_month), ("Incremental costs", -incr_costs_month), ("Operational value", ops_value_month)],
377
- "annual_card": {
378
- "incr_rev": incr_revenue_month * 12.0,
379
- "incr_costs": incr_costs_month * 12.0,
380
- "ops_value": ops_value_month * 12.0,
381
- "net": net_impact_month * 12.0,
382
- "roi_pct": float("nan"),
383
- "payback": float("nan"),
384
  },
 
 
 
385
  "evidence": evidence,
386
  }
387
 
@@ -407,11 +414,11 @@ def build_overall_card(title: str, summary_line: str, annual):
407
  </div>
408
  """
409
 
410
- def build_rows_card(title: str, rows: list) -> str:
411
  items = "".join(f"<div>{lab}</div><div>{val}</div>" for lab, val in rows)
412
  return f"<div class='card'><div style='font-weight:700;margin-bottom:6px'>{title}</div><div class='kpi-grid'>{items}</div></div>"
413
 
414
- def build_clinical_card(rows: list, bars: list) -> str:
415
  rows_html = "".join(f"<div>{lab}</div><div>{val}</div>" for lab, val in rows)
416
  bars_html = ""
417
  for lab, frac in (bars or []):
@@ -424,7 +431,7 @@ def build_clinical_card(rows: list, bars: list) -> str:
424
  </div>"""
425
  return f"<div class='card'><div style='font-weight:700;margin-bottom:6px'>Clinical</div><div class='kpi-grid'>{rows_html}</div><div class='bars'>{bars_html}</div></div>"
426
 
427
- def build_waterfall(wf_rows: list, title="Waterfall (monthly)") -> str:
428
  denom = sum(abs(v) for _, v in wf_rows) or 1.0
429
  def row(label, val):
430
  width = min(100, max(2, int(abs(val)/denom * 100)))
@@ -434,6 +441,10 @@ def build_waterfall(wf_rows: list, title="Waterfall (monthly)") -> str:
434
  title, "".join(row(l, v) for l, v in wf_rows), usd(sum(v for _, v in wf_rows))
435
  )
436
 
 
 
 
 
437
  # ---------- UI ----------
438
  def build_ui():
439
  with gr.Blocks(theme=gr.themes.Soft(), css="""
@@ -461,6 +472,32 @@ def build_ui():
461
  .header{display:flex;justify-content:space-between;align-items:center}
462
  .header .title{font-weight:800;font-size:1.1rem}
463
  .header .pill{margin-left:8px}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
  """) as demo:
465
  gr.Markdown("""
466
  <div class='header'>
@@ -471,9 +508,29 @@ def build_ui():
471
 
472
  with gr.Row():
473
  with gr.Column(scale=1):
474
- use_case = gr.Dropdown(USE_CASES, value=USE_CASES[0], label="Use case")
 
 
 
 
 
 
 
 
 
 
 
 
 
475
 
476
  # --- MMG Inputs ---
 
 
 
 
 
 
 
477
  mmg_monthly_volume = gr.Slider(0, 20000, 7500, step=50, label="Monthly volume (MMG)", info="Mammography exams per month", visible=True)
478
  mmg_read_minutes = gr.Number(label="Avg reading time today (minutes)", value=1.7, visible=True)
479
  mmg_rdx_hr_cost = gr.Number(label="Radiologist cost (USD/hr)", value=180, visible=True)
@@ -493,7 +550,6 @@ def build_ui():
493
  mmg_follow_uplift = gr.Slider(0, 0.2, value=0.0095, step=0.0005, label="Follow-up uplift (fraction of AI scans)")
494
  mmg_early_uplift_per_1000 = gr.Number(value=0.7, label="Earlier cancers detected (+/1000 AI scans)")
495
  mmg_tx_delta = gr.Number(value=15000.0, label="Treatment cost savings per earlier case (USD)")
496
- # Hidden program costs
497
  mmg_vendor_fee = gr.State(2.5)
498
  mmg_platform_annual = gr.State(12000.0)
499
  mmg_integration_mo = gr.State(0.0)
@@ -547,19 +603,26 @@ def build_ui():
547
  waterfall_panel = gr.HTML()
548
  evidence_panel = gr.HTML()
549
  cta_panel = gr.HTML(visible=False)
 
550
 
551
- # Toggle visibility by use case
552
- def _on_use_case_change(uc):
 
 
 
 
 
553
  mmg_vis = (uc == USE_CASES[0])
554
  f_vis = (uc == USE_CASES[1])
555
  msk_vis = (uc == USE_CASES[2])
556
  return (
557
- gr.update(visible=mmg_vis), gr.update(visible=mmg_vis), gr.update(visible=mmg_vis), gr.update(visible=mmg_vis),
558
- gr.update(visible=mmg_vis),
 
559
 
560
  gr.update(visible=f_vis), gr.update(visible=f_vis), gr.update(visible=f_vis), gr.update(visible=f_vis), gr.update(visible=f_vis),
561
  gr.update(visible=f_vis), # accordion
562
- # MSK
563
  gr.update(visible=msk_vis), gr.update(visible=msk_vis), gr.update(visible=msk_vis),
564
  )
565
 
@@ -567,15 +630,37 @@ def build_ui():
567
  _on_use_case_change,
568
  inputs=[use_case],
569
  outputs=[
570
- mmg_monthly_volume, mmg_read_minutes, mmg_rdx_hr_cost, mmg_sens, mmg_sens, # (accordion doubled on purpose to keep visibility synced)
 
571
  f_site, f_monthly_eligible, f_uptake, f_ttd_hours, f_touch_min, f_assump,
572
  msk_scans_day, msk_read_min, msk_er_ttt_min
573
  ],
574
  )
575
 
576
- # Dispatcher
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
577
  def _compute(
578
- uc,
579
  # MMG
580
  mv, rm, rhr,
581
  base_ppr, ai_ppr, base_audit, ai_audit, base_recall, ai_recall, recall_cost, read_redux, cps, cps_redux, fol_price, fol_uplift, early_uplift, tx_delta,
@@ -586,6 +671,8 @@ def build_ui():
586
  # MSK
587
  msk_day, msk_read, msk_ttt, msk_rhr,
588
  ):
 
 
589
  if uc == USE_CASES[0]:
590
  res = compute_mmg(
591
  mv, rm, rhr,
@@ -612,9 +699,15 @@ def build_ui():
612
  operational_html = build_rows_card("Operational", res["operational"]["rows"])
613
  water = build_waterfall(res["waterfall"])
614
  evidence = f"<div class='card'><div style='font-weight:700;margin-bottom:6px'>Evidence snapshot</div>{res['evidence']}<div class='small-note'>Neutral claims; update with site citations.</div></div>"
615
- cta_line = "Book a quick walkthrough with our team."
616
- cta = f"<div class='card cta'><div>{cta_line}</div><a class='cta-btn' href='{CTA_URL}' target='_blank' rel='noopener'>{CTA_LABEL}</a></div>"
617
- return overall_html, financial_html, clinical_html, operational_html, water, evidence, gr.update(value=cta, visible=True)
 
 
 
 
 
 
618
 
619
  inputs = [
620
  use_case,
@@ -628,7 +721,7 @@ def build_ui():
628
  # MSK inputs
629
  msk_scans_day, msk_read_min, msk_er_ttt_min, msk_rdx_hr_cost,
630
  ]
631
- outputs = [overall_card, financial_card, clinical_card, operational_card, waterfall_panel, evidence_panel, cta_panel]
632
 
633
  run_btn.click(_compute, inputs=inputs, outputs=outputs)
634
  demo.load(_compute, inputs=inputs, outputs=outputs)
 
1
  # app.py
2
  import math
3
+ import tempfile
4
+ from pathlib import Path
5
  import gradio as gr
6
 
7
  # ==========================================
8
  # CARPL Multi-Use-Case ROI Calculators (MMG / FFR-CT / MSK AI)
9
  # Shared UI/UX: Inter font, white cards, pill header, Overall + Tabs + Waterfall + Evidence + CTA
10
+ # Extras: card-style use case chooser, MMG vendor presets, CSV export (fixed)
11
  # ==========================================
12
 
13
  USE_CASES = ["Mammography AI (MMG)", "FFR-CT AI", "MSK AI (ER/Trauma)"]
14
+ CTA_URL = "https://carpl.ai/contact-us" # swap to your HubSpot/Calendly
15
  CTA_LABEL = "Book a 15-min walkthrough"
16
 
17
+ MMG_VENDOR_PRESETS = {
18
+ "Custom": {},
19
+ "Lunit": {
20
+ "base_recall_rate": 0.028, "ai_recall_rate": 0.025,
21
+ "base_ppr": 0.100, "ai_ppr": 0.095,
22
+ "ai_audit_rate": 0.050, "base_audit_rate": 0.000,
23
+ "read_reduction_pct": 0.15,
24
+ "followup_uplift_pct": 0.0095,
25
+ "early_detect_uplift_per_1000": 0.7,
26
+ },
27
+ "Therapixel (MammoScreen)": {
28
+ "base_recall_rate": 0.028, "ai_recall_rate": 0.024,
29
+ "base_ppr": 0.100, "ai_ppr": 0.094,
30
+ "ai_audit_rate": 0.040, "base_audit_rate": 0.000,
31
+ "read_reduction_pct": 0.15,
32
+ "followup_uplift_pct": 0.010,
33
+ "early_detect_uplift_per_1000": 0.9,
34
+ },
35
+ "MammoScreen (Alt)": {
36
+ "base_recall_rate": 0.030, "ai_recall_rate": 0.026,
37
+ "base_ppr": 0.100, "ai_ppr": 0.093,
38
+ "ai_audit_rate": 0.045, "base_audit_rate": 0.000,
39
+ "read_reduction_pct": 0.18,
40
+ "followup_uplift_pct": 0.011,
41
+ "early_detect_uplift_per_1000": 0.8,
42
+ },
43
+ "MedCognetics": {
44
+ "base_recall_rate": 0.029, "ai_recall_rate": 0.026,
45
+ "base_ppr": 0.100, "ai_ppr": 0.096,
46
+ "ai_audit_rate": 0.035, "base_audit_rate": 0.000,
47
+ "read_reduction_pct": 0.14,
48
+ "followup_uplift_pct": 0.009,
49
+ "early_detect_uplift_per_1000": 0.6,
50
+ },
51
+ }
52
+
53
  # ---------- Helpers ----------
54
  def usd(x: float, digits: int = 0) -> str:
55
  if x == math.inf:
56
  return "∞"
57
  try:
58
+ return "$" + f"{x:,.{digits}f}"
 
59
  except Exception:
60
  return "$0"
61
 
 
68
  def clamp_nonneg(x: float) -> float:
69
  return max(0.0, float(x))
70
 
71
+ def write_csv(rows, title: str = "roi_results") -> str:
72
+ """Write rows [(label, value), ...] to a temp CSV and return file path."""
73
+ csv_dir = Path(tempfile.gettempdir())
74
+ path = csv_dir / f"{title.replace(' ','_').lower()}.csv"
75
+ with open(path, "w", encoding="utf-8") as f:
76
+ f.write("Metric,Value\n")
77
+ for lab, val in rows:
78
+ # strip any HTML tags if present
79
+ val = str(val).replace("<b>", "").replace("</b>", "")
80
+ f.write(f"\"{lab}\",\"{val}\"\n")
81
+ return str(path)
82
 
83
  # ---------- MMG (Mammography) ----------
84
  def compute_mmg(
 
101
  integration_overhead_monthly: float,
102
  cloud_compute_monthly: float,
103
  ):
 
104
  monthly_ai_cases = clamp_nonneg(monthly_volume)
105
  annual_ai_cases = monthly_ai_cases * 12.0
106
 
 
107
  errors_reduced = clamp_nonneg(monthly_ai_cases * (base_ppr - ai_ppr))
108
  discrepant_flags = clamp_nonneg(monthly_ai_cases * (ai_audit_rate - base_audit_rate))
109
  recalls_avoided = clamp_nonneg(monthly_ai_cases * (base_recall_rate - ai_recall_rate))
110
  earlier_detections = clamp_nonneg(monthly_ai_cases * (early_detect_uplift_per_1000 / 1000.0))
111
 
 
112
  base_read_seconds = read_minutes * 60.0
113
  hours_saved = clamp_nonneg(monthly_ai_cases * (base_read_seconds * read_reduction_pct) / 3600.0)
114
  workload_reduction_pct = read_reduction_pct
 
116
  capacity_increase_pct = (1.0 / max(1e-6, (1.0 - read_reduction_pct)) - 1.0)
117
  value_time_saved_month = hours_saved * radiologist_hourly_cost
118
 
 
119
  baseline_monthly_cost = monthly_ai_cases * base_cost_per_scan
120
  new_monthly_cost = baseline_monthly_cost * (1.0 - cost_reduction_pct)
121
  per_scan_cost_savings_month = baseline_monthly_cost - new_monthly_cost
 
124
  recall_cost_savings_month = recalls_avoided * recall_cost_per_case
125
  early_detection_savings_month = earlier_detections * treatment_cost_delta_early_vs_late
126
 
 
127
  vendor_cost_month = monthly_ai_cases * vendor_per_case_fee
128
  platform_cost_month = platform_annual_fee / 12.0
129
  other_costs_month = integration_overhead_monthly + cloud_compute_monthly
 
133
  ops_value_month = value_time_saved_month + per_scan_cost_savings_month + recall_cost_savings_month + early_detection_savings_month
134
 
135
  net_impact_month = incr_revenue_month - incr_costs_month + ops_value_month
136
+ roi_pct_annual = ((net_impact_month*12) / (incr_costs_month*12)) if incr_costs_month > 0 else float("nan")
137
+ months_to_payback = (
138
+ (platform_annual_fee + vendor_per_case_fee*annual_ai_cases + other_costs_month*12.0)
139
+ / max(1e-6, (net_impact_month / max(1.0, monthly_ai_cases))) / max(1.0, monthly_ai_cases)
140
+ )
 
 
 
141
 
 
142
  evidence = """
143
  <ul class='evidence'>
144
  <li>Modeled reductions in recalls and false positives reduce unnecessary follow-ups and costs.</li>
 
147
  </ul>
148
  """
149
 
 
150
  clinical_bullet = (
151
  f"~{int(round(recalls_avoided))} recalls avoided, "
152
  f"{earlier_detections:.1f} earlier cancers detected, "
153
  f"{int(round(errors_reduced))} fewer missed positives"
154
  )
155
 
 
156
  return {
157
  "summary": f"For your practice with {int(monthly_volume):,} mammography scans/month, modeled net benefit is {usd(net_impact_month)} per month. Clinical: {clinical_bullet}.",
158
  "financial": {
159
  "rows": [
160
  ("Additional follow-up scans (count/mo)", f"{int(round(addl_followups))}"),
161
+ ("Additional follow-up revenue (mo)", usd(addl_followup_revenue_month)),
162
  ("Value of time saved (mo)", usd(value_time_saved_month)),
163
  ("Per-scan radiologist cost savings (mo)", usd(per_scan_cost_savings_month)),
164
  ("Savings from avoided recalls (mo)", usd(recall_cost_savings_month)),
 
167
  ("Platform license (mo)", usd(platform_cost_month)),
168
  ("Integration & cloud (mo)", usd(other_costs_month)),
169
  ("Net impact (mo)", f"<b>{usd(net_impact_month)}</b>"),
170
+ ("Net impact (annual)", f"<b>{usd(net_impact_month*12)}</b>"),
171
  ("ROI % (annual)", f"<b>{'' if math.isnan(roi_pct_annual) else f'{roi_pct_annual*100:.1f}%'}</b>"),
172
  ("Months to payback", f"<b>{months_to_payback:.1f}</b>"),
173
  ]
 
192
  ("Effective capacity increase", pct(capacity_increase_pct)),
193
  ]
194
  },
195
+ "waterfall": [("Incremental revenue", addl_followup_revenue_month), ("Incremental costs", - (vendor_cost_month + platform_cost_month + other_costs_month)), ("Operational value", ops_value_month)],
196
  "annual_card": {
197
+ "incr_rev": addl_followup_revenue_month * 12.0,
198
+ "incr_costs": (vendor_cost_month + platform_cost_month + other_costs_month) * 12.0,
199
  "ops_value": ops_value_month * 12.0,
200
+ "net": net_impact_month * 12.0,
201
  "roi_pct": roi_pct_annual,
202
  "payback": months_to_payback,
203
  },
 
211
  uptake_pct: float,
212
  avg_time_to_decision_today_hours: float,
213
  baseline_clinician_touch_min: float,
 
214
  reimb_ccta: float, reimb_ffrct: float, reimb_ai_qpa: float, pct_billed_ai_qpa: float,
215
  one_test_dx_pct: float, dec_unnec_ica_pct: float, more_likely_revasc_pct: float, revasc_prevalence_pct: float,
216
  vendor_per_case_cost: float, platform_annual_cost: float, stress_test_cost: float,
217
  bed_hour_value: float, clinician_hour_cost: float, ai_time_to_decision_min: float,
218
  clinician_touch_reduction_pct: float, baseline_diag_ica_rate_pct: float, baseline_additional_testing_rate_pct: float,
 
219
  sens_uptake_factor_pct: float, sens_dec_unnec_ica_factor_pct: float, sens_vendor_cost_factor_pct: float,
220
  ):
 
221
  if site_type == "Hospital / Health System":
222
  net_cost_per_diag_ica = 5000.0
223
  elif site_type == "Imaging Center":
 
230
  annual_eligible = monthly_eligible_ccta * 12.0
231
  annual_uptake_cases = annual_eligible * uptake
232
 
233
+ pct_ai_qpa = max(0.0, min(1.0, pct_billed_ai_qpa/100.0))
234
+ one_test_dx = max(0.0, min(1.0, one_test_dx_pct/100.0))
 
235
  need_addl_with_ai = 1.0 - one_test_dx
236
+ dec_unnec_ica = max(0.0, min(1.0, dec_unnec_ica_pct/100.0 * max(0.0, min(1.0, sens_dec_unnec_ica_factor_pct/100.0))))
237
  more_likely_revasc = max(0.0, min(1.0, more_likely_revasc_pct/100.0))
238
+ revasc_prev = max(0.0, min(1.0, revasc_prevalence_pct/100.0))
239
+ vendor_cost = float(vendor_per_case_cost) * max(0.0, min(1.0, sens_vendor_cost_factor_pct/100.0))
240
  platform_annual_cost = float(platform_annual_cost)
241
+ stress_test_cost = float(stress_test_cost)
242
 
 
243
  baseline_revenue = annual_eligible * reimb_ccta
244
  baseline_additional_tests = annual_eligible * max(0.0, min(1.0, baseline_additional_testing_rate_pct/100.0))
245
  baseline_additional_tests_cost = baseline_additional_tests * stress_test_cost
 
250
  baseline_ops_value = 0.0
251
  baseline_costs = baseline_additional_tests_cost + baseline_unnecessary_ica_cost
252
 
 
253
  with_ai_revenue = (
254
  annual_eligible * reimb_ccta
255
  + annual_uptake_cases * reimb_ffrct
 
259
  with_ai_platform_costs = platform_annual_cost
260
 
261
  baseline_addl_tests_in_ai_cohort = annual_uptake_cases * max(0.0, min(1.0, baseline_additional_testing_rate_pct/100.0))
262
+ with_ai_additional_tests = annual_uptake_cases * (1.0 - one_test_dx)
263
  with_ai_additional_tests_cost = with_ai_additional_tests * stress_test_cost
264
  avoided_additional_tests = max(0.0, baseline_addl_tests_in_ai_cohort - with_ai_additional_tests)
265
 
 
267
  with_ai_unnecessary_ica = max(0.0, baseline_unnecessary_ica - avoided_unnec_ica)
268
  with_ai_unnecessary_ica_cost = with_ai_unnecessary_ica * net_cost_per_diag_ica
269
 
 
270
  ai_saved_hours_per_case = min(max(0.0, ai_time_to_decision_min/60.0), max(0.0, float(avg_time_to_decision_today_hours)))
271
  bed_hours_saved = annual_uptake_cases * ai_saved_hours_per_case
272
  bed_hours_value = bed_hours_saved * bed_hour_value
 
275
  with_ai_ops_value = bed_hours_value + clinician_hours_value
276
 
277
  with_ai_costs = with_ai_vendor_costs + with_ai_platform_costs + with_ai_additional_tests_cost + with_ai_unnecessary_ica_cost
 
 
 
278
  incr_revenue = with_ai_revenue - baseline_revenue
279
  incr_costs = with_ai_costs - baseline_costs
280
  incr_ops = with_ai_ops_value - baseline_ops_value
 
285
 
286
  per_case_net_impact = (net_impact / annual_uptake_cases) if annual_uptake_cases > 0 else 0.0
287
  cases_to_payback = (ai_program_costs / per_case_net_impact) if per_case_net_impact > 0 else math.inf
288
+ months_to_payback = ((cases_to_payback / (monthly_eligible_ccta * (uptake))) if (monthly_eligible_ccta * uptake) > 0 and cases_to_payback != math.inf else math.inf)
289
 
290
  evidence = """
291
  <ul class='evidence'>
 
341
  "evidence": evidence,
342
  }
343
 
344
+ # ---------- MSK ----------
345
  def compute_msk(
346
  scans_per_day: float,
347
  reading_time_min: float,
348
  er_time_to_treatment_min: float,
349
  radiologist_hourly_cost: float = 180.0,
350
  ):
 
351
  scans_per_month = clamp_nonneg(scans_per_day) * 30.0
352
+ errors_reduced_per_month = int(scans_per_month * 0.05 * 0.20)
 
353
  discrepant_cases_flagged = int(scans_per_month * 0.05)
 
354
  hrs_saved_per_visit = max(0.0, er_time_to_treatment_min) * 0.50 / 60.0
355
  time_saved_per_scan_min = max(0.0, reading_time_min) * 0.30
356
  total_time_saved_hours = (scans_per_month * time_saved_per_scan_min) / 60.0
357
  value_time_saved_month = total_time_saved_hours * radiologist_hourly_cost
358
+ radiologist_cost_savings = scans_per_month * 4.0
 
 
359
  incr_revenue_month = 0.0
360
  incr_costs_month = 0.0
361
  ops_value_month = value_time_saved_month + radiologist_cost_savings
 
384
  ("Discrepant cases flagged (est.)", f"{discrepant_cases_flagged} /mo"),
385
  ("Hours saved per ED visit (modeled)", f"{hrs_saved_per_visit:.2f}"),
386
  ],
387
+ "bars": [("Touch-time reduction", 0.30)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  },
389
+ "operational": {"rows": [("Radiologist hours saved / month", f"{total_time_saved_hours:.1f}")]},
390
+ "waterfall": [("Incremental revenue", 0.0), ("Incremental costs", 0.0), ("Operational value", ops_value_month)],
391
+ "annual_card": {"incr_rev": 0.0, "incr_costs": 0.0, "ops_value": ops_value_month * 12.0, "net": net_impact_month * 12.0, "roi_pct": float("nan"), "payback": float("nan")},
392
  "evidence": evidence,
393
  }
394
 
 
414
  </div>
415
  """
416
 
417
+ def build_rows_card(title: str, rows):
418
  items = "".join(f"<div>{lab}</div><div>{val}</div>" for lab, val in rows)
419
  return f"<div class='card'><div style='font-weight:700;margin-bottom:6px'>{title}</div><div class='kpi-grid'>{items}</div></div>"
420
 
421
+ def build_clinical_card(rows, bars):
422
  rows_html = "".join(f"<div>{lab}</div><div>{val}</div>" for lab, val in rows)
423
  bars_html = ""
424
  for lab, frac in (bars or []):
 
431
  </div>"""
432
  return f"<div class='card'><div style='font-weight:700;margin-bottom:6px'>Clinical</div><div class='kpi-grid'>{rows_html}</div><div class='bars'>{bars_html}</div></div>"
433
 
434
+ def build_waterfall(wf_rows, title="Waterfall (monthly)"):
435
  denom = sum(abs(v) for _, v in wf_rows) or 1.0
436
  def row(label, val):
437
  width = min(100, max(2, int(abs(val)/denom * 100)))
 
441
  title, "".join(row(l, v) for l, v in wf_rows), usd(sum(v for _, v in wf_rows))
442
  )
443
 
444
+ def rows_to_csv(fin_rows, clin_rows, op_rows) -> str:
445
+ all_rows = [("Section","—")] + [("Financial","—")] + fin_rows + [("Clinical","—")] + clin_rows + [("Operational","—")] + op_rows
446
+ return write_csv(all_rows, title="roi_results")
447
+
448
  # ---------- UI ----------
449
  def build_ui():
450
  with gr.Blocks(theme=gr.themes.Soft(), css="""
 
472
  .header{display:flex;justify-content:space-between;align-items:center}
473
  .header .title{font-weight:800;font-size:1.1rem}
474
  .header .pill{margin-left:8px}
475
+
476
+ /* Use-case chooser as cards */
477
+ #uc [role="radiogroup"] {
478
+ display: grid;
479
+ grid-template-columns: repeat(3, minmax(0,1fr));
480
+ gap: 12px;
481
+ }
482
+ @media (max-width: 820px) {
483
+ #uc [role="radiogroup"] { grid-template-columns: 1fr; }
484
+ }
485
+ #uc [role="radiogroup"] label {
486
+ border: 1px solid #eef2f7;
487
+ border-radius: 16px;
488
+ background: #fff;
489
+ padding: 14px 16px;
490
+ box-shadow: 0 8px 24px rgba(0,0,0,.06);
491
+ cursor: pointer;
492
+ display: flex;
493
+ align-items: center;
494
+ gap: 10px;
495
+ transition: transform .06s ease, box-shadow .12s ease, border-color .12s ease;
496
+ }
497
+ #uc [role="radiogroup"] input[type="radio"] { accent-color: #14b8a6; }
498
+ #uc [role="radiogroup"] label:hover { transform: translateY(-1px); box-shadow: 0 12px 28px rgba(0,0,0,.10); }
499
+ #uc [role="radiogroup"] input[type="radio"]:checked + span { font-weight: 700; }
500
+ #uc .uc-hint { margin-top: 6px; opacity: .7; font-size: .9em; }
501
  """) as demo:
502
  gr.Markdown("""
503
  <div class='header'>
 
508
 
509
  with gr.Row():
510
  with gr.Column(scale=1):
511
+ # Use case chooser (as cards)
512
+ with gr.Column(elem_id="uc"):
513
+ gr.Markdown("### Choose your use case")
514
+ use_case = gr.Radio(
515
+ choices=[
516
+ "🩺 Mammography AI (MMG)",
517
+ "❤️‍🩹 FFR-CT AI",
518
+ "🦴 MSK AI (ER/Trauma)"
519
+ ],
520
+ value="🩺 Mammography AI (MMG)",
521
+ label=None,
522
+ interactive=True
523
+ )
524
+ gr.Markdown("<div class='uc-hint'>Pick one to load the matching calculator. You can switch anytime.</div>")
525
 
526
  # --- MMG Inputs ---
527
+ vendor_preset = gr.Dropdown(
528
+ choices=list(MMG_VENDOR_PRESETS.keys()),
529
+ value="Custom",
530
+ label="MMG vendor preset",
531
+ info="Prefills hidden sensitivity. You can still override in Sensitivity.",
532
+ visible=True
533
+ )
534
  mmg_monthly_volume = gr.Slider(0, 20000, 7500, step=50, label="Monthly volume (MMG)", info="Mammography exams per month", visible=True)
535
  mmg_read_minutes = gr.Number(label="Avg reading time today (minutes)", value=1.7, visible=True)
536
  mmg_rdx_hr_cost = gr.Number(label="Radiologist cost (USD/hr)", value=180, visible=True)
 
550
  mmg_follow_uplift = gr.Slider(0, 0.2, value=0.0095, step=0.0005, label="Follow-up uplift (fraction of AI scans)")
551
  mmg_early_uplift_per_1000 = gr.Number(value=0.7, label="Earlier cancers detected (+/1000 AI scans)")
552
  mmg_tx_delta = gr.Number(value=15000.0, label="Treatment cost savings per earlier case (USD)")
 
553
  mmg_vendor_fee = gr.State(2.5)
554
  mmg_platform_annual = gr.State(12000.0)
555
  mmg_integration_mo = gr.State(0.0)
 
603
  waterfall_panel = gr.HTML()
604
  evidence_panel = gr.HTML()
605
  cta_panel = gr.HTML(visible=False)
606
+ csv_file = gr.File(label="Download CSV", visible=False)
607
 
608
+ def normalize_uc(uclabel: str) -> str:
609
+ if "Mammography" in uclabel: return USE_CASES[0]
610
+ if "FFR-CT" in uclabel: return USE_CASES[1]
611
+ return USE_CASES[2]
612
+
613
+ def _on_use_case_change(uc_label):
614
+ uc = normalize_uc(uc_label)
615
  mmg_vis = (uc == USE_CASES[0])
616
  f_vis = (uc == USE_CASES[1])
617
  msk_vis = (uc == USE_CASES[2])
618
  return (
619
+ gr.update(visible=mmg_vis), # vendor preset
620
+ gr.update(visible=mmg_vis), gr.update(visible=mmg_vis), gr.update(visible=mmg_vis),
621
+ gr.update(visible=mmg_vis), # accordion
622
 
623
  gr.update(visible=f_vis), gr.update(visible=f_vis), gr.update(visible=f_vis), gr.update(visible=f_vis), gr.update(visible=f_vis),
624
  gr.update(visible=f_vis), # accordion
625
+
626
  gr.update(visible=msk_vis), gr.update(visible=msk_vis), gr.update(visible=msk_vis),
627
  )
628
 
 
630
  _on_use_case_change,
631
  inputs=[use_case],
632
  outputs=[
633
+ vendor_preset,
634
+ mmg_monthly_volume, mmg_read_minutes, mmg_rdx_hr_cost, mmg_sens,
635
  f_site, f_monthly_eligible, f_uptake, f_ttd_hours, f_touch_min, f_assump,
636
  msk_scans_day, msk_read_min, msk_er_ttt_min
637
  ],
638
  )
639
 
640
+ def _apply_vendor_preset(preset_name,
641
+ base_ppr, ai_ppr, base_audit, ai_audit, base_recall, ai_recall,
642
+ read_redux, follow_uplift, early_uplift):
643
+ p = MMG_VENDOR_PRESETS.get(preset_name, {})
644
+ return (
645
+ gr.update(value=p.get("base_ppr", base_ppr)),
646
+ gr.update(value=p.get("ai_ppr", ai_ppr)),
647
+ gr.update(value=p.get("base_audit_rate", base_audit)),
648
+ gr.update(value=p.get("ai_audit_rate", ai_audit)),
649
+ gr.update(value=p.get("base_recall_rate", base_recall)),
650
+ gr.update(value=p.get("ai_recall_rate", ai_recall)),
651
+ gr.update(value=p.get("read_reduction_pct", read_redux)),
652
+ gr.update(value=p.get("followup_uplift_pct", follow_uplift)),
653
+ gr.update(value=p.get("early_detect_uplift_per_1000", early_uplift)),
654
+ )
655
+
656
+ vendor_preset.change(
657
+ _apply_vendor_preset,
658
+ inputs=[vendor_preset, mmg_base_ppr, mmg_ai_ppr, mmg_base_audit, mmg_ai_audit, mmg_base_recall, mmg_ai_recall, mmg_read_redux, mmg_follow_uplift, mmg_early_uplift_per_1000],
659
+ outputs=[mmg_base_ppr, mmg_ai_ppr, mmg_base_audit, mmg_ai_audit, mmg_base_recall, mmg_ai_recall, mmg_read_redux, mmg_follow_uplift, mmg_early_uplift_per_1000],
660
+ )
661
+
662
  def _compute(
663
+ uclabel,
664
  # MMG
665
  mv, rm, rhr,
666
  base_ppr, ai_ppr, base_audit, ai_audit, base_recall, ai_recall, recall_cost, read_redux, cps, cps_redux, fol_price, fol_uplift, early_uplift, tx_delta,
 
671
  # MSK
672
  msk_day, msk_read, msk_ttt, msk_rhr,
673
  ):
674
+ uc = normalize_uc(uclabel)
675
+
676
  if uc == USE_CASES[0]:
677
  res = compute_mmg(
678
  mv, rm, rhr,
 
699
  operational_html = build_rows_card("Operational", res["operational"]["rows"])
700
  water = build_waterfall(res["waterfall"])
701
  evidence = f"<div class='card'><div style='font-weight:700;margin-bottom:6px'>Evidence snapshot</div>{res['evidence']}<div class='small-note'>Neutral claims; update with site citations.</div></div>"
702
+ cta = f"<div class='card cta'><div>Want to see this in your workflow?</div><a class='cta-btn' href='{CTA_URL}' target='_blank' rel='noopener'>{CTA_LABEL}</a></div>"
703
+
704
+ # CSV export (flatten current rows) -> write to temp file
705
+ fin_rows = [(lab, val) for lab, val in res["financial"]["rows"]]
706
+ clin_rows = [(lab, val) for lab, val in res["clinical"]["rows"]]
707
+ op_rows = [(lab, val) for lab, val in res["operational"]["rows"]]
708
+ csv_path = rows_to_csv(fin_rows, clin_rows, op_rows)
709
+
710
+ return overall_html, financial_html, clinical_html, operational_html, water, evidence, gr.update(value=cta, visible=True), gr.update(value=csv_path, visible=True)
711
 
712
  inputs = [
713
  use_case,
 
721
  # MSK inputs
722
  msk_scans_day, msk_read_min, msk_er_ttt_min, msk_rdx_hr_cost,
723
  ]
724
+ outputs = [overall_card, financial_card, clinical_card, operational_card, waterfall_panel, evidence_panel, cta_panel, csv_file]
725
 
726
  run_btn.click(_compute, inputs=inputs, outputs=outputs)
727
  demo.load(_compute, inputs=inputs, outputs=outputs)