diff --git a/runway_app.py b/runway_app.py index a06fec6..4a1b44f 100644 --- a/runway_app.py +++ b/runway_app.py @@ -14,6 +14,122 @@ STATE_FILE = DATA_DIR / "runway_state.json" MONTHS = 36 +DATA_DICTIONARY = [ + { + "Field": "month", + "ELI5 accounting/finance": "The timeline label so cash, revenue, and costs land in the right bucket.", + "ELI5 ABA": "Which month of care or staffing you’re planning for.", + }, + { + "Field": "BT_count / Mid_Tier_count / BCBA_count", + "ELI5 accounting/finance": "How many helpers you’re paying, which drives payroll costs.", + "ELI5 ABA": "How many therapists at each level you have on the schedule.", + }, + { + "Field": "new_patients", + "ELI5 accounting/finance": "New customers arriving, which leads to more billable hours and revenue.", + "ELI5 ABA": "New kids starting services this month.", + }, + { + "Field": "churn_rate", + "ELI5 accounting/finance": "The percent of customers who leave; fewer customers means less revenue.", + "ELI5 ABA": "Share of current clients who pause or stop services.", + }, + { + "Field": "active_patients", + "ELI5 accounting/finance": "How many paying customers you have after churn and new signups.", + "ELI5 ABA": "Kids you’re actively serving this month.", + }, + { + "Field": "demand_hours", + "ELI5 accounting/finance": "Total hours patients want, which cap revenue potential.", + "ELI5 ABA": "Therapy hours families are scheduled for.", + }, + { + "Field": "staff_onboarding_cost / staff_turnover_cost", + "ELI5 accounting/finance": "Hiring and replacement costs for staff, tied to how many people you add or lose.", + "ELI5 ABA": "Money spent getting new therapists ready and replacing ones who leave.", + }, + { + "Field": "patient_onboarding_cost / patient_turnover_cost", + "ELI5 accounting/finance": "Intake and discharge costs that move with new patients and churned patients.", + "ELI5 ABA": "Time and admin dollars to onboard new families and close out those who stop.", + }, + { + "Field": "insurance_partner_*_cost", + "ELI5 accounting/finance": "Eligibility/payer setup and offboarding costs that scale with patient adds and losses.", + "ELI5 ABA": "What it costs to set up insurance and partner paperwork each time a family starts or ends care.", + }, + { + "Field": "*_turnover_ratio", + "ELI5 accounting/finance": "Percent of last month's headcount that exited; patient turnover matches churn rate.", + "ELI5 ABA": "Share of therapists or families who leave compared with last month.", + }, + { + "Field": "*_capacity_hours", + "ELI5 accounting/finance": "Maximum hours your team can bill, which controls both revenue and payroll.", + "ELI5 ABA": "How many therapy hours each role can deliver based on the roster.", + }, + { + "Field": "billed_hours", + "ELI5 accounting/finance": "Hours you can actually invoice after matching demand to capacity.", + "ELI5 ABA": "Therapy time that really gets delivered and sent to insurance.", + }, + { + "Field": "revenue_* / revenue_total", + "ELI5 accounting/finance": "Money earned from billed hours at each rate.", + "ELI5 ABA": "What payers owe for BT, mid-tier, and BCBA services.", + }, + { + "Field": "payroll_* / payroll_total", + "ELI5 accounting/finance": "Wages plus payroll taxes owed for scheduled hours.", + "ELI5 ABA": "Labor costs to keep each role on the schedule.", + }, + { + "Field": "overhead", + "ELI5 accounting/finance": "Fixed bills like rent and software that happen every month.", + "ELI5 ABA": "Clinic costs that don’t depend on the number of sessions.", + }, + { + "Field": "net_burn", + "ELI5 accounting/finance": "How much cash the business loses (or gains) in an operating month before collections timing.", + "ELI5 ABA": "Whether you’re burning money after paying staff and overhead once services are delivered.", + }, + { + "Field": "cash_collected", + "ELI5 accounting/finance": "Revenue that finally shows up after the billing lag.", + "ELI5 ABA": "Checks that clear a few months after sessions are provided.", + }, + { + "Field": "capital_injection", + "ELI5 accounting/finance": "Extra cash investors put in so you don’t run out.", + "ELI5 ABA": "Top-up money to keep services running while waiting for payments.", + }, + { + "Field": "cash_balance", + "ELI5 accounting/finance": "Running total of cash left after inflows and outflows.", + "ELI5 ABA": "How much money is in the bank after paying people and getting reimbursed.", + }, + { + "Field": "utilization & role utilizations", + "ELI5 accounting/finance": "Percent of available hours that turned into billable revenue.", + "ELI5 ABA": "How busy your therapists are versus their schedules.", + }, + { + "Field": "margin_* / margin_total", + "ELI5 accounting/finance": "Profit (or loss) after paying the team, and after overhead for total margin.", + "ELI5 ABA": "What’s left from payments after covering therapist time and clinic costs.", + }, +] + +SUGGESTED_IMPROVEMENTS = [ + "Show tooltips on every input so new users know which slider affects revenue, payroll, or churn.", + "Add scenario presets (e.g., \"aggressive growth\" or \"lean operations\") to swap staffing and patient curves quickly.", + "Include CSV export buttons for the summary and for assumptions so teams can share snapshots.", + "Surface alerts when utilization drops below a target or when cash runway dips under six months.", + "Let users pin notes per tab (staffing, patients, assumptions) to record why they changed a number.", +] + @st.cache_data(show_spinner=False) def load_state() -> Dict: @@ -64,6 +180,16 @@ def default_assumptions() -> Dict: "capital_injection_month": 1, "capital_injection_amount": 0.0, "starting_cash": 0.0, + "patient_onboarding_cost": 150.0, + "patient_turnover_cost": 50.0, + "bt_onboarding_cost": 750.0, + "mid_onboarding_cost": 1000.0, + "bcba_onboarding_cost": 1500.0, + "bt_turnover_cost": 400.0, + "mid_turnover_cost": 600.0, + "bcba_turnover_cost": 800.0, + "insurance_partner_onboarding_per_patient": 25.0, + "insurance_partner_turnover_per_patient": 10.0, } @@ -92,18 +218,37 @@ def get_default_state() -> Dict: def compute_model(staffing: pd.DataFrame, patients: pd.DataFrame, assumptions: Dict) -> pd.DataFrame: - df = staffing.merge(patients, on="month", how="left") + lag = max(int(assumptions.get("billing_lag_months", 0)), 0) + max_operating_month = int( + max( + staffing["month"].max() if not staffing.empty else 0, + patients["month"].max() if not patients.empty else 0, + ) + ) + + full_months = pd.DataFrame({"month": range(1, max_operating_month + lag + 1)}) + df = full_months.merge(staffing, on="month", how="left").merge( + patients, on="month", how="left" + ) df = df.sort_values("month").reset_index(drop=True) - df["churn_rate"] = df["churn_rate"].fillna(0).clip(lower=0, upper=1) + df["BT_count"] = df["BT_count"].fillna(0) + df["Mid_Tier_count"] = df["Mid_Tier_count"].fillna(0) + df["BCBA_count"] = df["BCBA_count"].fillna(0) df["new_patients"] = df["new_patients"].fillna(0) + df.loc[df["month"] > max_operating_month, "churn_rate"] = 1.0 + df["churn_rate"] = df["churn_rate"].fillna(0).clip(lower=0, upper=1) + active_patients = [] + churned_patients = [] for idx, row in df.iterrows(): prev_active = active_patients[idx - 1] if idx > 0 else 0 churned = prev_active * row["churn_rate"] + churned_patients.append(churned) active_patients.append(max(prev_active - churned + row["new_patients"], 0)) df["active_patients"] = active_patients + df["churned_patients"] = churned_patients hours_per_patient_week = assumptions["hours_per_patient_per_week"] df["demand_hours"] = df["active_patients"] * hours_per_patient_week * 4.33 @@ -159,9 +304,58 @@ def compute_model(staffing: pd.DataFrame, patients: pd.DataFrame, assumptions: D df["payroll_total"] = df[["payroll_bt", "payroll_mid", "payroll_bcba"]].sum(axis=1) df["overhead"] = assumptions["overhead"] - df["net_burn"] = df["payroll_total"] + df["overhead"] - df["revenue_total"] + df.loc[df["month"] > max_operating_month, "overhead"] = 0 + bt_hires = df["BT_count"].diff().clip(lower=0).fillna(df["BT_count"]) + mid_hires = df["Mid_Tier_count"].diff().clip(lower=0).fillna(df["Mid_Tier_count"]) + bcba_hires = df["BCBA_count"].diff().clip(lower=0).fillna(df["BCBA_count"]) + + bt_exits = (-df["BT_count"].diff()).clip(lower=0).fillna(0) + mid_exits = (-df["Mid_Tier_count"].diff()).clip(lower=0).fillna(0) + bcba_exits = (-df["BCBA_count"].diff()).clip(lower=0).fillna(0) + + df["bt_turnover_ratio"] = bt_exits / df["BT_count"].shift(1).replace(0, pd.NA) + df["mid_turnover_ratio"] = mid_exits / df["Mid_Tier_count"].shift(1).replace(0, pd.NA) + df["bcba_turnover_ratio"] = bcba_exits / df["BCBA_count"].shift(1).replace(0, pd.NA) + + df["patient_turnover_ratio"] = df["churn_rate"] + + df["staff_onboarding_cost"] = ( + bt_hires * assumptions["bt_onboarding_cost"] + + mid_hires * assumptions["mid_onboarding_cost"] + + bcba_hires * assumptions["bcba_onboarding_cost"] + ) + df["staff_turnover_cost"] = ( + bt_exits * assumptions["bt_turnover_cost"] + + mid_exits * assumptions["mid_turnover_cost"] + + bcba_exits * assumptions["bcba_turnover_cost"] + ) + df["patient_onboarding_cost"] = df["new_patients"] * assumptions["patient_onboarding_cost"] + df["patient_turnover_cost"] = df["churned_patients"] * assumptions["patient_turnover_cost"] + df["insurance_partner_onboarding_cost"] = df["new_patients"] * assumptions[ + "insurance_partner_onboarding_per_patient" + ] + df["insurance_partner_turnover_cost"] = df["churned_patients"] * assumptions[ + "insurance_partner_turnover_per_patient" + ] + + df["onboarding_turnover_cost_total"] = df[ + [ + "staff_onboarding_cost", + "staff_turnover_cost", + "patient_onboarding_cost", + "patient_turnover_cost", + "insurance_partner_onboarding_cost", + "insurance_partner_turnover_cost", + ] + ].sum(axis=1) + + df["net_burn"] = ( + df["payroll_total"] + + df["overhead"] + + df["onboarding_turnover_cost_total"] + - df["revenue_total"] + ) - lag = max(int(assumptions["billing_lag_months"]), 0) df["cash_collected"] = df["revenue_total"].shift(lag).fillna(0) df["capital_injection"] = df["month"].apply( @@ -170,23 +364,48 @@ def compute_model(staffing: pd.DataFrame, patients: pd.DataFrame, assumptions: D else 0 ) - df["net_cash_flow"] = df["cash_collected"] + df["capital_injection"] - df["payroll_total"] - df["overhead"] + df["net_cash_flow"] = ( + df["cash_collected"] + + df["capital_injection"] + - df["payroll_total"] + - df["overhead"] + - df["onboarding_turnover_cost_total"] + ) df["cash_balance"] = df["net_cash_flow"].cumsum() + assumptions.get("starting_cash", 0) df["utilization"] = df["billed_hours"] / df["total_capacity_hours"].replace(0, pd.NA) + df["margin_bt"] = df["revenue_bt"] - df["payroll_bt"] + df["margin_mid"] = df["revenue_mid"] - df["payroll_mid"] + df["margin_bcba"] = df["revenue_bcba"] - df["payroll_bcba"] + df["margin_total"] = df["revenue_total"] - df["payroll_total"] - df["overhead"] + + df["bt_utilization"] = df["billed_hours_bt"] / df["bt_capacity_hours"].replace(0, pd.NA) + df["mid_utilization"] = df["billed_hours_mid"] / df["mid_capacity_hours"].replace(0, pd.NA) + df["bcba_utilization"] = df["billed_hours_bcba"] / df["bcba_capacity_hours"].replace(0, pd.NA) + + df["operating_month"] = df["month"] <= max_operating_month + return df def kpi_tiles(df: pd.DataFrame): last = df.iloc[-1] - avg_util = df["utilization"].mean(skipna=True) + operating_df = df[df["operating_month"]] + last_operating = operating_df.iloc[-1] if not operating_df.empty else last + + avg_util = operating_df["utilization"].mean(skipna=True) runway_months = (df["cash_balance"] >= 0).sum() col1, col2 = st.columns(2) - col1.metric("Cash balance (month 36)", f"${last['cash_balance']:,.0f}") + col1.metric( + f"Cash balance (month {int(last['month'])})", f"${last['cash_balance']:,.0f}" + ) col1.metric("Runway (months with cash)", f"{runway_months} months") col2.metric("Avg utilization", f"{avg_util:.1%}") - col2.metric("Monthly revenue (month 36)", f"${last['revenue_total']:,.0f}") + col2.metric( + f"Monthly revenue (month {int(last_operating['month'])})", + f"${last_operating['revenue_total']:,.0f}", + ) @st.cache_data(show_spinner=False) @@ -223,6 +442,15 @@ def render_overview(df: pd.DataFrame): "net_cash_flow", "cash_balance", "utilization", + "bt_utilization", + "mid_utilization", + "bcba_utilization", + "margin_total", + "onboarding_turnover_cost_total", + "patient_onboarding_cost", + "patient_turnover_cost", + "staff_onboarding_cost", + "staff_turnover_cost", ] st.markdown("### Monthly summary") st.dataframe(df[summary_cols], use_container_width=True, height=500) @@ -304,6 +532,35 @@ def render_assumptions_form(assumptions: Dict) -> Dict: step=5000.0, format="%0.0f", ) + st.markdown("### Patient & payer onboarding") + patient_onboarding_cost = st.number_input( + "Patient onboarding cost (per new patient)", + min_value=0.0, + value=float(assumptions["patient_onboarding_cost"]), + step=10.0, + format="%0.0f", + ) + patient_turnover_cost = st.number_input( + "Patient turnover cost (per churned patient)", + min_value=0.0, + value=float(assumptions["patient_turnover_cost"]), + step=10.0, + format="%0.0f", + ) + insurance_partner_onboarding_per_patient = st.number_input( + "Insurance/partner onboarding (per new patient)", + min_value=0.0, + value=float(assumptions["insurance_partner_onboarding_per_patient"]), + step=5.0, + format="%0.0f", + ) + insurance_partner_turnover_per_patient = st.number_input( + "Insurance/partner offboarding (per churned patient)", + min_value=0.0, + value=float(assumptions["insurance_partner_turnover_per_patient"]), + step=5.0, + format="%0.0f", + ) with col2: bt_rate = st.number_input( "BT rate ($/hr)", @@ -335,6 +592,49 @@ def render_assumptions_form(assumptions: Dict) -> Dict: min_value=0.0, value=float(assumptions["bcba_wage"]), ) + st.markdown("### Staffing onboarding & turnover") + bt_onboarding_cost = st.number_input( + "BT onboarding cost (per hire)", + min_value=0.0, + value=float(assumptions["bt_onboarding_cost"]), + step=50.0, + format="%0.0f", + ) + mid_onboarding_cost = st.number_input( + "Mid-tier onboarding cost (per hire)", + min_value=0.0, + value=float(assumptions["mid_onboarding_cost"]), + step=50.0, + format="%0.0f", + ) + bcba_onboarding_cost = st.number_input( + "BCBA onboarding cost (per hire)", + min_value=0.0, + value=float(assumptions["bcba_onboarding_cost"]), + step=50.0, + format="%0.0f", + ) + bt_turnover_cost = st.number_input( + "BT turnover cost (per exit)", + min_value=0.0, + value=float(assumptions["bt_turnover_cost"]), + step=25.0, + format="%0.0f", + ) + mid_turnover_cost = st.number_input( + "Mid-tier turnover cost (per exit)", + min_value=0.0, + value=float(assumptions["mid_turnover_cost"]), + step=25.0, + format="%0.0f", + ) + bcba_turnover_cost = st.number_input( + "BCBA turnover cost (per exit)", + min_value=0.0, + value=float(assumptions["bcba_turnover_cost"]), + step=25.0, + format="%0.0f", + ) payroll_tax_rate = st.number_input( "Payroll tax & benefits", min_value=0.0, @@ -374,9 +674,37 @@ def render_assumptions_form(assumptions: Dict) -> Dict: "capital_injection_month": capital_injection_month, "capital_injection_amount": capital_injection_amount, "starting_cash": starting_cash, + "patient_onboarding_cost": patient_onboarding_cost, + "patient_turnover_cost": patient_turnover_cost, + "insurance_partner_onboarding_per_patient": insurance_partner_onboarding_per_patient, + "insurance_partner_turnover_per_patient": insurance_partner_turnover_per_patient, + "bt_onboarding_cost": bt_onboarding_cost, + "mid_onboarding_cost": mid_onboarding_cost, + "bcba_onboarding_cost": bcba_onboarding_cost, + "bt_turnover_cost": bt_turnover_cost, + "mid_turnover_cost": mid_turnover_cost, + "bcba_turnover_cost": bcba_turnover_cost, } +def render_data_dictionary(): + st.subheader("Data dictionary (ELI5)") + st.caption( + "Plain-language accounting/finance and ABA explanations for the fields used in the model." + ) + st.dataframe( + pd.DataFrame(DATA_DICTIONARY), + use_container_width=True, + hide_index=True, + height=600, + ) + + st.subheader("Ideas to make the app even friendlier") + st.caption("Quick wins to reduce confusion and speed up planning.") + for idea in SUGGESTED_IMPROVEMENTS: + st.markdown(f"- {idea}") + + def main(): st.set_page_config( page_title="Runway model", @@ -393,8 +721,8 @@ def main(): patients_df = st.session_state.app_state["patients"].copy() assumptions = st.session_state.app_state["assumptions"].copy() - overview_tab, staffing_tab, patients_tab, assumptions_tab = st.tabs( - ["Overview", "Staffing", "Patients", "Assumptions"] + overview_tab, staffing_tab, patients_tab, assumptions_tab, info_tab = st.tabs( + ["Overview", "Staffing", "Patients", "Assumptions", "Data dictionary"] ) with staffing_tab: @@ -408,6 +736,8 @@ def main(): with overview_tab: render_overview(result_df) + with info_tab: + render_data_dictionary() st.divider() if st.button("Save inputs", type="primary"):