ROI_UseCase / app.py
SChodavarpu's picture
Update app.py
88baec3 verified
raw
history blame
42.6 kB
# app.py
import math
import tempfile
from pathlib import Path
import gradio as gr
# ==========================================
# CARPL Multi-Use-Case ROI Calculators (MMG / FFR-CT / MSK AI)
# - Consistent UI/UX across use cases
# - Waterfall now colors COSTS in red automatically
# - Monthly/Annual toggle (default: Annual)
# - MSK hides zero/NA metrics
# - ROI/Payback shown only when finite
# ==========================================
USE_CASES = ["Mammography AI (MMG)", "FFR-CT AI", "MSK AI (ER/Trauma)"]
CTA_URL = "https://carpl.ai/contact-us"
CTA_LABEL = "Book a 15-min walkthrough"
MMG_VENDOR_PRESETS = {
"Custom": {},
"Lunit": {
"base_recall_rate": 0.028, "ai_recall_rate": 0.025,
"base_ppr": 0.100, "ai_ppr": 0.095,
"ai_audit_rate": 0.050, "base_audit_rate": 0.000,
"read_reduction_pct": 0.15,
"followup_uplift_pct": 0.0095,
"early_detect_uplift_per_1000": 0.7,
},
"Therapixel (MammoScreen)": {
"base_recall_rate": 0.028, "ai_recall_rate": 0.024,
"base_ppr": 0.100, "ai_ppr": 0.094,
"ai_audit_rate": 0.040, "base_audit_rate": 0.000,
"read_reduction_pct": 0.15,
"followup_uplift_pct": 0.010,
"early_detect_uplift_per_1000": 0.9,
},
"MammoScreen (Alt)": {
"base_recall_rate": 0.030, "ai_recall_rate": 0.026,
"base_ppr": 0.100, "ai_ppr": 0.093,
"ai_audit_rate": 0.045, "base_audit_rate": 0.000,
"read_reduction_pct": 0.18,
"followup_uplift_pct": 0.011,
"early_detect_uplift_per_1000": 0.8,
},
"MedCognetics": {
"base_recall_rate": 0.029, "ai_recall_rate": 0.026,
"base_ppr": 0.100, "ai_ppr": 0.096,
"ai_audit_rate": 0.035, "base_audit_rate": 0.000,
"read_reduction_pct": 0.14,
"followup_uplift_pct": 0.009,
"early_detect_uplift_per_1000": 0.6,
},
}
# ---------- Helpers ----------
def usd(x: float, digits: int = 0) -> str:
if x == math.inf:
return "∞"
try:
return "$" + f"{x:,.{digits}f}"
except Exception:
return "$0"
def pct(x: float, digits: int = 1) -> str:
try:
return f"{x*100:.{digits}f}%"
except Exception:
return "0.0%"
def clamp_nonneg(x: float) -> float:
return max(0.0, float(x))
def safe_fraction(x: float) -> float:
return max(0.0, min(1.0, float(x)))
def write_csv(rows, title: str = "roi_results") -> str:
"""Write rows [(label, value), ...] to a temp CSV and return file path."""
csv_dir = Path(tempfile.gettempdir())
path = csv_dir / f"{title.replace(' ','_').lower()}.csv"
with open(path, "w", encoding="utf-8") as f:
f.write("Metric,Value\n")
for lab, val in rows:
val = str(val).replace("<b>", "").replace("</b>", "")
f.write(f"\"{lab}\",\"{val}\"\n")
return str(path)
# ---------- MMG (Mammography) ----------
def compute_mmg(
monthly_volume: float,
read_minutes: float,
radiologist_hourly_cost: float,
# Sensitivity (advanced)
base_ppr: float, ai_ppr: float,
base_audit_rate: float, ai_audit_rate: float,
base_recall_rate: float, ai_recall_rate: float,
recall_cost_per_case: float,
read_reduction_pct: float,
base_cost_per_scan: float, cost_reduction_pct: float,
followup_price: float, followup_uplift_pct: float,
early_detect_uplift_per_1000: float,
treatment_cost_delta_early_vs_late: float,
# Backend program costs (hidden)
vendor_per_case_fee: float,
platform_annual_fee: float,
integration_overhead_monthly: float,
cloud_compute_monthly: float,
):
monthly_ai_cases = clamp_nonneg(monthly_volume)
annual_ai_cases = monthly_ai_cases * 12.0
# Clinical
errors_reduced = clamp_nonneg(monthly_ai_cases * (base_ppr - ai_ppr))
discrepant_flags = clamp_nonneg(monthly_ai_cases * (ai_audit_rate - base_audit_rate))
recalls_avoided = clamp_nonneg(monthly_ai_cases * (base_recall_rate - ai_recall_rate))
earlier_detections = clamp_nonneg(monthly_ai_cases * (early_detect_uplift_per_1000 / 1000.0))
# Ops
base_read_seconds = read_minutes * 60.0
hours_saved = clamp_nonneg(monthly_ai_cases * (base_read_seconds * read_reduction_pct) / 3600.0)
workload_reduction_pct = read_reduction_pct
fte_saved = hours_saved / 160.0
capacity_increase_pct = (1.0 / max(1e-6, (1.0 - read_reduction_pct)) - 1.0)
value_time_saved_month = hours_saved * radiologist_hourly_cost
# $$
baseline_monthly_cost = monthly_ai_cases * base_cost_per_scan
new_monthly_cost = baseline_monthly_cost * (1.0 - cost_reduction_pct)
per_scan_cost_savings_month = baseline_monthly_cost - new_monthly_cost
addl_followups = monthly_ai_cases * followup_uplift_pct
addl_followup_revenue_month = addl_followups * followup_price
recall_cost_savings_month = recalls_avoided * recall_cost_per_case
early_detection_savings_month = earlier_detections * treatment_cost_delta_early_vs_late
vendor_cost_month = monthly_ai_cases * vendor_per_case_fee
platform_cost_month = platform_annual_fee / 12.0
other_costs_month = integration_overhead_monthly + cloud_compute_monthly
incr_revenue_month = addl_followup_revenue_month
incr_costs_month = vendor_cost_month + platform_cost_month + other_costs_month
ops_value_month = value_time_saved_month + per_scan_cost_savings_month + recall_cost_savings_month + early_detection_savings_month
net_impact_month = incr_revenue_month - incr_costs_month + ops_value_month
roi_pct_annual = ( (net_impact_month*12) / max(1e-6, (incr_costs_month*12)) )
months_to_payback = (
(platform_annual_fee + vendor_per_case_fee*annual_ai_cases + other_costs_month*12.0)
/ max(1e-6, (net_impact_month / max(1.0, monthly_ai_cases))) / max(1.0, monthly_ai_cases)
)
evidence = """
<ul class='evidence'>
<li>Modeled reductions in recalls and false positives reduce unnecessary follow-ups and costs.</li>
<li>Earlier detection can reduce treatment costs vs late-stage presentation.</li>
<li>Reading-time reductions translate into throughput gains and less burnout.</li>
</ul>
"""
clinical_bullet = (
f"~{int(round(recalls_avoided))} recalls avoided, "
f"{earlier_detections:.1f} earlier cancers detected, "
f"{int(round(errors_reduced))} fewer missed positives"
)
return {
"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}.",
"financial": {
"rows": [
("Additional follow-up scans (count/mo)", f"{int(round(addl_followups))}"),
("Additional follow-up revenue (mo)", usd(addl_followup_revenue_month)),
("Value of time saved (mo)", usd(value_time_saved_month)),
("Per-scan radiologist cost savings (mo)", usd(per_scan_cost_savings_month)),
("Savings from avoided recalls (mo)", usd(recall_cost_savings_month)),
("Savings from earlier detection (mo)", usd(early_detection_savings_month)),
("AI vendor fees (mo)", usd(vendor_cost_month)),
("Platform license (mo)", usd(platform_cost_month)),
("Integration & cloud (mo)", usd(other_costs_month)),
("Net impact (mo)", f"<b>{usd(net_impact_month)}</b>"),
("Net impact (annual)", f"<b>{usd(net_impact_month*12)}</b>"),
("ROI % (annual)", f"<b>{roi_pct_annual*100:.1f}%</b>"),
("Months to payback", f"<b>{months_to_payback:.1f}</b>"),
]
},
"clinical": {
"rows": [
("Fewer missed positives (Δ pickup)", f"{int(round(errors_reduced))} /mo"),
("Discrepant cases flagged (audit uplift)", f"{int(round(discrepant_flags))} /mo"),
("Earlier cancers detected", f"{earlier_detections:.1f} /mo"),
("Recalls avoided", f"{int(round(recalls_avoided))} /mo"),
],
"bars": [
("Recall reduction", max(0.0, base_recall_rate - ai_recall_rate)),
("Pickup improvement", max(0.0, base_ppr - ai_ppr)),
]
},
"operational": {
"rows": [
("Hours saved / month", f"{hours_saved:.1f}"),
("Workload reduction", pct(workload_reduction_pct)),
("Approx. FTE-month saved", f"{fte_saved:.2f}"),
("Effective capacity increase", pct(capacity_increase_pct)),
]
},
"waterfall_monthly": [("Incremental revenue", incr_revenue_month), ("Incremental costs", -incr_costs_month), ("Operational value", ops_value_month)],
"annual_card": {
"incr_rev": incr_revenue_month * 12.0,
"incr_costs": incr_costs_month * 12.0,
"ops_value": ops_value_month * 12.0,
"net": net_impact_month * 12.0,
"roi_pct": roi_pct_annual,
"payback": months_to_payback,
},
"evidence": evidence,
}
# ---------- FFR-CT ----------
def compute_ffrct(
site_type: str,
monthly_eligible_ccta: float,
uptake_pct: float,
avg_time_to_decision_today_hours: float,
baseline_clinician_touch_min: float,
reimb_ccta: float, reimb_ffrct: float, reimb_ai_qpa: float, pct_billed_ai_qpa: float,
one_test_dx_pct: float, dec_unnec_ica_pct: float, more_likely_revasc_pct: float, revasc_prevalence_pct: float,
vendor_per_case_cost: float, platform_annual_cost: float, stress_test_cost: float,
bed_hour_value: float, clinician_hour_cost: float, ai_time_to_decision_min: float,
clinician_touch_reduction_pct: float, baseline_diag_ica_rate_pct: float, baseline_additional_testing_rate_pct: float,
sens_uptake_factor_pct: float, sens_dec_unnec_ica_factor_pct: float, sens_vendor_cost_factor_pct: float,
):
if site_type == "Hospital / Health System":
net_cost_per_diag_ica = 5000.0
elif site_type == "Imaging Center":
net_cost_per_diag_ica = 2000.0
else:
net_cost_per_diag_ica = 4000.0
monthly_eligible_ccta = clamp_nonneg(monthly_eligible_ccta)
uptake = safe_fraction(uptake_pct/100.0) * safe_fraction(sens_uptake_factor_pct/100.0)
annual_eligible = monthly_eligible_ccta * 12.0
annual_ai_cases = annual_eligible * uptake
pct_ai_qpa = safe_fraction(pct_billed_ai_qpa/100.0)
one_test_dx = safe_fraction(one_test_dx_pct/100.0)
dec_unnec_ica = safe_fraction(dec_unnec_ica_pct/100.0) * safe_fraction(sens_dec_unnec_ica_factor_pct/100.0)
more_likely_revasc = safe_fraction(more_likely_revasc_pct/100.0)
revasc_prev = safe_fraction(revasc_prevalence_pct/100.0)
vendor_cost = float(vendor_per_case_cost) * safe_fraction(sens_vendor_cost_factor_pct/100.0)
platform_annual_cost = float(platform_annual_cost)
stress_test_cost = float(stress_test_cost)
# Baseline (annual)
baseline_revenue = annual_eligible * reimb_ccta
baseline_additional_tests = annual_eligible * safe_fraction(baseline_additional_testing_rate_pct/100.0)
baseline_additional_tests_cost = baseline_additional_tests * stress_test_cost
baseline_diag_ica_total = annual_eligible * safe_fraction(baseline_diag_ica_rate_pct/100.0)
baseline_revasc_true = annual_eligible * revasc_prev
baseline_unnecessary_ica = max(0.0, baseline_diag_ica_total - baseline_revasc_true)
baseline_unnecessary_ica_cost = baseline_unnecessary_ica * net_cost_per_diag_ica
baseline_ops_value = 0.0
baseline_costs = baseline_additional_tests_cost + baseline_unnecessary_ica_cost
# With AI (annual)
with_ai_revenue = (
annual_eligible * reimb_ccta
+ annual_ai_cases * reimb_ffrct
+ annual_ai_cases * pct_ai_qpa * reimb_ai_qpa
)
with_ai_vendor_costs = annual_ai_cases * vendor_cost
with_ai_platform_costs = platform_annual_cost
baseline_addl_tests_in_ai_cohort = annual_ai_cases * safe_fraction(baseline_additional_testing_rate_pct/100.0)
with_ai_additional_tests = annual_ai_cases * (1.0 - one_test_dx)
with_ai_additional_tests_cost = with_ai_additional_tests * stress_test_cost
avoided_additional_tests = max(0.0, baseline_addl_tests_in_ai_cohort - with_ai_additional_tests)
avoided_unnec_ica = baseline_unnecessary_ica * dec_unnec_ica * (annual_ai_cases / annual_eligible if annual_eligible > 0 else 0.0)
with_ai_unnecessary_ica = max(0.0, baseline_unnecessary_ica - avoided_unnec_ica)
with_ai_unnecessary_ica_cost = with_ai_unnecessary_ica * net_cost_per_diag_ica
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)))
bed_hours_saved = annual_ai_cases * ai_saved_hours_per_case
bed_hours_value = bed_hours_saved * bed_hour_value
clinician_hours_saved = annual_ai_cases * max(0.0, float(baseline_clinician_touch_min)/60.0) * safe_fraction(clinician_touch_reduction_pct/100.0)
clinician_hours_value = clinician_hours_saved * clinician_hour_cost
with_ai_ops_value = bed_hours_value + clinician_hours_value
with_ai_costs = with_ai_vendor_costs + with_ai_platform_costs + with_ai_additional_tests_cost + with_ai_unnecessary_ica_cost
incr_revenue = with_ai_revenue - baseline_revenue
incr_costs = with_ai_costs - baseline_costs
incr_ops = with_ai_ops_value - baseline_ops_value
net_impact = incr_revenue - incr_costs + incr_ops
ai_program_costs = with_ai_vendor_costs + with_ai_platform_costs
roi_pct_val = net_impact / max(1e-6, ai_program_costs)
per_case_net_impact = (net_impact / annual_ai_cases) if annual_ai_cases > 0 else 0.0
cases_to_payback = (ai_program_costs / max(1e-6, per_case_net_impact)) if per_case_net_impact > 0 else math.inf
months_to_payback = (cases_to_payback / (monthly_eligible_ccta * uptake)) if (monthly_eligible_ccta * uptake) > 0 else math.inf
evidence = """
<ul class='evidence'>
<li>Selective FFR-CT strategies reduce unnecessary ICAs and extra tests in multiple trials.</li>
<li>One-test diagnosis streamlines workups and may shorten time-to-decision.</li>
<li>Operational value modeled via bed-hour and clinician-time savings.</li>
</ul>
"""
return {
"summary": f"For your program with {int(annual_ai_cases):,} AI cases/year, modeled net impact is {usd(net_impact)} annually.",
"financial": {
"rows": [
("Incremental revenue (annual)", usd(incr_revenue)),
("Incremental costs (annual)", usd(incr_costs)),
("Operational value (annual)", usd(incr_ops)),
("AI program costs (annual)", usd(ai_program_costs)),
("Net impact (annual)", f"<b>{usd(net_impact)}</b>"),
("ROI % (annual on AI program)", f"<b>{roi_pct_val*100:.1f}%</b>"),
("Months to payback", f"<b>{'∞' if months_to_payback==math.inf else f'{months_to_payback:.1f}'}</b>"),
]
},
"clinical": {
"rows": [
("Avoided unnecessary ICAs (est.)", f"{int(round(avoided_unnec_ica)):,} /yr"),
("One-test diagnosis rate (AI cohort)", f"{one_test_dx*100:.0f}%"),
("Added revasc candidates (est.)", f"{int(round(annual_eligible * revasc_prev * uptake * more_likely_revasc)):,} /yr"),
("Avoided extra tests (est.)", f"{int(round(avoided_additional_tests)):,} /yr"),
],
"bars": [
("Unnecessary ICA reduction", dec_unnec_ica),
("One-test diagnosis", one_test_dx),
]
},
"operational": {
"rows": [
("Avg hours saved per case", f"{ai_saved_hours_per_case:.2f}"),
("Bed-hours saved", f"{int(round(bed_hours_saved)):,} hrs/yr"),
("Value of bed-hours", usd(bed_hours_value)),
("Clinician hours saved", f"{int(round(clinician_hours_saved)):,} hrs/yr"),
("Value of clinician time", usd(clinician_hours_value)),
]
},
"waterfall_annual": [("Incremental revenue", incr_revenue), ("Incremental costs", -incr_costs), ("Operational value", incr_ops)],
"annual_card": {
"incr_rev": incr_revenue,
"incr_costs": incr_costs,
"ops_value": incr_ops,
"net": net_impact,
"roi_pct": roi_pct_val,
"payback": months_to_payback,
},
"evidence": evidence,
}
# ---------- MSK (ER/Trauma) ----------
def compute_msk(
scans_per_day: float,
reading_time_min: float,
er_time_to_treatment_min: float,
radiologist_hourly_cost: float = 180.0,
):
scans_per_month = clamp_nonneg(scans_per_day) * 30.0
# Clinical volumes (illustrative)
errors_reduced_per_month = int(scans_per_month * 0.05 * 0.20) # incidence × improvement
discrepant_cases_flagged = int(scans_per_month * 0.05)
hrs_saved_per_visit = max(0.0, er_time_to_treatment_min) * 0.50 / 60.0
# Operational value
time_saved_per_scan_min = max(0.0, reading_time_min) * 0.30
total_time_saved_hours = (scans_per_month * time_saved_per_scan_min) / 60.0
value_time_saved_month = total_time_saved_hours * radiologist_hourly_cost
radiologist_cost_savings = scans_per_month * 4.0 # conservative proxy
ops_value_month = value_time_saved_month + radiologist_cost_savings
# Financial: by default not counted
incr_revenue_month = 0.0
incr_costs_month = 0.0
net_impact_month = incr_revenue_month - incr_costs_month + ops_value_month
evidence = """
<ul class='evidence'>
<li>Faster ED triage modeled via shorter time-to-treatment and reduced radiologist touch time.</li>
<li>Audit flags approximate discrepancy capture for QA workflows.</li>
<li>Staff time savings converted to $ value at radiologist $/hr.</li>
</ul>
"""
# Conditional rows (hide zeros)
fin_rows = []
if incr_revenue_month != 0:
fin_rows.append(("Incremental revenue (mo)", usd(incr_revenue_month)))
if incr_costs_month != 0:
fin_rows.append(("Incremental costs (mo)", usd(incr_costs_month)))
fin_rows.append(("Net impact (mo)", f"<b>{usd(net_impact_month)}</b>"))
clin_rows = []
if errors_reduced_per_month > 0:
clin_rows.append(("Errors reduced (est.)", f"{errors_reduced_per_month} /mo"))
if discrepant_cases_flagged > 0:
clin_rows.append(("Discrepant cases flagged (est.)", f"{discrepant_cases_flagged} /mo"))
if hrs_saved_per_visit > 0:
clin_rows.append(("Hours saved per ED visit (modeled)", f"{hrs_saved_per_visit:.2f}"))
op_rows = []
if total_time_saved_hours > 0:
op_rows.append(("Radiologist hours saved / month", f"{total_time_saved_hours:.1f}"))
if value_time_saved_month > 0:
op_rows.append(("Value of radiologist time saved (mo)", usd(value_time_saved_month)))
if radiologist_cost_savings > 0:
op_rows.append(("Radiologist cost proxy savings (mo)", usd(radiologist_cost_savings)))
# Waterfall (monthly), include only nonzero components
wf_rows = []
if incr_revenue_month != 0:
wf_rows.append(("Incremental revenue", incr_revenue_month))
if incr_costs_month != 0:
wf_rows.append(("Incremental costs", -incr_costs_month))
if ops_value_month != 0:
wf_rows.append(("Operational value", ops_value_month))
if not wf_rows:
wf_rows = [("Operational value", ops_value_month)]
annual_card = {
"incr_rev": incr_revenue_month * 12.0,
"incr_costs": incr_costs_month * 12.0,
"ops_value": ops_value_month * 12.0,
"net": net_impact_month * 12.0,
"roi_pct": None, # hidden
"payback": None, # hidden
}
return {
"summary": f"For your ED with ~{int(scans_per_month):,} MSK scans/month, modeled net benefit is {usd(net_impact_month)} per month.",
"financial": {"rows": fin_rows},
"clinical": {"rows": clin_rows, "bars": [("Touch-time reduction", 0.30)] if time_saved_per_scan_min > 0 else []},
"operational": {"rows": op_rows},
"waterfall_monthly": wf_rows,
"annual_card": annual_card,
"evidence": evidence,
}
# ---------- Card / HTML builders ----------
def build_overall_card(title: str, summary_line: str, annual: dict):
rows = []
if "incr_rev" in annual and annual["incr_rev"] != 0:
rows.append(("Incremental revenue (annual)", f"<b>{usd(annual['incr_rev'])}</b>"))
if "incr_costs" in annual and annual["incr_costs"] != 0:
rows.append(("Incremental costs (annual)", f"<b class='neg'>{usd(annual['incr_costs'])}</b>"))
if "ops_value" in annual and annual["ops_value"] != 0:
rows.append(("Operational value (annual)", f"<b>{usd(annual['ops_value'])}</b>"))
if "net" in annual:
rows.append(("Net impact (annual)", f"<b>{usd(annual['net'])}</b>"))
roi = annual.get("roi_pct", None)
if isinstance(roi, (int, float)) and math.isfinite(roi):
rows.append(("ROI %", f"<b>{roi*100:.1f}%</b>"))
payback = annual.get("payback", None)
if isinstance(payback, (int, float)) and math.isfinite(payback):
rows.append(("Months to payback", f"<b>{payback:.1f}</b>"))
items = "".join(f"<div>{lab}</div><div>{val}</div>" for lab, val in rows)
return f"""
<div class='card'>
<div style='display:flex;justify-content:space-between;align-items:center;margin-bottom:8px'>
<div style='font-weight:700'>{title}</div>
<div class='pill'>Clinical · Financial · Operational</div>
</div>
<div class='sumline'>{summary_line}</div>
<div class='kpi-grid'>{items}</div>
</div>
"""
def build_rows_card(title: str, rows):
items = "".join(f"<div>{lab}</div><div>{val}</div>" for lab, val in rows)
return f"<div class='card'><div class='card-title'>{title}</div><div class='kpi-grid'>{items}</div></div>"
def build_clinical_card(rows, bars):
rows_html = "".join(f"<div>{lab}</div><div>{val}</div>" for lab, val in rows)
bars_html = ""
for lab, frac in (bars or []):
frac = max(0.0, min(1.0, float(frac)))
if frac <= 0:
continue
bars_html += f"""
<div class='bar-row'>
<div>{lab}</div>
<div class='bar'><span style='width:{frac*100:.1f}%'><em>{frac*100:.1f}%</em></span></div>
<div>{frac*100:.1f}%</div>
</div>"""
bars_section = f"<div class='bars'>{bars_html}</div>" if bars_html else ""
return f"<div class='card'><div class='card-title'>Clinical</div><div class='kpi-grid'>{rows_html}</div>{bars_section}</div>"
def build_waterfall(wf_rows, period_label="Annual"):
"""
wf_rows: list of (label, value) where value is signed.
Colors: positive=green, negative=red (costs).
"""
# remove exact zero bars
wf_rows = [(l, v) for (l, v) in wf_rows if abs(float(v)) > 1e-9]
if not wf_rows:
return "<div class='card'><div class='card-title'>Waterfall ({})</div><div>—</div></div>".format(period_label)
denom = sum(abs(v) for _, v in wf_rows) or 1.0
def row(label, val):
width = min(100, max(2, int(abs(val)/denom * 100)))
cls = "wf-pos" if val >= 0 else "wf-neg"
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>"
net_total = sum(v for _, v in wf_rows)
return "<div class='card'><div class='card-title'>Waterfall ({})</div>{}<div class='wf-total'>Net impact: <b>{}</b></div></div>".format(
period_label, "".join(row(l, v) for l, v in wf_rows), usd(net_total)
)
def rows_to_csv(fin_rows, clin_rows, op_rows) -> str:
all_rows = [("Section","—")] + [("Financial","—")] + fin_rows + [("Clinical","—")] + clin_rows + [("Operational","—")] + op_rows
return write_csv(all_rows, title="roi_results")
# ---------- UI ----------
def build_ui():
with gr.Blocks(theme=gr.themes.Soft(), css="""
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap');
* { font-family: Inter, ui-sans-serif, system-ui; }
.gradio-container { max-width: 1120px !important; }
.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}
.card-title{font-weight:700;margin-bottom:6px}
.pill{background:#ecfdf5;color:#065f46;padding:4px 10px;border-radius:999px;font-weight:700;font-size:.75rem}
.kpi-grid{display:grid;grid-template-columns:1fr auto;gap:6px 12px}
.neg{color:#b91c1c}
.sumline{margin-bottom:8px;opacity:.9}
.small-note{opacity:.75;font-size:.9em;margin-top:6px}
.bars .bar-row{display:grid;grid-template-columns:1fr auto auto;gap:10px;align-items:center;margin:6px 0}
.bars .bar{height:12px;background:#f1f5f9;border-radius:999px;position:relative;overflow:hidden;width:100%}
.bars .bar span{display:block;height:100%;background:linear-gradient(90deg,#14b8a6,#22d3ee);position:relative}
.bars .bar span em{position:absolute;right:6px;top:-18px;font-size:.85em;opacity:.8}
.wf-row{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center;margin:8px 0}
.wf-bar{height:22px;border-radius:6px;display:flex;align-items:center;justify-content:flex-end;padding-right:8px;color:#0b1727;min-width:80px}
.wf-bar .wf-val{font-weight:700}
.wf-pos{background:linear-gradient(90deg,#a7f3d0,#34d399)} /* green */
.wf-neg{background:linear-gradient(90deg,#fecaca,#f87171)} /* red for costs/negatives */
.wf-total{margin-top:8px;border-top:1px solid #e5e7eb;padding-top:8px;font-weight:700}
.cta{display:flex;justify-content:space-between;align-items:center}
.cta-btn{background:#0ea5e9;color:#fff;text-decoration:none;padding:10px 14px;border-radius:12px;font-weight:700}
.header{display:flex;justify-content:space-between;align-items:center}
.header .title{font-weight:800;font-size:1.1rem}
.header .pill{margin-left:8px}
/* Use-case chooser as cards */
#uc [role="radiogroup"] {
display: grid;
grid-template-columns: repeat(3, minmax(0,1fr));
gap: 12px;
}
@media (max-width: 820px) { #uc [role="radiogroup"] { grid-template-columns: 1fr; } }
#uc [role="radiogroup"] label {
border: 1px solid #eef2f7;
border-radius: 16px;
background: #fff;
padding: 14px 16px;
box-shadow: 0 8px 24px rgba(0,0,0,.06);
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
transition: transform .06s ease, box-shadow .12s ease, border-color .12s ease;
}
#uc [role="radiogroup"] input[type="radio"] { accent-color: #14b8a6; }
#uc [role="radiogroup"] label:hover { transform: translateY(-1px); box-shadow: 0 12px 28px rgba(0,0,0,.10); }
#uc [role="radiogroup"] input[type="radio"]:checked + span { font-weight: 700; }
#uc .uc-hint { margin-top: 6px; opacity: .7; font-size: .9em; }
""") as demo:
gr.Markdown("""
<div class='header'>
<div class='title'>CARPL ROI Calculator · Multi-Use-Case</div>
<div class='pill'>Clinical · Financial · Operational</div>
</div>
""")
with gr.Row():
with gr.Column(scale=1):
# Use case chooser
with gr.Column(elem_id="uc"):
use_case = gr.Radio(
choices=[
"🩺 Mammography AI (MMG)",
"❤️‍🩹 FFR-CT AI",
"🦴 MSK AI (ER/Trauma)"
],
value="🩺 Mammography AI (MMG)",
label="Choose your use case",
interactive=True
)
period = gr.Radio(
choices=["Annual", "Monthly"],
value="Annual",
label="Reporting period",
interactive=True
)
gr.Markdown("<div class='uc-hint'>Pick a calculator and reporting period. You can switch anytime. Click the 'Calculate' button when you are ready! </div>")
# --- MMG Inputs ---
vendor_preset = gr.Dropdown(
choices=list(MMG_VENDOR_PRESETS.keys()),
value="Custom",
label="MMG vendor preset",
info="Prefills hidden sensitivity. You can still override in Sensitivity.",
visible=True
)
mmg_monthly_volume = gr.Slider(0, 20000, 7500, step=50, label="Monthly volume (MMG)", info="Mammography exams per month", visible=True)
mmg_read_minutes = gr.Number(label="Avg reading time today (minutes)", value=1.7, visible=True)
mmg_rdx_hr_cost = gr.Number(label="Radiologist cost (USD/hr)", value=180, visible=True)
with gr.Accordion("Sensitivity (MMG)", open=False, visible=True) as mmg_sens:
mmg_base_ppr = gr.Slider(0, 1, value=0.10, step=0.001, label="Baseline positive pickup rate")
mmg_ai_ppr = gr.Slider(0, 1, value=0.095, step=0.001, label="With-AI positive pickup rate")
mmg_base_audit = gr.Slider(0, 1, value=0.00, step=0.001, label="Baseline audit flag rate")
mmg_ai_audit = gr.Slider(0, 1, value=0.05, step=0.001, label="With-AI audit flag rate")
mmg_base_recall = gr.Slider(0, 1, value=0.028, step=0.001, label="Baseline recall rate")
mmg_ai_recall = gr.Slider(0, 1, value=0.025, step=0.001, label="With-AI recall rate")
mmg_recall_cost = gr.Number(value=250.0, label="Cost per recall case (USD)")
mmg_read_redux = gr.Slider(0, 0.8, value=0.15, step=0.005, label="Reading time reduction with AI (fraction)")
mmg_cost_per_scan = gr.Number(value=15, label="Radiologist cost per scan (USD)")
mmg_cost_redux = gr.Slider(0, 0.8, value=0.15, step=0.005, label="Per-scan radiologist cost reduction")
mmg_follow_price = gr.Number(value=200, label="Price per follow-up scan (USD)")
mmg_follow_uplift = gr.Slider(0, 0.2, value=0.0095, step=0.0005, label="Follow-up uplift (fraction of AI scans)")
mmg_early_uplift_per_1000 = gr.Number(value=0.7, label="Earlier cancers detected (+/1000 AI scans)")
mmg_tx_delta = gr.Number(value=15000.0, label="Treatment cost savings per earlier case (USD)")
mmg_vendor_fee = gr.State(2.5)
mmg_platform_annual = gr.State(12000.0)
mmg_integration_mo = gr.State(0.0)
mmg_cloud_mo = gr.State(0.0)
# --- FFR-CT Inputs ---
f_site = gr.Dropdown(["Hospital / Health System","Imaging Center","Academic Medical Center"], value="Hospital / Health System", label="Site type (FFR-CT)", visible=False)
f_monthly_eligible = gr.Slider(0, 5000, 100, step=10, label="Monthly eligible CCTA", visible=False)
f_uptake = gr.Slider(0, 100, 60, step=1, label="Uptake (%)", visible=False)
f_ttd_hours = gr.Number(label="Avg time-to-decision today (hours)", value=8, visible=False)
f_touch_min = gr.Number(label="Clinician touch-time per case (min)", value=30, visible=False)
with gr.Accordion("Assumptions (FFR-CT)", open=False, visible=False) as f_assump:
f_reimb_ccta = gr.Number(value=400, label="CCTA reimbursement (USD)")
f_reimb_ffr = gr.Number(value=1017, label="FFR-CT reimbursement (USD)")
f_reimb_aiqpa= gr.Number(value=950, label="AI-QPA reimbursement (USD)")
f_pct_aiqpa = gr.Slider(0, 100, 60, step=1, label="% billed AI-QPA")
f_one_test = gr.Slider(0, 100, 97, step=1, label="One-test Dx (%)")
f_dec_unnec_ica = gr.Slider(0, 100, 69, step=1, label="Unnecessary ICA reduction (%)")
f_more_revasc = gr.Slider(0, 100, 78, step=1, label="More likely revasc (%)")
f_revasc_prev = gr.Slider(0, 100, 10, step=1, label="Revasc prevalence (%)")
f_vendor_cost = gr.Number(value=350, label="Vendor per-case cost (USD)")
f_platform_annual = gr.Number(value=12000, label="Platform annual (USD)")
f_stress_cost = gr.Number(value=400, label="Non-invasive test cost (USD)")
f_bed_hr_val = gr.Number(value=100, label="Bed-hour value (USD)")
f_clin_hr_cost = gr.Number(value=150, label="Clinician hr cost (USD)")
f_ai_ttd_min = gr.Number(value=90, label="AI time-to-decision saved (min)")
f_touch_redux = gr.Slider(0, 100, 30, step=1, label="Clinician touch reduction (%)")
f_base_diag_ica = gr.Slider(0, 100, 30, step=1, label="Baseline diagnostic ICA rate (%)")
f_base_addl_test= gr.Slider(0, 100, 30, step=1, label="Baseline additional testing rate (%)")
f_sens_uptake = gr.Slider(0, 200, 100, step=5, label="Sensitivity: Uptake factor (%)")
f_sens_dec_ica = gr.Slider(0, 200, 100, step=5, label="Sensitivity: ICA reduction factor (%)")
f_sens_vendor = gr.Slider(0, 200, 100, step=5, label="Sensitivity: Vendor cost factor (%)")
# --- MSK Inputs ---
msk_scans_day = gr.Number(label="Scans per day (MSK)", value=100, visible=False)
msk_read_min = gr.Number(label="Radiologist time per scan (min)", value=3, visible=False)
msk_er_ttt_min= gr.Number(label="ED time to treatment (min)", value=60, visible=False)
msk_rdx_hr_cost = gr.State(180.0)
run_btn = gr.Button("Calculate", variant="primary")
with gr.Column(scale=1):
overall_card = gr.HTML()
with gr.Tabs():
with gr.Tab("Financial"):
financial_card = gr.HTML()
with gr.Tab("Clinical"):
clinical_card = gr.HTML()
with gr.Tab("Operational"):
operational_card = gr.HTML()
waterfall_panel = gr.HTML()
evidence_panel = gr.HTML()
cta_panel = gr.HTML(visible=False)
csv_file = gr.File(label="Download CSV", visible=False)
def normalize_uc(uclabel: str) -> str:
if "Mammography" in uclabel: return USE_CASES[0]
if "FFR" in uclabel: return USE_CASES[1]
return USE_CASES[2]
def _on_use_case_change(uc_label):
uc = normalize_uc(uc_label)
mmg_vis = (uc == USE_CASES[0])
f_vis = (uc == USE_CASES[1])
msk_vis = (uc == USE_CASES[2])
return (
gr.update(visible=mmg_vis), # vendor preset
gr.update(visible=mmg_vis), gr.update(visible=mmg_vis), gr.update(visible=mmg_vis),
gr.update(visible=mmg_vis), # accordion
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),
gr.update(visible=f_vis), # accordion
gr.update(visible=msk_vis), gr.update(visible=msk_vis), gr.update(visible=msk_vis),
)
use_case.change(
_on_use_case_change,
inputs=[use_case],
outputs=[
vendor_preset,
mmg_monthly_volume, mmg_read_minutes, mmg_rdx_hr_cost, mmg_sens,
f_site, f_monthly_eligible, f_uptake, f_ttd_hours, f_touch_min, f_assump,
msk_scans_day, msk_read_min, msk_er_ttt_min
],
)
def _apply_vendor_preset(preset_name,
base_ppr, ai_ppr, base_audit, ai_audit, base_recall, ai_recall,
read_redux, follow_uplift, early_uplift):
p = MMG_VENDOR_PRESETS.get(preset_name, {})
return (
gr.update(value=p.get("base_ppr", base_ppr)),
gr.update(value=p.get("ai_ppr", ai_ppr)),
gr.update(value=p.get("base_audit_rate", base_audit)),
gr.update(value=p.get("ai_audit_rate", ai_audit)),
gr.update(value=p.get("base_recall_rate", base_recall)),
gr.update(value=p.get("ai_recall_rate", ai_recall)),
gr.update(value=p.get("read_reduction_pct", read_redux)),
gr.update(value=p.get("followup_uplift_pct", follow_uplift)),
gr.update(value=p.get("early_detect_uplift_per_1000", early_uplift)),
)
vendor_preset.change(
_apply_vendor_preset,
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],
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],
)
def _compute(
uclabel, period_sel,
# MMG
mv, rm, rhr,
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,
v_fee, p_annual, integ_mo, cloud_mo,
# FFR-CT
s_type, ccta_mo, uptake, ttd_h, touch_min,
r_ccta, r_ffr, r_aiqpa, pct_aiqpa, one_test, dec_ica, more_revasc, revasc_prev, v_per_case, p_ann, stress_cost, bed_hr_val, clin_hr_cost, ai_ttd_min, touch_redux, base_diag_ica, base_addl_test, sens_upt, sens_dec, sens_vendor,
# MSK
msk_day, msk_read, msk_ttt, msk_rhr,
):
uc = normalize_uc(uclabel)
annual = (period_sel == "Annual")
if uc == USE_CASES[0]:
res = compute_mmg(
mv, rm, rhr,
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,
v_fee, p_annual, integ_mo, cloud_mo,
)
title = "Overall Impact — Mammography AI"
# Waterfall source (monthly), scale if annual
wf = res["waterfall_monthly"]
wf = [(l, v*12.0) for (l, v) in wf] if annual else wf
elif uc == USE_CASES[1]:
res = compute_ffrct(
s_type, ccta_mo, uptake, ttd_h, touch_min,
r_ccta, r_ffr, r_aiqpa, pct_aiqpa, one_test, dec_ica, more_revasc, revasc_prev,
v_per_case, p_ann, stress_cost, bed_hr_val, clin_hr_cost, ai_ttd_min, touch_redux, base_diag_ica, base_addl_test,
sens_upt, sens_dec, sens_vendor,
)
title = "Overall Impact — FFR-CT AI"
# Waterfall source (annual), scale if monthly
wf = res["waterfall_annual"]
wf = [(l, v/12.0) for (l, v) in wf] if not annual else wf
else:
res = compute_msk(msk_day, msk_read, msk_ttt, msk_rhr)
title = "Overall Impact — MSK AI"
wf = res["waterfall_monthly"]
wf = [(l, v*12.0) for (l, v) in wf] if annual else wf
overall_html = build_overall_card(title, res["summary"], res["annual_card"])
financial_html = build_rows_card("Financial", res["financial"]["rows"])
clinical_html = build_clinical_card(res["clinical"]["rows"], res["clinical"].get("bars"))
operational_html = build_rows_card("Operational", res["operational"]["rows"])
water = build_waterfall(wf, period_label=("Annual" if annual else "Monthly"))
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>"
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>"
# CSV export
fin_rows = [(lab, val) for lab, val in res["financial"]["rows"]]
clin_rows = [(lab, val) for lab, val in res["clinical"]["rows"]]
op_rows = [(lab, val) for lab, val in res["operational"]["rows"]]
csv_path = rows_to_csv(fin_rows, clin_rows, op_rows)
return overall_html, financial_html, clinical_html, operational_html, water, evidence, gr.update(value=cta, visible=True), gr.update(value=csv_path, visible=True)
inputs = [
use_case, period,
# MMG inputs
mmg_monthly_volume, mmg_read_minutes, mmg_rdx_hr_cost,
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,
mmg_vendor_fee, mmg_platform_annual, mmg_integration_mo, mmg_cloud_mo,
# FFR-CT inputs
f_site, f_monthly_eligible, f_uptake, f_ttd_hours, f_touch_min,
f_reimb_ccta, f_reimb_ffr, f_reimb_aiqpa, f_pct_aiqpa, f_one_test, f_dec_unnec_ica, f_more_revasc, f_revasc_prev, f_vendor_cost, f_platform_annual, f_stress_cost, f_bed_hr_val, f_clin_hr_cost, f_ai_ttd_min, f_touch_redux, f_base_diag_ica, f_base_addl_test, f_sens_uptake, f_sens_dec_ica, f_sens_vendor,
# MSK inputs
msk_scans_day, msk_read_min, msk_er_ttt_min, msk_rdx_hr_cost,
]
outputs = [overall_card, financial_card, clinical_card, operational_card, waterfall_panel, evidence_panel, cta_panel, csv_file]
run_btn.click(_compute, inputs=inputs, outputs=outputs)
demo.load(_compute, inputs=inputs, outputs=outputs)
return demo
def main():
return build_ui()
if __name__ == "__main__":
app = build_ui()
app.launch()