SChodavarpu commited on
Commit
6ccd76a
·
verified ·
1 Parent(s): ab1bb03

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +103 -82
app.py CHANGED
@@ -6,13 +6,15 @@ 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
11
- # MSK: hides zero-value rows & bars; no NaNs, only relevant metrics
 
 
12
  # ==========================================
13
 
14
  USE_CASES = ["Mammography AI (MMG)", "FFR-CT AI", "MSK AI (ER/Trauma)"]
15
- CTA_URL = "https://carpl.ai/contact-us" # swap to your HubSpot/Calendly
16
  CTA_LABEL = "Book a 15-min walkthrough"
17
 
18
  MMG_VENDOR_PRESETS = {
@@ -69,6 +71,9 @@ def pct(x: float, digits: int = 1) -> str:
69
  def clamp_nonneg(x: float) -> float:
70
  return max(0.0, float(x))
71
 
 
 
 
72
  def write_csv(rows, title: str = "roi_results") -> str:
73
  """Write rows [(label, value), ...] to a temp CSV and return file path."""
74
  csv_dir = Path(tempfile.gettempdir())
@@ -104,11 +109,13 @@ def compute_mmg(
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,6 +123,7 @@ def compute_mmg(
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
@@ -133,7 +141,7 @@ def compute_mmg(
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)
@@ -168,7 +176,7 @@ def compute_mmg(
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
  ]
174
  },
@@ -192,7 +200,7 @@ def compute_mmg(
192
  ("Effective capacity increase", pct(capacity_increase_pct)),
193
  ]
194
  },
195
- "waterfall": [("Incremental revenue", incr_revenue_month), ("Incremental costs", -incr_costs_month), ("Operational value", ops_value_month)],
196
  "annual_card": {
197
  "incr_rev": incr_revenue_month * 12.0,
198
  "incr_costs": incr_costs_month * 12.0,
@@ -226,65 +234,68 @@ def compute_ffrct(
226
  net_cost_per_diag_ica = 4000.0
227
 
228
  monthly_eligible_ccta = clamp_nonneg(monthly_eligible_ccta)
229
- uptake = max(0.0, min(1.0, uptake_pct/100.0 * max(0.0, min(1.0, sens_uptake_factor_pct/100.0))))
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
- 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))))
236
- more_likely_revasc = max(0.0, min(1.0, more_likely_revasc_pct/100.0))
237
- revasc_prev = max(0.0, min(1.0, revasc_prevalence_pct/100.0))
238
- vendor_cost = float(vendor_per_case_cost) * max(0.0, min(1.0, sens_vendor_cost_factor_pct/100.0))
239
  platform_annual_cost = float(platform_annual_cost)
240
  stress_test_cost = float(stress_test_cost)
241
 
 
242
  baseline_revenue = annual_eligible * reimb_ccta
243
- baseline_additional_tests = annual_eligible * max(0.0, min(1.0, baseline_additional_testing_rate_pct/100.0))
244
  baseline_additional_tests_cost = baseline_additional_tests * stress_test_cost
245
- baseline_diag_ica_total = annual_eligible * max(0.0, min(1.0, baseline_diag_ica_rate_pct/100.0))
246
  baseline_revasc_true = annual_eligible * revasc_prev
247
  baseline_unnecessary_ica = max(0.0, baseline_diag_ica_total - baseline_revasc_true)
248
  baseline_unnecessary_ica_cost = baseline_unnecessary_ica * net_cost_per_diag_ica
249
  baseline_ops_value = 0.0
250
  baseline_costs = baseline_additional_tests_cost + baseline_unnecessary_ica_cost
251
 
 
252
  with_ai_revenue = (
253
  annual_eligible * reimb_ccta
254
- + annual_uptake_cases * reimb_ffrct
255
- + annual_uptake_cases * pct_ai_qpa * reimb_ai_qpa
256
  )
257
- with_ai_vendor_costs = annual_uptake_cases * vendor_cost
258
  with_ai_platform_costs = platform_annual_cost
259
 
260
- baseline_addl_tests_in_ai_cohort = annual_uptake_cases * max(0.0, min(1.0, baseline_additional_testing_rate_pct/100.0))
261
- with_ai_additional_tests = annual_uptake_cases * (1.0 - one_test_dx)
262
  with_ai_additional_tests_cost = with_ai_additional_tests * stress_test_cost
263
  avoided_additional_tests = max(0.0, baseline_addl_tests_in_ai_cohort - with_ai_additional_tests)
264
 
265
- avoided_unnec_ica = baseline_unnecessary_ica * dec_unnec_ica * (annual_uptake_cases / annual_eligible if annual_eligible > 0 else 0)
266
  with_ai_unnecessary_ica = max(0.0, baseline_unnecessary_ica - avoided_unnec_ica)
267
  with_ai_unnecessary_ica_cost = with_ai_unnecessary_ica * net_cost_per_diag_ica
268
 
269
  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)))
270
- bed_hours_saved = annual_uptake_cases * ai_saved_hours_per_case
271
  bed_hours_value = bed_hours_saved * bed_hour_value
272
- clinician_hours_saved = annual_uptake_cases * max(0.0, float(baseline_clinician_touch_min)/60.0) * max(0.0, min(1.0, clinician_touch_reduction_pct/100.0))
273
  clinician_hours_value = clinician_hours_saved * clinician_hour_cost
274
  with_ai_ops_value = bed_hours_value + clinician_hours_value
275
 
276
  with_ai_costs = with_ai_vendor_costs + with_ai_platform_costs + with_ai_additional_tests_cost + with_ai_unnecessary_ica_cost
 
277
  incr_revenue = with_ai_revenue - baseline_revenue
278
  incr_costs = with_ai_costs - baseline_costs
279
  incr_ops = with_ai_ops_value - baseline_ops_value
280
  net_impact = incr_revenue - incr_costs + incr_ops
281
 
282
  ai_program_costs = with_ai_vendor_costs + with_ai_platform_costs
283
- roi_pct_val = (net_impact / ai_program_costs) if ai_program_costs > 0 else float("nan")
284
 
285
- per_case_net_impact = (net_impact / annual_uptake_cases) if annual_uptake_cases > 0 else 0.0
286
- cases_to_payback = (ai_program_costs / per_case_net_impact) if per_case_net_impact > 0 else math.inf
287
- 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)
288
 
289
  evidence = """
290
  <ul class='evidence'>
@@ -295,7 +306,7 @@ def compute_ffrct(
295
  """
296
 
297
  return {
298
- "summary": f"For your program with {int(annual_uptake_cases):,} AI cases/year, modeled net impact is {usd(net_impact)} annually.",
299
  "financial": {
300
  "rows": [
301
  ("Incremental revenue (annual)", usd(incr_revenue)),
@@ -303,7 +314,7 @@ def compute_ffrct(
303
  ("Operational value (annual)", usd(incr_ops)),
304
  ("AI program costs (annual)", usd(ai_program_costs)),
305
  ("Net impact (annual)", f"<b>{usd(net_impact)}</b>"),
306
- ("ROI % (annual on AI program)", f"<b>{'' if math.isnan(roi_pct_val) else f'{roi_pct_val*100:.1f}%'}</b>"),
307
  ("Months to payback", f"<b>{'∞' if months_to_payback==math.inf else f'{months_to_payback:.1f}'}</b>"),
308
  ]
309
  },
@@ -328,7 +339,7 @@ def compute_ffrct(
328
  ("Value of clinician time", usd(clinician_hours_value)),
329
  ]
330
  },
331
- "waterfall": [("Incremental revenue", incr_revenue), ("Incremental costs", -incr_costs), ("Operational value", incr_ops)],
332
  "annual_card": {
333
  "incr_rev": incr_revenue,
334
  "incr_costs": incr_costs,
@@ -349,8 +360,8 @@ def compute_msk(
349
  ):
350
  scans_per_month = clamp_nonneg(scans_per_day) * 30.0
351
 
352
- # Clinical volumes
353
- errors_reduced_per_month = int(scans_per_month * 0.05 * 0.20) # example incidence × improvement
354
  discrepant_cases_flagged = int(scans_per_month * 0.05)
355
  hrs_saved_per_visit = max(0.0, er_time_to_treatment_min) * 0.50 / 60.0
356
 
@@ -358,34 +369,29 @@ def compute_msk(
358
  time_saved_per_scan_min = max(0.0, reading_time_min) * 0.30
359
  total_time_saved_hours = (scans_per_month * time_saved_per_scan_min) / 60.0
360
  value_time_saved_month = total_time_saved_hours * radiologist_hourly_cost
361
- radiologist_cost_savings = scans_per_month * 4.0 # proxy from earlier MSK model
362
  ops_value_month = value_time_saved_month + radiologist_cost_savings
363
 
364
- # Financial (FFS-style): no extra revenue/costs modeled here
365
  incr_revenue_month = 0.0
366
  incr_costs_month = 0.0
367
 
368
- # Clinical value ($) placeholder (0); if you later monetize, wire it here
369
- clinical_value_month = 0.0
370
-
371
- # Net impact
372
- net_impact_month = incr_revenue_month - incr_costs_month + ops_value_month + clinical_value_month
373
 
374
  evidence = """
375
  <ul class='evidence'>
376
  <li>Faster ED triage modeled via shorter time-to-treatment and reduced radiologist touch time.</li>
377
  <li>Audit flags approximate discrepancy capture for QA workflows.</li>
378
- <li>Staff time savings converted to dollar value at radiologist $/hr.</li>
379
  </ul>
380
  """
381
 
382
- # Build rows conditionally (hide zeros)
383
  fin_rows = []
384
  if incr_revenue_month != 0:
385
  fin_rows.append(("Incremental revenue (mo)", usd(incr_revenue_month)))
386
  if incr_costs_month != 0:
387
  fin_rows.append(("Incremental costs (mo)", usd(incr_costs_month)))
388
- # Always show net
389
  fin_rows.append(("Net impact (mo)", f"<b>{usd(net_impact_month)}</b>"))
390
 
391
  clin_rows = []
@@ -404,7 +410,7 @@ def compute_msk(
404
  if radiologist_cost_savings > 0:
405
  op_rows.append(("Radiologist cost proxy savings (mo)", usd(radiologist_cost_savings)))
406
 
407
- # Waterfall: include only nonzero components
408
  wf_rows = []
409
  if incr_revenue_month != 0:
410
  wf_rows.append(("Incremental revenue", incr_revenue_month))
@@ -412,19 +418,16 @@ def compute_msk(
412
  wf_rows.append(("Incremental costs", -incr_costs_month))
413
  if ops_value_month != 0:
414
  wf_rows.append(("Operational value", ops_value_month))
415
- if clinical_value_month != 0:
416
- wf_rows.append(("Clinical value", clinical_value_month))
417
- # If all are zero except ops (typical), wf_rows will still show ops
418
 
419
  annual_card = {
420
  "incr_rev": incr_revenue_month * 12.0,
421
  "incr_costs": incr_costs_month * 12.0,
422
- # only add clinical_value if monetized
423
- # "clinical_value": clinical_value_month * 12.0,
424
  "ops_value": ops_value_month * 12.0,
425
  "net": net_impact_month * 12.0,
426
- "roi_pct": None, # hide in Overall Impact
427
- "payback": None, # hide in Overall Impact
428
  }
429
 
430
  return {
@@ -432,31 +435,28 @@ def compute_msk(
432
  "financial": {"rows": fin_rows},
433
  "clinical": {"rows": clin_rows, "bars": [("Touch-time reduction", 0.30)] if time_saved_per_scan_min > 0 else []},
434
  "operational": {"rows": op_rows},
435
- "waterfall": wf_rows if wf_rows else [("Operational value", ops_value_month)],
436
  "annual_card": annual_card,
437
  "evidence": evidence,
438
  }
439
 
440
  # ---------- Card / HTML builders ----------
441
  def build_overall_card(title: str, summary_line: str, annual: dict):
442
- """Conditional Overall card: shows Financial + Clinical + Operational; hides ROI/Payback if N/A or invalid."""
443
  rows = []
444
  if "incr_rev" in annual and annual["incr_rev"] != 0:
445
  rows.append(("Incremental revenue (annual)", f"<b>{usd(annual['incr_rev'])}</b>"))
446
  if "incr_costs" in annual and annual["incr_costs"] != 0:
447
  rows.append(("Incremental costs (annual)", f"<b class='neg'>{usd(annual['incr_costs'])}</b>"))
448
- if "clinical_value" in annual and isinstance(annual["clinical_value"], (int, float)) and annual["clinical_value"] != 0:
449
- rows.append(("Clinical value (annual)", f"<b>{usd(annual['clinical_value'])}</b>"))
450
  if "ops_value" in annual and annual["ops_value"] != 0:
451
  rows.append(("Operational value (annual)", f"<b>{usd(annual['ops_value'])}</b>"))
452
  if "net" in annual:
453
  rows.append(("Net impact (annual)", f"<b>{usd(annual['net'])}</b>"))
454
 
455
  roi = annual.get("roi_pct", None)
456
- if isinstance(roi, (int, float)) and not (math.isnan(roi) or math.isinf(roi)):
457
  rows.append(("ROI %", f"<b>{roi*100:.1f}%</b>"))
458
  payback = annual.get("payback", None)
459
- if isinstance(payback, (int, float)) and not (math.isnan(payback) or math.isinf(payback)):
460
  rows.append(("Months to payback", f"<b>{payback:.1f}</b>"))
461
 
462
  items = "".join(f"<div>{lab}</div><div>{val}</div>" for lab, val in rows)
@@ -480,8 +480,7 @@ def build_clinical_card(rows, bars):
480
  bars_html = ""
481
  for lab, frac in (bars or []):
482
  frac = max(0.0, min(1.0, float(frac)))
483
- # skip rendering a 0% bar entirely
484
- if frac <= 0:
485
  continue
486
  bars_html += f"""
487
  <div class='bar-row'>
@@ -492,17 +491,26 @@ def build_clinical_card(rows, bars):
492
  bars_section = f"<div class='bars'>{bars_html}</div>" if bars_html else ""
493
  return f"<div class='card'><div class='card-title'>Clinical</div><div class='kpi-grid'>{rows_html}</div>{bars_section}</div>"
494
 
495
- def build_waterfall(wf_rows, title="Waterfall (monthly)"):
496
- # Remove exact-zero bars, but keep at least one row (Net shows separately)
497
- filtered = [(l, v) for (l, v) in wf_rows if abs(float(v)) > 1e-9]
498
- wf_rows = filtered if filtered else wf_rows
 
 
 
 
 
 
499
  denom = sum(abs(v) for _, v in wf_rows) or 1.0
 
500
  def row(label, val):
501
  width = min(100, max(2, int(abs(val)/denom * 100)))
502
- cls = "pos" if val >= 0 else "neg"
503
  return f"<div class='wf-row'><div>{label}</div><div class='wf-bar {cls}' style='width:{width}%;'><span class='wf-val'>{usd(val)}</span></div></div>"
504
- return "<div class='card'><div class='card-title'>{}</div>{}<div class='wf-total'>Net impact: <b>{}</b></div></div>".format(
505
- title, "".join(row(l, v) for l, v in wf_rows), usd(sum(v for _, v in wf_rows))
 
 
506
  )
507
 
508
  def rows_to_csv(fin_rows, clin_rows, op_rows) -> str:
@@ -514,12 +522,11 @@ def build_ui():
514
  with gr.Blocks(theme=gr.themes.Soft(), css="""
515
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap');
516
  * { font-family: Inter, ui-sans-serif, system-ui; }
517
- .gradio-container { max-width: 1100px !important; }
518
  .card{background:#fff;border:1px solid #eef2f7;border-radius:18px;padding:18px;box-shadow:0 8px 24px rgba(0,0,0,.08);margin-bottom:12px}
519
  .card-title{font-weight:700;margin-bottom:6px}
520
  .pill{background:#ecfdf5;color:#065f46;padding:4px 10px;border-radius:999px;font-weight:700;font-size:.75rem}
521
  .kpi-grid{display:grid;grid-template-columns:1fr auto;gap:6px 12px}
522
- .kpi-grid .sep{border-top:1px solid #e5e7eb;padding-top:6px}
523
  .neg{color:#b91c1c}
524
  .sumline{margin-bottom:8px;opacity:.9}
525
  .small-note{opacity:.75;font-size:.9em;margin-top:6px}
@@ -529,9 +536,9 @@ def build_ui():
529
  .bars .bar span em{position:absolute;right:6px;top:-18px;font-size:.85em;opacity:.8}
530
  .wf-row{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center;margin:8px 0}
531
  .wf-bar{height:22px;border-radius:6px;display:flex;align-items:center;justify-content:flex-end;padding-right:8px;color:#0b1727;min-width:80px}
532
- .wf-bar.pos{background:linear-gradient(90deg,#a7f3d0,#34d399)}
533
- .wf-bar.neg{background:linear-gradient(90deg,#fecaca,#f87171)}
534
- .wf-val{font-weight:700}
535
  .wf-total{margin-top:8px;border-top:1px solid #e5e7eb;padding-top:8px;font-weight:700}
536
  .cta{display:flex;justify-content:space-between;align-items:center}
537
  .cta-btn{background:#0ea5e9;color:#fff;text-decoration:none;padding:10px 14px;border-radius:12px;font-weight:700}
@@ -572,9 +579,8 @@ def build_ui():
572
 
573
  with gr.Row():
574
  with gr.Column(scale=1):
575
- # Use case chooser (as cards)
576
  with gr.Column(elem_id="uc"):
577
- gr.Markdown("### We have three pre-loaded use cases")
578
  use_case = gr.Radio(
579
  choices=[
580
  "🩺 Mammography AI (MMG)",
@@ -582,10 +588,16 @@ def build_ui():
582
  "🦴 MSK AI (ER/Trauma)"
583
  ],
584
  value="🩺 Mammography AI (MMG)",
585
- label="Select Use Case",
 
 
 
 
 
 
586
  interactive=True
587
  )
588
- gr.Markdown("<div class='uc-hint'>Pick one to load the matching calculator. You can switch anytime.</div>")
589
 
590
  # --- MMG Inputs ---
591
  vendor_preset = gr.Dropdown(
@@ -671,7 +683,7 @@ def build_ui():
671
 
672
  def normalize_uc(uclabel: str) -> str:
673
  if "Mammography" in uclabel: return USE_CASES[0]
674
- if "FFR-CT" in uclabel: return USE_CASES[1]
675
  return USE_CASES[2]
676
 
677
  def _on_use_case_change(uc_label):
@@ -724,7 +736,7 @@ def build_ui():
724
  )
725
 
726
  def _compute(
727
- uclabel,
728
  # MMG
729
  mv, rm, rhr,
730
  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,
@@ -736,6 +748,7 @@ def build_ui():
736
  msk_day, msk_read, msk_ttt, msk_rhr,
737
  ):
738
  uc = normalize_uc(uclabel)
 
739
 
740
  if uc == USE_CASES[0]:
741
  res = compute_mmg(
@@ -745,6 +758,9 @@ def build_ui():
745
  v_fee, p_annual, integ_mo, cloud_mo,
746
  )
747
  title = "Overall Impact — Mammography AI"
 
 
 
748
  elif uc == USE_CASES[1]:
749
  res = compute_ffrct(
750
  s_type, ccta_mo, uptake, ttd_h, touch_min,
@@ -753,19 +769,24 @@ def build_ui():
753
  sens_upt, sens_dec, sens_vendor,
754
  )
755
  title = "Overall Impact — FFR-CT AI"
 
 
 
756
  else:
757
  res = compute_msk(msk_day, msk_read, msk_ttt, msk_rhr)
758
  title = "Overall Impact — MSK AI"
 
 
759
 
760
  overall_html = build_overall_card(title, res["summary"], res["annual_card"])
761
  financial_html = build_rows_card("Financial", res["financial"]["rows"])
762
  clinical_html = build_clinical_card(res["clinical"]["rows"], res["clinical"].get("bars"))
763
  operational_html = build_rows_card("Operational", res["operational"]["rows"])
764
- water = build_waterfall(res["waterfall"])
765
  evidence = f"<div class='card'><div class='card-title'>Evidence snapshot</div>{res['evidence']}<div class='small-note'>Neutral claims; update with site citations.</div></div>"
766
  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>"
767
 
768
- # CSV export -> write to temp file
769
  fin_rows = [(lab, val) for lab, val in res["financial"]["rows"]]
770
  clin_rows = [(lab, val) for lab, val in res["clinical"]["rows"]]
771
  op_rows = [(lab, val) for lab, val in res["operational"]["rows"]]
@@ -774,7 +795,7 @@ def build_ui():
774
  return overall_html, financial_html, clinical_html, operational_html, water, evidence, gr.update(value=cta, visible=True), gr.update(value=csv_path, visible=True)
775
 
776
  inputs = [
777
- use_case,
778
  # MMG inputs
779
  mmg_monthly_volume, mmg_read_minutes, mmg_rdx_hr_cost,
780
  mmg_base_ppr, mmg_ai_ppr, mmg_base_audit, mmg_ai_audit, mmg_base_recall, mmg_ai_recall, mmg_recall_cost, mmg_read_redux, mmg_cost_per_scan, mmg_cost_redux, mmg_follow_price, mmg_follow_uplift, mmg_early_uplift_per_1000, mmg_tx_delta,
 
6
 
7
  # ==========================================
8
  # CARPL Multi-Use-Case ROI Calculators (MMG / FFR-CT / MSK AI)
9
+ # - Consistent UI/UX across use cases
10
+ # - Waterfall now colors COSTS in red automatically
11
+ # - Monthly/Annual toggle (default: Annual)
12
+ # - MSK hides zero/NA metrics
13
+ # - ROI/Payback shown only when finite
14
  # ==========================================
15
 
16
  USE_CASES = ["Mammography AI (MMG)", "FFR-CT AI", "MSK AI (ER/Trauma)"]
17
+ CTA_URL = "https://carpl.ai/contact-us"
18
  CTA_LABEL = "Book a 15-min walkthrough"
19
 
20
  MMG_VENDOR_PRESETS = {
 
71
  def clamp_nonneg(x: float) -> float:
72
  return max(0.0, float(x))
73
 
74
+ def safe_fraction(x: float) -> float:
75
+ return max(0.0, min(1.0, float(x)))
76
+
77
  def write_csv(rows, title: str = "roi_results") -> str:
78
  """Write rows [(label, value), ...] to a temp CSV and return file path."""
79
  csv_dir = Path(tempfile.gettempdir())
 
109
  monthly_ai_cases = clamp_nonneg(monthly_volume)
110
  annual_ai_cases = monthly_ai_cases * 12.0
111
 
112
+ # Clinical
113
  errors_reduced = clamp_nonneg(monthly_ai_cases * (base_ppr - ai_ppr))
114
  discrepant_flags = clamp_nonneg(monthly_ai_cases * (ai_audit_rate - base_audit_rate))
115
  recalls_avoided = clamp_nonneg(monthly_ai_cases * (base_recall_rate - ai_recall_rate))
116
  earlier_detections = clamp_nonneg(monthly_ai_cases * (early_detect_uplift_per_1000 / 1000.0))
117
 
118
+ # Ops
119
  base_read_seconds = read_minutes * 60.0
120
  hours_saved = clamp_nonneg(monthly_ai_cases * (base_read_seconds * read_reduction_pct) / 3600.0)
121
  workload_reduction_pct = read_reduction_pct
 
123
  capacity_increase_pct = (1.0 / max(1e-6, (1.0 - read_reduction_pct)) - 1.0)
124
  value_time_saved_month = hours_saved * radiologist_hourly_cost
125
 
126
+ # $$
127
  baseline_monthly_cost = monthly_ai_cases * base_cost_per_scan
128
  new_monthly_cost = baseline_monthly_cost * (1.0 - cost_reduction_pct)
129
  per_scan_cost_savings_month = baseline_monthly_cost - new_monthly_cost
 
141
  ops_value_month = value_time_saved_month + per_scan_cost_savings_month + recall_cost_savings_month + early_detection_savings_month
142
 
143
  net_impact_month = incr_revenue_month - incr_costs_month + ops_value_month
144
+ roi_pct_annual = ( (net_impact_month*12) / max(1e-6, (incr_costs_month*12)) )
145
  months_to_payback = (
146
  (platform_annual_fee + vendor_per_case_fee*annual_ai_cases + other_costs_month*12.0)
147
  / max(1e-6, (net_impact_month / max(1.0, monthly_ai_cases))) / max(1.0, monthly_ai_cases)
 
176
  ("Integration & cloud (mo)", usd(other_costs_month)),
177
  ("Net impact (mo)", f"<b>{usd(net_impact_month)}</b>"),
178
  ("Net impact (annual)", f"<b>{usd(net_impact_month*12)}</b>"),
179
+ ("ROI % (annual)", f"<b>{roi_pct_annual*100:.1f}%</b>"),
180
  ("Months to payback", f"<b>{months_to_payback:.1f}</b>"),
181
  ]
182
  },
 
200
  ("Effective capacity increase", pct(capacity_increase_pct)),
201
  ]
202
  },
203
+ "waterfall_monthly": [("Incremental revenue", incr_revenue_month), ("Incremental costs", -incr_costs_month), ("Operational value", ops_value_month)],
204
  "annual_card": {
205
  "incr_rev": incr_revenue_month * 12.0,
206
  "incr_costs": incr_costs_month * 12.0,
 
234
  net_cost_per_diag_ica = 4000.0
235
 
236
  monthly_eligible_ccta = clamp_nonneg(monthly_eligible_ccta)
237
+ uptake = safe_fraction(uptake_pct/100.0) * safe_fraction(sens_uptake_factor_pct/100.0)
238
  annual_eligible = monthly_eligible_ccta * 12.0
239
+ annual_ai_cases = annual_eligible * uptake
240
+
241
+ pct_ai_qpa = safe_fraction(pct_billed_ai_qpa/100.0)
242
+ one_test_dx = safe_fraction(one_test_dx_pct/100.0)
243
+ dec_unnec_ica = safe_fraction(dec_unnec_ica_pct/100.0) * safe_fraction(sens_dec_unnec_ica_factor_pct/100.0)
244
+ more_likely_revasc = safe_fraction(more_likely_revasc_pct/100.0)
245
+ revasc_prev = safe_fraction(revasc_prevalence_pct/100.0)
246
+ vendor_cost = float(vendor_per_case_cost) * safe_fraction(sens_vendor_cost_factor_pct/100.0)
247
  platform_annual_cost = float(platform_annual_cost)
248
  stress_test_cost = float(stress_test_cost)
249
 
250
+ # Baseline (annual)
251
  baseline_revenue = annual_eligible * reimb_ccta
252
+ baseline_additional_tests = annual_eligible * safe_fraction(baseline_additional_testing_rate_pct/100.0)
253
  baseline_additional_tests_cost = baseline_additional_tests * stress_test_cost
254
+ baseline_diag_ica_total = annual_eligible * safe_fraction(baseline_diag_ica_rate_pct/100.0)
255
  baseline_revasc_true = annual_eligible * revasc_prev
256
  baseline_unnecessary_ica = max(0.0, baseline_diag_ica_total - baseline_revasc_true)
257
  baseline_unnecessary_ica_cost = baseline_unnecessary_ica * net_cost_per_diag_ica
258
  baseline_ops_value = 0.0
259
  baseline_costs = baseline_additional_tests_cost + baseline_unnecessary_ica_cost
260
 
261
+ # With AI (annual)
262
  with_ai_revenue = (
263
  annual_eligible * reimb_ccta
264
+ + annual_ai_cases * reimb_ffrct
265
+ + annual_ai_cases * pct_ai_qpa * reimb_ai_qpa
266
  )
267
+ with_ai_vendor_costs = annual_ai_cases * vendor_cost
268
  with_ai_platform_costs = platform_annual_cost
269
 
270
+ baseline_addl_tests_in_ai_cohort = annual_ai_cases * safe_fraction(baseline_additional_testing_rate_pct/100.0)
271
+ with_ai_additional_tests = annual_ai_cases * (1.0 - one_test_dx)
272
  with_ai_additional_tests_cost = with_ai_additional_tests * stress_test_cost
273
  avoided_additional_tests = max(0.0, baseline_addl_tests_in_ai_cohort - with_ai_additional_tests)
274
 
275
+ avoided_unnec_ica = baseline_unnecessary_ica * dec_unnec_ica * (annual_ai_cases / annual_eligible if annual_eligible > 0 else 0.0)
276
  with_ai_unnecessary_ica = max(0.0, baseline_unnecessary_ica - avoided_unnec_ica)
277
  with_ai_unnecessary_ica_cost = with_ai_unnecessary_ica * net_cost_per_diag_ica
278
 
279
  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)))
280
+ bed_hours_saved = annual_ai_cases * ai_saved_hours_per_case
281
  bed_hours_value = bed_hours_saved * bed_hour_value
282
+ clinician_hours_saved = annual_ai_cases * max(0.0, float(baseline_clinician_touch_min)/60.0) * safe_fraction(clinician_touch_reduction_pct/100.0)
283
  clinician_hours_value = clinician_hours_saved * clinician_hour_cost
284
  with_ai_ops_value = bed_hours_value + clinician_hours_value
285
 
286
  with_ai_costs = with_ai_vendor_costs + with_ai_platform_costs + with_ai_additional_tests_cost + with_ai_unnecessary_ica_cost
287
+
288
  incr_revenue = with_ai_revenue - baseline_revenue
289
  incr_costs = with_ai_costs - baseline_costs
290
  incr_ops = with_ai_ops_value - baseline_ops_value
291
  net_impact = incr_revenue - incr_costs + incr_ops
292
 
293
  ai_program_costs = with_ai_vendor_costs + with_ai_platform_costs
294
+ roi_pct_val = net_impact / max(1e-6, ai_program_costs)
295
 
296
+ per_case_net_impact = (net_impact / annual_ai_cases) if annual_ai_cases > 0 else 0.0
297
+ cases_to_payback = (ai_program_costs / max(1e-6, per_case_net_impact)) if per_case_net_impact > 0 else math.inf
298
+ months_to_payback = (cases_to_payback / (monthly_eligible_ccta * uptake)) if (monthly_eligible_ccta * uptake) > 0 else math.inf
299
 
300
  evidence = """
301
  <ul class='evidence'>
 
306
  """
307
 
308
  return {
309
+ "summary": f"For your program with {int(annual_ai_cases):,} AI cases/year, modeled net impact is {usd(net_impact)} annually.",
310
  "financial": {
311
  "rows": [
312
  ("Incremental revenue (annual)", usd(incr_revenue)),
 
314
  ("Operational value (annual)", usd(incr_ops)),
315
  ("AI program costs (annual)", usd(ai_program_costs)),
316
  ("Net impact (annual)", f"<b>{usd(net_impact)}</b>"),
317
+ ("ROI % (annual on AI program)", f"<b>{roi_pct_val*100:.1f}%</b>"),
318
  ("Months to payback", f"<b>{'∞' if months_to_payback==math.inf else f'{months_to_payback:.1f}'}</b>"),
319
  ]
320
  },
 
339
  ("Value of clinician time", usd(clinician_hours_value)),
340
  ]
341
  },
342
+ "waterfall_annual": [("Incremental revenue", incr_revenue), ("Incremental costs", -incr_costs), ("Operational value", incr_ops)],
343
  "annual_card": {
344
  "incr_rev": incr_revenue,
345
  "incr_costs": incr_costs,
 
360
  ):
361
  scans_per_month = clamp_nonneg(scans_per_day) * 30.0
362
 
363
+ # Clinical volumes (illustrative)
364
+ errors_reduced_per_month = int(scans_per_month * 0.05 * 0.20) # incidence × improvement
365
  discrepant_cases_flagged = int(scans_per_month * 0.05)
366
  hrs_saved_per_visit = max(0.0, er_time_to_treatment_min) * 0.50 / 60.0
367
 
 
369
  time_saved_per_scan_min = max(0.0, reading_time_min) * 0.30
370
  total_time_saved_hours = (scans_per_month * time_saved_per_scan_min) / 60.0
371
  value_time_saved_month = total_time_saved_hours * radiologist_hourly_cost
372
+ radiologist_cost_savings = scans_per_month * 4.0 # conservative proxy
373
  ops_value_month = value_time_saved_month + radiologist_cost_savings
374
 
375
+ # Financial: by default not counted
376
  incr_revenue_month = 0.0
377
  incr_costs_month = 0.0
378
 
379
+ net_impact_month = incr_revenue_month - incr_costs_month + ops_value_month
 
 
 
 
380
 
381
  evidence = """
382
  <ul class='evidence'>
383
  <li>Faster ED triage modeled via shorter time-to-treatment and reduced radiologist touch time.</li>
384
  <li>Audit flags approximate discrepancy capture for QA workflows.</li>
385
+ <li>Staff time savings converted to $ value at radiologist $/hr.</li>
386
  </ul>
387
  """
388
 
389
+ # Conditional rows (hide zeros)
390
  fin_rows = []
391
  if incr_revenue_month != 0:
392
  fin_rows.append(("Incremental revenue (mo)", usd(incr_revenue_month)))
393
  if incr_costs_month != 0:
394
  fin_rows.append(("Incremental costs (mo)", usd(incr_costs_month)))
 
395
  fin_rows.append(("Net impact (mo)", f"<b>{usd(net_impact_month)}</b>"))
396
 
397
  clin_rows = []
 
410
  if radiologist_cost_savings > 0:
411
  op_rows.append(("Radiologist cost proxy savings (mo)", usd(radiologist_cost_savings)))
412
 
413
+ # Waterfall (monthly), include only nonzero components
414
  wf_rows = []
415
  if incr_revenue_month != 0:
416
  wf_rows.append(("Incremental revenue", incr_revenue_month))
 
418
  wf_rows.append(("Incremental costs", -incr_costs_month))
419
  if ops_value_month != 0:
420
  wf_rows.append(("Operational value", ops_value_month))
421
+ if not wf_rows:
422
+ wf_rows = [("Operational value", ops_value_month)]
 
423
 
424
  annual_card = {
425
  "incr_rev": incr_revenue_month * 12.0,
426
  "incr_costs": incr_costs_month * 12.0,
 
 
427
  "ops_value": ops_value_month * 12.0,
428
  "net": net_impact_month * 12.0,
429
+ "roi_pct": None, # hidden
430
+ "payback": None, # hidden
431
  }
432
 
433
  return {
 
435
  "financial": {"rows": fin_rows},
436
  "clinical": {"rows": clin_rows, "bars": [("Touch-time reduction", 0.30)] if time_saved_per_scan_min > 0 else []},
437
  "operational": {"rows": op_rows},
438
+ "waterfall_monthly": wf_rows,
439
  "annual_card": annual_card,
440
  "evidence": evidence,
441
  }
442
 
443
  # ---------- Card / HTML builders ----------
444
  def build_overall_card(title: str, summary_line: str, annual: dict):
 
445
  rows = []
446
  if "incr_rev" in annual and annual["incr_rev"] != 0:
447
  rows.append(("Incremental revenue (annual)", f"<b>{usd(annual['incr_rev'])}</b>"))
448
  if "incr_costs" in annual and annual["incr_costs"] != 0:
449
  rows.append(("Incremental costs (annual)", f"<b class='neg'>{usd(annual['incr_costs'])}</b>"))
 
 
450
  if "ops_value" in annual and annual["ops_value"] != 0:
451
  rows.append(("Operational value (annual)", f"<b>{usd(annual['ops_value'])}</b>"))
452
  if "net" in annual:
453
  rows.append(("Net impact (annual)", f"<b>{usd(annual['net'])}</b>"))
454
 
455
  roi = annual.get("roi_pct", None)
456
+ if isinstance(roi, (int, float)) and math.isfinite(roi):
457
  rows.append(("ROI %", f"<b>{roi*100:.1f}%</b>"))
458
  payback = annual.get("payback", None)
459
+ if isinstance(payback, (int, float)) and math.isfinite(payback):
460
  rows.append(("Months to payback", f"<b>{payback:.1f}</b>"))
461
 
462
  items = "".join(f"<div>{lab}</div><div>{val}</div>" for lab, val in rows)
 
480
  bars_html = ""
481
  for lab, frac in (bars or []):
482
  frac = max(0.0, min(1.0, float(frac)))
483
+ if frac <= 0:
 
484
  continue
485
  bars_html += f"""
486
  <div class='bar-row'>
 
491
  bars_section = f"<div class='bars'>{bars_html}</div>" if bars_html else ""
492
  return f"<div class='card'><div class='card-title'>Clinical</div><div class='kpi-grid'>{rows_html}</div>{bars_section}</div>"
493
 
494
+ def build_waterfall(wf_rows, period_label="Annual"):
495
+ """
496
+ wf_rows: list of (label, value) where value is signed.
497
+ Colors: positive=green, negative=red (costs).
498
+ """
499
+ # remove exact zero bars
500
+ wf_rows = [(l, v) for (l, v) in wf_rows if abs(float(v)) > 1e-9]
501
+ if not wf_rows:
502
+ return "<div class='card'><div class='card-title'>Waterfall ({})</div><div>—</div></div>".format(period_label)
503
+
504
  denom = sum(abs(v) for _, v in wf_rows) or 1.0
505
+
506
  def row(label, val):
507
  width = min(100, max(2, int(abs(val)/denom * 100)))
508
+ cls = "wf-pos" if val >= 0 else "wf-neg"
509
  return f"<div class='wf-row'><div>{label}</div><div class='wf-bar {cls}' style='width:{width}%;'><span class='wf-val'>{usd(val)}</span></div></div>"
510
+
511
+ net_total = sum(v for _, v in wf_rows)
512
+ return "<div class='card'><div class='card-title'>Waterfall ({})</div>{}<div class='wf-total'>Net impact: <b>{}</b></div></div>".format(
513
+ period_label, "".join(row(l, v) for l, v in wf_rows), usd(net_total)
514
  )
515
 
516
  def rows_to_csv(fin_rows, clin_rows, op_rows) -> str:
 
522
  with gr.Blocks(theme=gr.themes.Soft(), css="""
523
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap');
524
  * { font-family: Inter, ui-sans-serif, system-ui; }
525
+ .gradio-container { max-width: 1120px !important; }
526
  .card{background:#fff;border:1px solid #eef2f7;border-radius:18px;padding:18px;box-shadow:0 8px 24px rgba(0,0,0,.08);margin-bottom:12px}
527
  .card-title{font-weight:700;margin-bottom:6px}
528
  .pill{background:#ecfdf5;color:#065f46;padding:4px 10px;border-radius:999px;font-weight:700;font-size:.75rem}
529
  .kpi-grid{display:grid;grid-template-columns:1fr auto;gap:6px 12px}
 
530
  .neg{color:#b91c1c}
531
  .sumline{margin-bottom:8px;opacity:.9}
532
  .small-note{opacity:.75;font-size:.9em;margin-top:6px}
 
536
  .bars .bar span em{position:absolute;right:6px;top:-18px;font-size:.85em;opacity:.8}
537
  .wf-row{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center;margin:8px 0}
538
  .wf-bar{height:22px;border-radius:6px;display:flex;align-items:center;justify-content:flex-end;padding-right:8px;color:#0b1727;min-width:80px}
539
+ .wf-bar .wf-val{font-weight:700}
540
+ .wf-pos{background:linear-gradient(90deg,#a7f3d0,#34d399)} /* green */
541
+ .wf-neg{background:linear-gradient(90deg,#fecaca,#f87171)} /* red for costs/negatives */
542
  .wf-total{margin-top:8px;border-top:1px solid #e5e7eb;padding-top:8px;font-weight:700}
543
  .cta{display:flex;justify-content:space-between;align-items:center}
544
  .cta-btn{background:#0ea5e9;color:#fff;text-decoration:none;padding:10px 14px;border-radius:12px;font-weight:700}
 
579
 
580
  with gr.Row():
581
  with gr.Column(scale=1):
582
+ # Use case chooser
583
  with gr.Column(elem_id="uc"):
 
584
  use_case = gr.Radio(
585
  choices=[
586
  "🩺 Mammography AI (MMG)",
 
588
  "🦴 MSK AI (ER/Trauma)"
589
  ],
590
  value="🩺 Mammography AI (MMG)",
591
+ label="Choose your use case",
592
+ interactive=True
593
+ )
594
+ period = gr.Radio(
595
+ choices=["Annual", "Monthly"],
596
+ value="Annual",
597
+ label="Reporting period",
598
  interactive=True
599
  )
600
+ gr.Markdown("<div class='uc-hint'>Pick a calculator and reporting period. You can switch anytime.</div>")
601
 
602
  # --- MMG Inputs ---
603
  vendor_preset = gr.Dropdown(
 
683
 
684
  def normalize_uc(uclabel: str) -> str:
685
  if "Mammography" in uclabel: return USE_CASES[0]
686
+ if "FFR" in uclabel: return USE_CASES[1]
687
  return USE_CASES[2]
688
 
689
  def _on_use_case_change(uc_label):
 
736
  )
737
 
738
  def _compute(
739
+ uclabel, period_sel,
740
  # MMG
741
  mv, rm, rhr,
742
  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,
 
748
  msk_day, msk_read, msk_ttt, msk_rhr,
749
  ):
750
  uc = normalize_uc(uclabel)
751
+ annual = (period_sel == "Annual")
752
 
753
  if uc == USE_CASES[0]:
754
  res = compute_mmg(
 
758
  v_fee, p_annual, integ_mo, cloud_mo,
759
  )
760
  title = "Overall Impact — Mammography AI"
761
+ # Waterfall source (monthly), scale if annual
762
+ wf = res["waterfall_monthly"]
763
+ wf = [(l, v*12.0) for (l, v) in wf] if annual else wf
764
  elif uc == USE_CASES[1]:
765
  res = compute_ffrct(
766
  s_type, ccta_mo, uptake, ttd_h, touch_min,
 
769
  sens_upt, sens_dec, sens_vendor,
770
  )
771
  title = "Overall Impact — FFR-CT AI"
772
+ # Waterfall source (annual), scale if monthly
773
+ wf = res["waterfall_annual"]
774
+ wf = [(l, v/12.0) for (l, v) in wf] if not annual else wf
775
  else:
776
  res = compute_msk(msk_day, msk_read, msk_ttt, msk_rhr)
777
  title = "Overall Impact — MSK AI"
778
+ wf = res["waterfall_monthly"]
779
+ wf = [(l, v*12.0) for (l, v) in wf] if annual else wf
780
 
781
  overall_html = build_overall_card(title, res["summary"], res["annual_card"])
782
  financial_html = build_rows_card("Financial", res["financial"]["rows"])
783
  clinical_html = build_clinical_card(res["clinical"]["rows"], res["clinical"].get("bars"))
784
  operational_html = build_rows_card("Operational", res["operational"]["rows"])
785
+ water = build_waterfall(wf, period_label=("Annual" if annual else "Monthly"))
786
  evidence = f"<div class='card'><div class='card-title'>Evidence snapshot</div>{res['evidence']}<div class='small-note'>Neutral claims; update with site citations.</div></div>"
787
  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>"
788
 
789
+ # CSV export
790
  fin_rows = [(lab, val) for lab, val in res["financial"]["rows"]]
791
  clin_rows = [(lab, val) for lab, val in res["clinical"]["rows"]]
792
  op_rows = [(lab, val) for lab, val in res["operational"]["rows"]]
 
795
  return overall_html, financial_html, clinical_html, operational_html, water, evidence, gr.update(value=cta, visible=True), gr.update(value=csv_path, visible=True)
796
 
797
  inputs = [
798
+ use_case, period,
799
  # MMG inputs
800
  mmg_monthly_volume, mmg_read_minutes, mmg_rdx_hr_cost,
801
  mmg_base_ppr, mmg_ai_ppr, mmg_base_audit, mmg_ai_audit, mmg_base_recall, mmg_ai_recall, mmg_recall_cost, mmg_read_redux, mmg_cost_per_scan, mmg_cost_redux, mmg_follow_price, mmg_follow_uplift, mmg_early_uplift_per_1000, mmg_tx_delta,