Spaces:
Sleeping
Sleeping
Update app.py
Browse files
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 |
-
#
|
| 10 |
-
#
|
| 11 |
-
#
|
|
|
|
|
|
|
| 12 |
# ==========================================
|
| 13 |
|
| 14 |
USE_CASES = ["Mammography AI (MMG)", "FFR-CT AI", "MSK AI (ER/Trauma)"]
|
| 15 |
-
CTA_URL = "https://carpl.ai/contact-us"
|
| 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))
|
| 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>{
|
| 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 |
-
"
|
| 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 =
|
| 230 |
annual_eligible = monthly_eligible_ccta * 12.0
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
pct_ai_qpa =
|
| 234 |
-
one_test_dx =
|
| 235 |
-
dec_unnec_ica =
|
| 236 |
-
more_likely_revasc =
|
| 237 |
-
revasc_prev =
|
| 238 |
-
vendor_cost = float(vendor_per_case_cost) *
|
| 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 *
|
| 244 |
baseline_additional_tests_cost = baseline_additional_tests * stress_test_cost
|
| 245 |
-
baseline_diag_ica_total = annual_eligible *
|
| 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 |
-
+
|
| 255 |
-
+
|
| 256 |
)
|
| 257 |
-
with_ai_vendor_costs =
|
| 258 |
with_ai_platform_costs = platform_annual_cost
|
| 259 |
|
| 260 |
-
baseline_addl_tests_in_ai_cohort =
|
| 261 |
-
with_ai_additional_tests =
|
| 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 * (
|
| 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 =
|
| 271 |
bed_hours_value = bed_hours_saved * bed_hour_value
|
| 272 |
-
clinician_hours_saved =
|
| 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 =
|
| 284 |
|
| 285 |
-
per_case_net_impact = (net_impact /
|
| 286 |
-
cases_to_payback = (ai_program_costs / per_case_net_impact) if per_case_net_impact > 0 else math.inf
|
| 287 |
-
months_to_payback = (
|
| 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(
|
| 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>{
|
| 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 |
-
"
|
| 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) #
|
| 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 #
|
| 362 |
ops_value_month = value_time_saved_month + radiologist_cost_savings
|
| 363 |
|
| 364 |
-
# Financial
|
| 365 |
incr_revenue_month = 0.0
|
| 366 |
incr_costs_month = 0.0
|
| 367 |
|
| 368 |
-
|
| 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
|
| 379 |
</ul>
|
| 380 |
"""
|
| 381 |
|
| 382 |
-
#
|
| 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
|
| 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
|
| 416 |
-
wf_rows
|
| 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, #
|
| 427 |
-
"payback": None, #
|
| 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 |
-
"
|
| 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
|
| 457 |
rows.append(("ROI %", f"<b>{roi*100:.1f}%</b>"))
|
| 458 |
payback = annual.get("payback", None)
|
| 459 |
-
if isinstance(payback, (int, float)) and
|
| 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 |
-
|
| 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,
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 505 |
-
|
|
|
|
|
|
|
| 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:
|
| 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.
|
| 533 |
-
.wf-
|
| 534 |
-
.wf-
|
| 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
|
| 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="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
interactive=True
|
| 587 |
)
|
| 588 |
-
gr.Markdown("<div class='uc-hint'>Pick
|
| 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
|
| 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(
|
| 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
|
| 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,
|