SChodavarpu commited on
Commit
22746f8
·
verified ·
1 Parent(s): fdd1b8c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +602 -141
app.py CHANGED
@@ -1,145 +1,606 @@
 
 
1
  import gradio as gr
2
- import pandas as pd
3
- import plotly.graph_objects as go
4
-
5
- # ===== Baseline Constants =====
6
- REIMBURSEMENT_FFRCT = 1017
7
- REIMBURSEMENT_AI_QPA = 950
8
- PCT_BILLED_AI_QPA = 0.60
9
- UPTAKE_DEFAULT = 0.60
10
- ONE_TEST_DIAGNOSIS = 0.97
11
- UNNECESSARY_ICA_REDUCTION = 0.69
12
- MORE_LIKELY_REVASC = 0.78
13
- REVASC_PREVALENCE = 0.10
14
-
15
- VENDOR_COST_PER_CASE = 350
16
- PLATFORM_COST_ANNUAL = 12000 # Updated to annual fee
17
- STRESS_TEST_COST = 400
18
- NET_ICA_COST = 5000
19
-
20
- BED_HOUR_VALUE = 100
21
- CLINICIAN_HOUR_COST = 150
22
- TOUCH_TIME_REDUCTION_PCT = 0.30
23
- AI_TIME_TO_DECISION_MIN = 90
24
-
25
- # ===== Helper Calculations =====
26
- def calculate_roi(eligible_cases, uptake, time_to_decision, clinician_touch_time):
27
- ai_cases = eligible_cases * uptake
28
-
29
- # Financial
30
- ai_revenue_per_case = REIMBURSEMENT_FFRCT + (REIMBURSEMENT_AI_QPA * PCT_BILLED_AI_QPA)
31
- baseline_revenue_per_case = REIMBURSEMENT_FFRCT
32
- incr_revenue = ai_cases * (ai_revenue_per_case - baseline_revenue_per_case)
33
-
34
- total_ai_costs = (VENDOR_COST_PER_CASE * ai_cases) + PLATFORM_COST_ANNUAL
35
- incr_costs = total_ai_costs
36
-
37
- # Operational
38
- time_saved_per_case_hr = (time_to_decision - (AI_TIME_TO_DECISION_MIN)) / 60
39
- bed_hours_saved = max(time_saved_per_case_hr, 0) * ai_cases
40
- clinician_time_saved_hr = (clinician_touch_time * TOUCH_TIME_REDUCTION_PCT) / 60 * ai_cases
41
- ops_value = (bed_hours_saved * BED_HOUR_VALUE) + (clinician_time_saved_hr * CLINICIAN_HOUR_COST)
42
-
43
- # Clinical
44
- icas_avoided = ai_cases * UNNECESSARY_ICA_REDUCTION
45
- extra_tests_avoided = ai_cases * (1 - ONE_TEST_DIAGNOSIS)
46
- revacs_identified = ai_cases * REVASC_PREVALENCE * MORE_LIKELY_REVASC
47
-
48
- # Net + ROI
49
- net_impact = incr_revenue - incr_costs + ops_value
50
- roi_pct = (net_impact / total_ai_costs) * 100 if total_ai_costs > 0 else 0
51
- net_monthly_impact = net_impact / 12
52
- payback_months = total_ai_costs / net_monthly_impact if net_monthly_impact > 0 else None
53
-
54
- return {
55
- "ai_cases": ai_cases,
56
- "incr_revenue": incr_revenue,
57
- "incr_costs": incr_costs,
58
- "ops_value": ops_value,
59
- "net_impact": net_impact,
60
- "roi_pct": roi_pct,
61
- "payback_months": payback_months,
62
- "icas_avoided": icas_avoided,
63
- "extra_tests_avoided": extra_tests_avoided,
64
- "revacs_identified": revacs_identified,
65
- "bed_hours_saved": bed_hours_saved,
66
- "clinician_hours_saved": clinician_time_saved_hr
67
- }
68
-
69
- # ===== Visual Components =====
70
- def waterfall_chart(incr_revenue, incr_costs, ops_value, net_impact):
71
- fig = go.Figure(go.Waterfall(
72
- name="ROI",
73
- orientation="v",
74
- measure=["relative", "relative", "relative", "total"],
75
- x=["Incremental Revenue", "Operational Value", "Incremental Costs", "Net Impact"],
76
- text=[f"${incr_revenue:,.0f}", f"${ops_value:,.0f}", f"-${incr_costs:,.0f}", f"${net_impact:,.0f}"],
77
- y=[incr_revenue, ops_value, -incr_costs, net_impact],
78
- connector={"line": {"color": "rgb(63, 63, 63)"}}
79
- ))
80
- fig.update_layout(
81
- title="ROI Breakdown",
82
- showlegend=False,
83
- height=400,
84
- margin=dict(l=40, r=40, t=40, b=40)
85
- )
86
- return fig
87
-
88
- # ===== Main Function =====
89
- def run_calculator(eligible_cases, uptake, time_to_decision, clinician_touch_time):
90
- results = calculate_roi(eligible_cases, uptake, time_to_decision, clinician_touch_time)
91
-
92
- # Summary Text
93
- cta_text = (f"Based on your {results['ai_cases']:.0f} AI cases/yr: "
94
- f"net impact ${results['net_impact']:,.0f}, ROI {results['roi_pct']:.0f}%, "
95
- f"payback {results['payback_months']:.1f} mo; "
96
- f"avoids ~{results['icas_avoided']:.0f} ICAs, "
97
- f"~{results['extra_tests_avoided']:.0f} extra tests; "
98
- f"frees ~{results['bed_hours_saved']:.0f} bed-hrs, "
99
- f"~{results['clinician_hours_saved']:.0f} clinician-hrs annually.")
100
 
101
- return (
102
- f"${results['incr_revenue']:,.0f}",
103
- f"${results['incr_costs']:,.0f}",
104
- f"${results['ops_value']:,.0f}",
105
- f"${results['net_impact']:,.0f}",
106
- f"{results['roi_pct']:.0f}%",
107
- f"{results['payback_months']:.1f} months",
108
- waterfall_chart(results['incr_revenue'], results['incr_costs'], results['ops_value'], results['net_impact']),
109
- cta_text
110
- )
111
-
112
- # ===== Gradio UI =====
113
- custom_css = """
114
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
115
- * { font-family: 'Inter', sans-serif; }
116
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
- with gr.Blocks(css=custom_css, title="CARPL FFR-CT AI ROI Calculator") as demo:
119
- gr.Markdown("## CARPL FFR-CT AI ROI Calculator")
120
- gr.Markdown("Enter basic site metrics — we’ll calculate financial, clinical, and operational ROI.")
121
-
122
- with gr.Row():
123
- eligible_cases = gr.Number(label="Annual Eligible Cases", value=1200, info="Patients eligible for FFR-CT annually")
124
- uptake = gr.Slider(label="AI Uptake (%)", minimum=0, maximum=1, step=0.05, value=UPTAKE_DEFAULT, info="Fraction of eligible cases using AI")
125
- with gr.Row():
126
- time_to_decision = gr.Number(label="Avg Time to Decision Today (min)", value=180)
127
- clinician_touch_time = gr.Number(label="Avg Clinician Touch Time per Case (min)", value=30)
128
-
129
- with gr.Row():
130
- incr_revenue_out = gr.Textbox(label="Incremental Revenue", interactive=False)
131
- incr_costs_out = gr.Textbox(label="Incremental Costs", interactive=False)
132
- ops_value_out = gr.Textbox(label="Operational Value", interactive=False)
133
- net_impact_out = gr.Textbox(label="Net Impact", interactive=False)
134
- roi_pct_out = gr.Textbox(label="ROI (%)", interactive=False)
135
- payback_out = gr.Textbox(label="Payback (months)", interactive=False)
136
-
137
- waterfall_plot = gr.Plot()
138
- cta_output = gr.Markdown()
139
-
140
- run_btn = gr.Button("Calculate ROI", variant="primary")
141
- run_btn.click(run_calculator,
142
- inputs=[eligible_cases, uptake, time_to_decision, clinician_touch_time],
143
- outputs=[incr_revenue_out, incr_costs_out, ops_value_out, net_impact_out, roi_pct_out, payback_out, waterfall_plot, cta_output])
144
-
145
- demo.launch()
 
1
+ import math
2
+ from dataclasses import dataclass
3
  import gradio as gr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
+ CARPL ROI Calculator · FFR-CT AI
7
+ v4.5 — Polished UI/UX + Inter + Tooltips + Annual platform cost ($12k)
8
+
9
+ - Minimal inputs (4 simple fields). Assumptions hidden as internal state.
10
+ - Four output cards: Overall Impact, Financial, Clinical, Operational.
11
+ - Inter font, HTML waterfall with inline values, percent labels on mini bars.
12
+ - CSV only on click. No sticky UI. No external plotting deps.
13
+ - Platform cost is a one-time ANNUAL fee of $12,000 (not monthly).
14
+ """
15
+
16
+ # ------------------------- Defaults & Config -------------------------
17
+ @dataclass
18
+ class Defaults:
19
+ # Volumes
20
+ monthly_eligible_ccta: int = 100
21
+ uptake_pct: float = 60.0
22
+
23
+ # Site presets (net cost per diagnostic ICA; allow negative if profit-positive)
24
+ net_cost_per_diag_ica_hospital: float = 5000.0
25
+ net_cost_per_diag_ica_imaging_center: float = 2000.0
26
+ net_cost_per_diag_ica_amc: float = 4000.0
27
+
28
+ # Reimbursement (USD)
29
+ reimb_ccta: float = 400.0 # varies by region/payer
30
+ reimb_ffrct: float = 1017.0 # CMS 2025
31
+ reimb_ai_qpa: float = 950.0 # CMS 2025
32
+ pct_billed_ai_qpa: float = 60.0
33
+
34
+ # Clinical performance (evidence-based anchors)
35
+ one_test_dx_pct: float = 97.0 # 3% need additional testing with AI
36
+ dec_unnec_ica_pct: float = 69.0 # reduction in unnecessary diagnostic ICAs (scaled)
37
+ more_likely_revasc_pct: float = 78.0
38
+ revasc_prevalence_pct: float = 10.0
39
+
40
+ # Costs (USD)
41
+ vendor_per_case_cost: float = 350.0
42
+ platform_annual_cost: float = 12000.0 # <-- annual flat fee (one-time per year)
43
+ stress_test_cost: float = 400.0
44
+
45
+ # Ops economics (USD)
46
+ bed_hour_value: float = 100.0
47
+ clinician_hour_cost: float = 150.0
48
+
49
+ # Time effects
50
+ ai_time_to_decision_min: float = 90.0
51
+ clinician_touch_reduction_pct: float = 30.0
52
+
53
+ # Baseline (without AI)
54
+ baseline_diag_ica_rate_pct: float = 30.0
55
+ baseline_additional_testing_rate_pct: float = 30.0
56
+
57
+ DEFAULTS = Defaults()
58
+
59
+ SITE_TYPES = [
60
+ "Hospital / Health System",
61
+ "Imaging Center",
62
+ "Academic Medical Center",
63
+ ]
64
+
65
+ CTA_URL = "https://carpl.ai/contact-us" # swap to your CTA if you want
66
+ CTA_LABEL = "Book a 15-min walkthrough"
67
+
68
+ # ------------------------- Helpers -------------------------
69
+ def _pct(x): return max(0.0, min(100.0, float(x)))
70
+ def _pos(x): return max(0.0, float(x))
71
+
72
+ def tooltip(label: str, tip: str) -> str:
73
+ """Returns a label with a subtle tooltip (title attribute)."""
74
+ return (
75
+ f"<span style='display:inline-flex; align-items:center; gap:6px'>"
76
+ f"{label}"
77
+ f"<span title=\"{tip}\" style='cursor:help; opacity:.75'>ℹ︎</span>"
78
+ f"</span>"
79
+ )
80
+
81
+ # ------------------------- Core computation -------------------------
82
+ def compute_roi(
83
+ site_type,
84
+ monthly_eligible_ccta,
85
+ uptake_pct,
86
+ avg_time_to_decision_today_hours, # minimal input #1
87
+ baseline_clinician_touch_min, # minimal input #2
88
+ # Hidden assumptions (States)
89
+ reimb_ccta,
90
+ reimb_ffrct,
91
+ reimb_ai_qpa,
92
+ pct_billed_ai_qpa,
93
+ one_test_dx_pct,
94
+ dec_unnec_ica_pct,
95
+ more_likely_revasc_pct,
96
+ revasc_prevalence_pct,
97
+ vendor_per_case_cost,
98
+ platform_annual_cost, # <-- annual
99
+ stress_test_cost,
100
+ bed_hour_value,
101
+ clinician_hour_cost,
102
+ ai_time_to_decision_min,
103
+ clinician_touch_reduction_pct,
104
+ baseline_diag_ica_rate_pct,
105
+ baseline_additional_testing_rate_pct,
106
+ # Sensitivity
107
+ sens_uptake_factor_pct,
108
+ sens_dec_unnec_ica_factor_pct,
109
+ sens_vendor_cost_factor_pct,
110
+ ):
111
+ # Site-driven ICA economics
112
+ if site_type == SITE_TYPES[0]:
113
+ net_cost_per_diag_ica = DEFAULTS.net_cost_per_diag_ica_hospital
114
+ elif site_type == SITE_TYPES[1]:
115
+ net_cost_per_diag_ica = DEFAULTS.net_cost_per_diag_ica_imaging_center
116
+ else:
117
+ net_cost_per_diag_ica = DEFAULTS.net_cost_per_diag_ica_amc
118
+
119
+ # Sanitize + derive
120
+ monthly_eligible_ccta = _pos(monthly_eligible_ccta)
121
+ uptake = _pct(uptake_pct) / 100.0 * (_pct(sens_uptake_factor_pct) / 100.0)
122
+
123
+ reimb_ccta = float(reimb_ccta)
124
+ reimb_ffrct = float(reimb_ffrct)
125
+ reimb_ai_qpa = float(reimb_ai_qpa)
126
+ pct_billed_ai_qpa = _pct(pct_billed_ai_qpa) / 100.0
127
+
128
+ one_test_dx = _pct(one_test_dx_pct) / 100.0
129
+ need_addl_with_ai = 1.0 - one_test_dx
130
+
131
+ base_dec_unnec_ica = _pct(dec_unnec_ica_pct) / 100.0
132
+ dec_unnec_ica = base_dec_unnec_ica * (_pct(sens_dec_unnec_ica_factor_pct) / 100.0)
133
+
134
+ more_likely_revasc = _pct(more_likely_revasc_pct) / 100.0
135
+ revasc_prev = _pct(revasc_prevalence_pct) / 100.0
136
+
137
+ vendor_per_case_cost = float(vendor_per_case_cost) * (_pct(sens_vendor_cost_factor_pct) / 100.0)
138
+ platform_annual_cost = float(platform_annual_cost) # <-- annual fee (no scaling)
139
+ stress_test_cost = float(stress_test_cost)
140
+
141
+ bed_hour_value = float(bed_hour_value)
142
+ clinician_hour_cost = float(clinician_hour_cost)
143
+ ai_time_to_decision_min = _pos(ai_time_to_decision_min)
144
+ clinician_touch_reduction_pct = _pct(clinician_touch_reduction_pct) / 100.0
145
+
146
+ baseline_diag_ica_rate = _pct(baseline_diag_ica_rate_pct) / 100.0
147
+ baseline_additional_testing_rate = _pct(baseline_additional_testing_rate_pct) / 100.0
148
+
149
+ # Volumes
150
+ annual_eligible = monthly_eligible_ccta * 12.0
151
+ annual_uptake_cases = annual_eligible * uptake
152
+
153
+ # -------- Baseline (without AI) --------
154
+ baseline_revenue = annual_eligible * reimb_ccta
155
+
156
+ baseline_additional_tests = annual_eligible * baseline_additional_testing_rate
157
+ baseline_additional_tests_cost = baseline_additional_tests * stress_test_cost
158
+
159
+ baseline_diag_ica_total = annual_eligible * baseline_diag_ica_rate
160
+ baseline_revasc_true = annual_eligible * revasc_prev
161
+ baseline_unnecessary_ica = max(0.0, baseline_diag_ica_total - baseline_revasc_true)
162
+ baseline_unnecessary_ica_cost = baseline_unnecessary_ica * net_cost_per_diag_ica
163
+
164
+ baseline_ops_value = 0.0
165
+ baseline_costs = baseline_additional_tests_cost + baseline_unnecessary_ica_cost
166
+ baseline_net = baseline_revenue - baseline_costs + baseline_ops_value
167
+
168
+ # -------- With AI --------
169
+ with_ai_revenue = (
170
+ annual_eligible * reimb_ccta
171
+ + annual_uptake_cases * reimb_ffrct
172
+ + annual_uptake_cases * pct_billed_ai_qpa * reimb_ai_qpa
173
+ )
174
+
175
+ with_ai_vendor_costs = annual_uptake_cases * vendor_per_case_cost
176
+ with_ai_platform_costs = platform_annual_cost # <-- annual, one-time
177
+
178
+ # Additional testing (compare baseline rate vs one-test Dx for AI cohort only)
179
+ baseline_addl_tests_in_ai_cohort = annual_uptake_cases * baseline_additional_testing_rate
180
+ with_ai_additional_tests = annual_uptake_cases * need_addl_with_ai
181
+ with_ai_additional_tests_cost = with_ai_additional_tests * stress_test_cost
182
+ avoided_additional_tests = max(0.0, baseline_addl_tests_in_ai_cohort - with_ai_additional_tests)
183
+
184
+ # Unnecessary diagnostic ICAs
185
+ avoided_unnec_ica = baseline_unnecessary_ica * dec_unnec_ica * (annual_uptake_cases / annual_eligible if annual_eligible > 0 else 0)
186
+ with_ai_unnecessary_ica = max(0.0, baseline_unnecessary_ica - avoided_unnec_ica)
187
+ with_ai_unnecessary_ica_cost = with_ai_unnecessary_ica * net_cost_per_diag_ica
188
+
189
+ # Revasc candidate identification (est., scaled by prevalence & uptake)
190
+ addl_revasc_candidates = annual_eligible * revasc_prev * uptake * more_likely_revasc
191
+
192
+ # Ops value (from just 2 inputs)
193
+ avg_time_to_decision_today_hours = _pos(avg_time_to_decision_today_hours)
194
+ baseline_clinician_touch_min = _pos(baseline_clinician_touch_min)
195
+
196
+ ai_saved_hours_per_case = min(ai_time_to_decision_min / 60.0, avg_time_to_decision_today_hours)
197
+ bed_hours_saved = annual_uptake_cases * ai_saved_hours_per_case
198
+ bed_hours_value = bed_hours_saved * bed_hour_value
199
+
200
+ clinician_hours_saved = annual_uptake_cases * (baseline_clinician_touch_min / 60.0) * clinician_touch_reduction_pct
201
+ clinician_hours_value = clinician_hours_saved * clinician_hour_cost
202
+
203
+ with_ai_ops_value = bed_hours_value + clinician_hours_value
204
+
205
+ with_ai_costs = (
206
+ with_ai_vendor_costs
207
+ + with_ai_platform_costs
208
+ + with_ai_additional_tests_cost
209
+ + with_ai_unnecessary_ica_cost
210
+ )
211
+
212
+ with_ai_net = with_ai_revenue - with_ai_costs + with_ai_ops_value
213
+
214
+ # -------- Incremental --------
215
+ incr_revenue = with_ai_revenue - baseline_revenue
216
+ incr_costs = with_ai_costs - baseline_costs
217
+ incr_ops_value = with_ai_ops_value - baseline_ops_value
218
+
219
+ net_impact = incr_revenue - incr_costs + incr_ops_value
220
+ ai_program_costs = with_ai_vendor_costs + with_ai_platform_costs # <-- includes $12k annual
221
+ roi_pct = (net_impact / ai_program_costs * 100.0) if ai_program_costs > 0 else 0.0
222
+
223
+ per_case_net_impact = (net_impact / annual_uptake_cases) if annual_uptake_cases > 0 else 0.0
224
+ cases_to_payback = (ai_program_costs / per_case_net_impact) if per_case_net_impact > 0 else math.inf
225
+ months_to_payback = (
226
+ (cases_to_payback / (monthly_eligible_ccta * uptake))
227
+ if (monthly_eligible_ccta * uptake) > 0 and cases_to_payback != math.inf else math.inf
228
+ )
229
+
230
+ # -------- Card builders --------
231
+ def money(x, digits=0):
232
+ if x == math.inf: return "∞"
233
+ return f"${x:,.{digits}f}" if digits else f"${x:,.0f}"
234
+
235
+ def num(x, digits=1):
236
+ if x == math.inf: return "∞"
237
+ return f"{x:,.{digits}f}"
238
+
239
+ def card(title, subtitle, items, bars=None):
240
+ rows = "".join(
241
+ f"<div class='kpi-row'><span class='kpi-label'>{lab}</span><span class='kpi-val{' emph' if emph else ''}'>{val}</span></div>"
242
+ for (lab, val, emph) in items
243
+ )
244
+ # bars with right-aligned % labels
245
+ bars_html = ""
246
+ if bars:
247
+ for (lab, pct) in bars:
248
+ pct = max(0, min(100, pct))
249
+ bars_html += (
250
+ "<div class='bar'>"
251
+ f"<div class='bar-label' style=\"display:flex;align-items:center;justify-content:space-between;\">"
252
+ f"<span>{lab}</span>"
253
+ f"<span style=\"font-variant-numeric:tabular-nums;\">{pct:.0f}%</span>"
254
+ "</div>"
255
+ "<div class='bar-track'><div class='bar-fill' style='width:"
256
+ f"{pct}%'></div></div></div>"
257
+ )
258
+ return (
259
+ "<div class='card'>"
260
+ "<div class='card-head'>"
261
+ f"<div class='card-title'>{title}</div>"
262
+ f"<div class='card-sub'>{subtitle}</div>"
263
+ "</div>"
264
+ f"<div class='kpi-grid'>{rows}</div>"
265
+ f"{bars_html}"
266
+ "</div>"
267
+ )
268
+
269
+ # Labels with tooltips where it helps
270
+ roi_label_tt = tooltip(
271
+ "ROI % (on AI program)",
272
+ "ROI = Net impact ÷ (Annual platform license $12k + vendor per-case fees), for the modeled year."
273
+ )
274
+ payback_label_tt = tooltip(
275
+ "Months to payback",
276
+ "Payback months = (Annual AI program cost ÷ net impact per AI case) ÷ monthly AI cases."
277
+ )
278
+ incr_rev_label_tt = tooltip(
279
+ "Incremental revenue",
280
+ "Additional CCTA+FFR-CT+AI-QPA revenue from AI cohort vs baseline."
281
+ )
282
+ incr_costs_label_tt = tooltip(
283
+ "Incremental costs",
284
+ "Vendor per-case + annual $12k platform + testing/cath costs in AI cohort minus baseline costs."
285
+ )
286
+ ops_value_label_tt = tooltip(
287
+ "Operational value",
288
+ "Value of faster time-to-decision (bed-hours) + clinician hours saved."
289
+ )
290
+
291
+ overall_html = card(
292
+ "Overall Impact",
293
+ "Incremental (annual)",
294
+ [
295
+ (incr_rev_label_tt, money(incr_revenue), False),
296
+ (incr_costs_label_tt, money(incr_costs), False),
297
+ (ops_value_label_tt, money(incr_ops_value), False),
298
+ ("Net impact", money(net_impact), True),
299
+ (roi_label_tt, f"{roi_pct:,.1f}%", True),
300
+ (payback_label_tt, num(months_to_payback, 1), False),
301
+ ],
302
+ )
303
+ # Inline explanation of payback
304
+ overall_html += (
305
+ "<div class='muted' style='margin-top:4px; font-size:.85rem'>"
306
+ "Payback = (Annual AI program cost ÷ net impact per AI case) ÷ monthly AI cases."
307
+ "</div>"
308
+ )
309
+
310
+ financial_html = card(
311
+ "Financial",
312
+ "Incremental economics",
313
+ [
314
+ (incr_rev_label_tt, money(incr_revenue), False),
315
+ (incr_costs_label_tt, money(incr_costs), False),
316
+ ("Net impact", money(net_impact), True),
317
+ ("AI program costs (annual)", money(ai_program_costs), False),
318
+ (roi_label_tt, f"{roi_pct:,.1f}%", True),
319
+ (payback_label_tt, num(months_to_payback, 1), False),
320
+ ],
321
+ )
322
+
323
+ clinical_html = card(
324
+ "Clinical",
325
+ "Modeled impact (AI cohort)",
326
+ [
327
+ ("Avoided unnecessary ICAs", f"{int(round(avoided_unnec_ica)):,} /yr", True),
328
+ ("One-test diagnosis rate", f"{one_test_dx*100:,.0f}% of AI cases", False),
329
+ ("Added revasc candidates (est.)", f"{int(round(addl_revasc_candidates)):,} /yr", False),
330
+ ("Avoided extra non-invasive tests", f"{int(round(avoided_additional_tests)):,} /yr", False),
331
+ ],
332
+ bars=[
333
+ ("Unnecessary ICA reduction", base_dec_unnec_ica * 100),
334
+ ("One-test diagnosis", one_test_dx * 100),
335
+ ],
336
+ )
337
+
338
+ operational_html = card(
339
+ "Operational",
340
+ "Throughput & staff time (AI cohort)",
341
+ [
342
+ ("Avg hours saved per case", num(ai_saved_hours_per_case, 2), False),
343
+ ("Bed-hours saved", f"{int(round(bed_hours_saved)):,} hrs/yr", True),
344
+ ("Value of bed-hours", money(bed_hours_value), False),
345
+ ("Clinician hours saved", f"{int(round(clinician_hours_saved)):,} hrs/yr", False),
346
+ ("Value of clinician time", money(clinician_hours_value), False),
347
+ ],
348
+ )
349
+
350
+ # Evidence (neutral language)
351
+ evidence = """
352
+ <div class='card'>
353
+ <div class='card-head'>
354
+ <div class='card-title'>Evidence snapshot</div>
355
+ <div class='card-sub'>Published guidance & trials</div>
356
+ </div>
357
+ <ul style='margin:6px 0 0 1.1rem; line-height:1.45;'>
358
+ <li>PRECISE (RCT): Reduced composite of death/MI or ICA without obstructive CAD vs. traditional testing.</li>
359
+ <li>FORECAST (RCT): Fewer ICAs and fewer unnecessary ICAs; similar revascularization; no NHS cost increase.</li>
360
+ <li>PLATFORM: Fewer non-therapeutic ICAs; many planned ICAs cancelled; lower 1-yr costs.</li>
361
+ <li>NICE MTG32: CCTA-first with selective FFR-CT recommended; modeled per-patient savings.</li>
362
+ </ul>
363
+ <div class='muted' style='margin-top:6px;'>Note: Site results vary by policy, mix, and operations.</div>
364
+ </div>
365
+ """
366
+
367
+ # Contextual CTA (richer line)
368
+ cases_per_year = int(round(annual_uptake_cases))
369
+ cta_line = (
370
+ f"Based on your {cases_per_year:,} AI cases/yr: "
371
+ f"net impact {money(net_impact)}, ROI {roi_pct:,.1f}%, payback {num(months_to_payback,1)} mo; "
372
+ f"avoids ~{int(round(avoided_unnec_ica)):,} diagnostic ICAs & ~{int(round(avoided_additional_tests)):,} extra tests; "
373
+ f"frees ~{int(round(bed_hours_saved)):,} bed-hrs & ~{int(round(clinician_hours_saved)):,} clinician-hrs annually."
374
+ )
375
+ cta_html = f"""
376
+ <div class='cta-card'>
377
+ <div class='cta-line'>{cta_line}</div>
378
+ <a class='cta-btn' href='{CTA_URL}' target='_blank' rel='noopener'>{CTA_LABEL}</a>
379
+ </div>"""
380
+
381
+ # Return cards + numbers for waterfall / CSV
382
+ return (
383
+ overall_html, financial_html, clinical_html, operational_html,
384
+ evidence, cta_html,
385
+ incr_revenue, incr_costs, incr_ops_value, net_impact
386
+ )
387
+
388
+ # ------------------------- UI -------------------------
389
+ def build_ui():
390
+ with gr.Blocks(theme=gr.themes.Soft(), css="""
391
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
392
+
393
+ :root { --card-bg:#ffffff; --muted:#6b7280; }
394
+ * { font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, 'Apple Color Emoji', 'Segoe UI Emoji'; }
395
+
396
+ .header { display:flex; align-items:center; justify-content:space-between; padding:12px 0; }
397
+ .app-title { font-weight:800; font-size:1.4rem; }
398
+ .pill { background:#ecfdf5; color:#065f46; padding:4px 10px; border-radius:999px; font-weight:700; font-size:.85rem; }
399
+
400
+ .card { background:var(--card-bg); border-radius:18px; padding:18px; box-shadow:0 8px 24px rgba(0,0,0,.08); border:1px solid #eef2f7; }
401
+ .card-head { margin-bottom:8px; display:flex; align-items:baseline; gap:8px; }
402
+ .card-title { font-weight:800; font-size:1.05rem; }
403
+ .card-sub { color:var(--muted); font-size:.9rem; }
404
+ .kpi-grid { display:grid; grid-template-columns:1fr auto; gap:6px 12px; align-items:center; margin-bottom:8px; }
405
+ .kpi-row { display:contents; }
406
+ .kpi-label { color:#4b5563; }
407
+ .kpi-val { font-variant-numeric:tabular-nums; font-weight:600; }
408
+ .kpi-val.emph { font-weight:900; font-size:1.06rem; }
409
+
410
+ .bar { margin-top:8px; }
411
+ .bar-label { font-size:.85rem; color:#374151; margin-bottom:2px; }
412
+ .bar-track { background:#f1f5f9; border-radius:999px; height:8px; position:relative; overflow:hidden; }
413
+ .bar-fill { background:linear-gradient(90deg,#14b8a6,#06b6d4); height:8px; border-radius:999px; }
414
+
415
+ .muted { color:var(--muted); }
416
+ .cta-card { margin-top:8px; padding:12px; border:1px dashed #d1fae5; border-radius:12px; display:flex; align-items:center; justify-content:space-between; gap:10px; }
417
+ .cta-line { font-weight:700; }
418
+ .cta-btn { background:#0ea5e9; color:white; padding:8px 12px; border-radius:10px; text-decoration:none; font-weight:700; }
419
+
420
+ .wf { margin-top:8px; }
421
+ .wf-row { display:flex; align-items:center; gap:10px; margin:6px 0; }
422
+ .wf-label { width:160px; color:#334155; font-size:.9rem; }
423
+ .wf-bar { height:12px; border-radius:999px; background:#f1f5f9; flex:1; position:relative; overflow:hidden; }
424
+ .wf-fill.pos { position:absolute; left:0; top:0; bottom:0; border-radius:999px; background:linear-gradient(90deg,#22c55e,#16a34a); color:rgba(255,255,255,.95); }
425
+ .wf-fill.neg { position:absolute; right:0; top:0; bottom:0; border-radius:999px; background:linear-gradient(90deg,#ef4444,#dc2626); color:rgba(255,255,255,.95); }
426
+ .wf-val { width:140px; text-align:right; font-variant-numeric:tabular-nums; }
427
+ """) as demo:
428
+ # Header
429
+ with gr.Row(elem_classes=["header"]):
430
+ gr.Markdown("""
431
+ <div class='app-title'>CARPL ROI Calculator · FFR-CT AI</div>
432
+ <div class='pill'>Clinical · Financial · Operational</div>
433
+ """)
434
+
435
+ with gr.Row():
436
+ # -------- Left: inputs --------
437
+ with gr.Column(scale=1):
438
+ site_type = gr.Dropdown(SITE_TYPES, value=SITE_TYPES[0], label="Site type")
439
+ monthly_eligible_ccta = gr.Slider(0, 2000, value=DEFAULTS.monthly_eligible_ccta, step=10,
440
+ label="Monthly eligible CCTA volume")
441
+ uptake_pct = gr.Slider(0, 100, value=DEFAULTS.uptake_pct, step=1,
442
+ label="Uptake (eligible receiving FFR-CT, %)")
443
+ avg_time_to_decision_today_hours = gr.Number(label="Average time-to-decision today (hours)", value=8.0)
444
+ baseline_clinician_touch_min = gr.Number(label="Clinician touch-time per case (min)", value=30.0)
445
+
446
+ # Hidden internal assumptions (States) — not displayed
447
+ reimb_ccta = gr.State(DEFAULTS.reimb_ccta)
448
+ reimb_ffrct = gr.State(DEFAULTS.reimb_ffrct)
449
+ reimb_ai_qpa = gr.State(DEFAULTS.reimb_ai_qpa)
450
+ pct_billed_ai_qpa = gr.State(DEFAULTS.pct_billed_ai_qpa)
451
+ one_test_dx_pct_state = gr.State(DEFAULTS.one_test_dx_pct)
452
+ dec_unnec_ica_pct_state = gr.State(DEFAULTS.dec_unnec_ica_pct)
453
+ more_likely_revasc_pct_state = gr.State(DEFAULTS.more_likely_revasc_pct)
454
+ revasc_prevalence_pct_state = gr.State(DEFAULTS.revasc_prevalence_pct)
455
+ vendor_per_case_cost = gr.State(DEFAULTS.vendor_per_case_cost)
456
+ platform_annual_cost = gr.State(DEFAULTS.platform_annual_cost) # <-- annual
457
+ stress_test_cost = gr.State(DEFAULTS.stress_test_cost)
458
+ bed_hour_value = gr.State(DEFAULTS.bed_hour_value)
459
+ clinician_hour_cost = gr.State(DEFAULTS.clinician_hour_cost)
460
+ ai_time_to_decision_min = gr.State(DEFAULTS.ai_time_to_decision_min)
461
+ clinician_touch_reduction_pct = gr.State(DEFAULTS.clinician_touch_reduction_pct)
462
+ baseline_diag_ica_rate_pct = gr.State(DEFAULTS.baseline_diag_ica_rate_pct)
463
+ baseline_additional_testing_rate_pct = gr.State(DEFAULTS.baseline_additional_testing_rate_pct)
464
+
465
+ with gr.Accordion("Sensitivity (what-if)", open=False):
466
+ sens_uptake_factor_pct = gr.Slider(0, 200, 100, step=5, label="Uptake factor (%)")
467
+ sens_dec_unnec_ica_factor_pct = gr.Slider(0, 200, 100, step=5, label="ICA reduction factor (%)")
468
+ sens_vendor_cost_factor_pct = gr.Slider(25, 200, 100, step=5, label="Vendor per-case cost factor (%)")
469
+
470
+ # -------- Right: outputs --------
471
+ with gr.Column(scale=1):
472
+ overall_card = gr.HTML()
473
+ financial_card = gr.HTML()
474
+ clinical_card = gr.HTML()
475
+ operational_card = gr.HTML()
476
+ waterfall_panel = gr.HTML(label="ROI Waterfall")
477
+ cta_panel = gr.HTML()
478
+ kpi_copy = gr.Textbox(label="Copy KPIs", interactive=False, lines=6)
479
+ csv_button = gr.DownloadButton(label="Download CSV", value=None)
480
+ evidence_panel = gr.HTML()
481
+
482
+ # Bundle inputs (order matters!)
483
+ inputs = [
484
+ site_type,
485
+ monthly_eligible_ccta,
486
+ uptake_pct,
487
+ avg_time_to_decision_today_hours,
488
+ baseline_clinician_touch_min,
489
+ reimb_ccta,
490
+ reimb_ffrct,
491
+ reimb_ai_qpa,
492
+ pct_billed_ai_qpa,
493
+ one_test_dx_pct_state,
494
+ dec_unnec_ica_pct_state,
495
+ more_likely_revasc_pct_state,
496
+ revasc_prevalence_pct_state,
497
+ vendor_per_case_cost,
498
+ platform_annual_cost, # <-- annual
499
+ stress_test_cost,
500
+ bed_hour_value,
501
+ clinician_hour_cost,
502
+ ai_time_to_decision_min,
503
+ clinician_touch_reduction_pct,
504
+ baseline_diag_ica_rate_pct,
505
+ baseline_additional_testing_rate_pct,
506
+ sens_uptake_factor_pct,
507
+ sens_dec_unnec_ica_factor_pct,
508
+ sens_vendor_cost_factor_pct,
509
+ ]
510
+
511
+ # HTML waterfall (dep-free) with inline values
512
+ def waterfall_html(incr_rev, incr_costs, incr_ops, net):
513
+ maxv = max(abs(incr_rev), abs(incr_costs), abs(incr_ops), abs(net), 1)
514
+ def row(label, val):
515
+ pct = int(abs(val) / maxv * 100)
516
+ cls = 'pos' if val >= 0 else 'neg'
517
+ inner_val = f"${val:,.0f}"
518
+ inner = (
519
+ "<div style='position:absolute;inset:0;display:flex;align-items:center;padding:0 8px;"
520
+ "font-size:.82rem;opacity:.95;'>"
521
+ f"{inner_val}</div>"
522
+ if pct >= 24 else ""
523
+ )
524
+ return (
525
+ "<div class='wf-row'>"
526
+ f"<div class='wf-label'>{label}</div>"
527
+ "<div class='wf-bar'>"
528
+ f"<div class='wf-fill {cls}' style='width:{pct}%'>{inner}</div>"
529
+ "</div>"
530
+ f"<div class='wf-val'>{inner_val}</div>"
531
+ "</div>"
532
+ )
533
+ return (
534
+ "<div class='wf'>"
535
+ + row("Incr. Revenue", incr_rev)
536
+ + row("Incr. Costs", -abs(incr_costs))
537
+ + row("Ops Value", incr_ops)
538
+ + row("Net Impact", net)
539
+ + "</div>"
540
+ )
541
+
542
+ def kpi_text(incr_rev, incr_costs, incr_ops, net):
543
+ return (
544
+ f"Incremental revenue: ${incr_rev:,.0f}\n"
545
+ f"Incremental costs: ${incr_costs:,.0f}\n"
546
+ f"Operational value: ${incr_ops:,.0f}\n"
547
+ f"Net impact: ${net:,.0f}\n"
548
+ )
549
+
550
+ def post_compute(*args):
551
+ (overall_html, financial_html, clinical_html, operational_html,
552
+ evidence_html, cta_html,
553
+ incr_rev, incr_costs, incr_ops, net) = compute_roi(*args)
554
+ wf = waterfall_html(incr_rev, incr_costs, incr_ops, net)
555
+ kpis = kpi_text(incr_rev, incr_costs, incr_ops, net)
556
+ return (overall_html, financial_html, clinical_html, operational_html,
557
+ evidence_html, cta_html, wf, kpis)
558
+
559
+ # Bind changes (DON’T include csv_button here)
560
+ for comp in inputs[:5]:
561
+ comp.change(post_compute, inputs=inputs,
562
+ outputs=[overall_card, financial_card, clinical_card, operational_card,
563
+ evidence_panel, cta_panel, waterfall_panel, kpi_copy])
564
+ for comp in inputs[-3:]:
565
+ comp.change(post_compute, inputs=inputs,
566
+ outputs=[overall_card, financial_card, clinical_card, operational_card,
567
+ evidence_panel, cta_panel, waterfall_panel, kpi_copy])
568
+
569
+ # Init on load
570
+ demo.load(post_compute, inputs=inputs,
571
+ outputs=[overall_card, financial_card, clinical_card, operational_card,
572
+ evidence_panel, cta_panel, waterfall_panel, kpi_copy])
573
+
574
+ # CSV on click only
575
+ def make_csv(*args):
576
+ import csv, tempfile
577
+ (overall_html, financial_html, clinical_html, operational_html,
578
+ evidence_html, cta_html,
579
+ incr_rev, incr_costs, incr_ops, net) = compute_roi(*args)
580
+ headers = ["Metric", "Value"]
581
+ rows = [
582
+ ["Incremental revenue", f"${incr_rev:,.0f}"],
583
+ ["Incremental costs", f"${incr_costs:,.0f}"],
584
+ ["Operational value", f"${incr_ops:,.0f}"],
585
+ ["Net impact", f"${net:,.0f}"],
586
+ ]
587
+ fd, path = tempfile.mkstemp(suffix="_roi.csv")
588
+ with open(path, "w", newline="") as f:
589
+ writer = csv.writer(f)
590
+ writer.writerow(headers)
591
+ writer.writerows(rows)
592
+ return path
593
+
594
+ csv_button.click(make_csv, inputs=inputs, outputs=csv_button)
595
+
596
+ gr.Markdown("> Enter the four inputs on the left. We apply published evidence and CMS-2025 assumptions under the hood for a site-specific clinical, financial, and operational view. The platform cost is a one-time **$12,000/year** license.")
597
+
598
+ return demo
599
+
600
+ # ------------------------- Entrypoint -------------------------
601
+ def main():
602
+ demo = build_ui()
603
+ demo.queue().launch()
604
 
605
+ if __name__ == "__main__":
606
+ main()