Spaces:
Sleeping
Sleeping
| # 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() |