diff --git a/us/states/ut/generate_hb15_charts.py b/us/states/ut/generate_hb15_charts.py new file mode 100644 index 0000000..e33bab2 --- /dev/null +++ b/us/states/ut/generate_hb15_charts.py @@ -0,0 +1,239 @@ +"""Generate charts and tables for Utah HB 15 blog post.""" + +import plotly.graph_objects as go +from policyengine_us import Simulation +from policyengine_core.periods import instant +from policyengine_core.reforms import Reform +from policyengine_core.charts import format_fig + +YEAR = 2027 +GRAY = '#808080' +BLUE_PRIMARY = '#2C6496' +TEAL_ACCENT = '#39C6C0' +DARK_GRAY = '#616161' + +# FPL values for 2027 +FPL_1_PERSON = 16334 +FPL_2_PERSON = 22138 + + +def create_ut_medicaid_expansion_repeal(): + def modify_parameters(parameters): + parameters.gov.hhs.medicaid.eligibility.categories.adult.income_limit.UT.update( + start=instant(f'{YEAR}-01-01'), + stop=instant('2100-12-31'), + value=float('-inf'), + ) + return parameters + + class reform(Reform): + def apply(self): + self.modify_parameters(modify_parameters) + + return reform + + +def generate_table_data(): + """Generate table data at selected income levels.""" + + print("=" * 60) + print("SINGLE ADULT TABLE DATA") + print("=" * 60) + + # Selected income levels for single adult + single_incomes = [ + (12000, "75%", "Coverage gap"), + (16334, "100%", "FPL threshold"), + (18000, "110%", "ACA eligible"), + (22541, "138%", "Expansion limit"), + (25000, "153%", "Above expansion"), + ] + + print(f"{'Income':<12} {'% FPL':<8} {'Medicaid (Base)':<18} {'Medicaid (Reform)':<18} {'ACA PTC (Base)':<16} {'ACA PTC (Reform)':<16} {'Notes'}") + print("-" * 120) + + for income, fpl_pct, notes in single_incomes: + situation = { + 'people': {'adult': {'age': {YEAR: 35}, 'employment_income': {YEAR: income}, 'monthly_hours_worked': {YEAR: 100}}}, + 'tax_units': {'tax_unit': {'members': ['adult']}}, + 'spm_units': {'spm_unit': {'members': ['adult']}}, + 'households': {'household': {'members': ['adult'], 'state_code': {YEAR: 'UT'}}}, + 'families': {'family': {'members': ['adult']}}, + 'marital_units': {'marital_unit': {'members': ['adult']}}, + } + + base = Simulation(situation=situation) + ref = Simulation(situation=situation, reform=create_ut_medicaid_expansion_repeal()) + + b_medicaid = base.calculate('medicaid', YEAR, map_to='person')[0] + r_medicaid = ref.calculate('medicaid', YEAR, map_to='person')[0] + b_ptc = base.calculate('premium_tax_credit', YEAR)[0] + r_ptc = ref.calculate('premium_tax_credit', YEAR)[0] + + print(f"${income:<11,} {fpl_pct:<8} ${b_medicaid:<17,.0f} ${r_medicaid:<17,.0f} ${b_ptc:<15,.0f} ${r_ptc:<15,.0f} {notes}") + + print() + print("=" * 60) + print("SINGLE PARENT + CHILD TABLE DATA") + print("=" * 60) + + # Selected income levels for parent+child + # Note: Utah has parent Medicaid at 46% FPL, so very low income parents still get coverage + parent_incomes = [ + (8000, "36%", "Parent Medicaid (below 46% FPL)"), + (12000, "54%", "Coverage gap (above 46% FPL)"), + (22138, "100%", "FPL threshold"), + (30550, "138%", "Expansion limit"), + (35000, "158%", "Above expansion (CHIP)"), + ] + + print(f"{'Income':<12} {'% FPL':<8} {'Parent Medicaid (B)':<20} {'Parent Medicaid (R)':<20} {'Child Medicaid/CHIP':<20} {'ACA PTC (B)':<14} {'ACA PTC (R)':<14} {'Notes'}") + print("-" * 140) + + for income, fpl_pct, notes in parent_incomes: + situation = { + 'people': { + 'parent': {'age': {YEAR: 30}, 'employment_income': {YEAR: income}, 'monthly_hours_worked': {YEAR: 100}}, + 'child': {'age': {YEAR: 8}}, + }, + 'tax_units': {'tax_unit': {'members': ['parent', 'child']}}, + 'spm_units': {'spm_unit': {'members': ['parent', 'child']}}, + 'households': {'household': {'members': ['parent', 'child'], 'state_code': {YEAR: 'UT'}}}, + 'families': {'family': {'members': ['parent', 'child']}}, + 'marital_units': {'marital_unit': {'members': ['parent']}}, + } + + base = Simulation(situation=situation) + ref = Simulation(situation=situation, reform=create_ut_medicaid_expansion_repeal()) + + # Parent is index 0, child is index 1 + b_parent_medicaid = base.calculate('medicaid', YEAR, map_to='person')[0] + r_parent_medicaid = ref.calculate('medicaid', YEAR, map_to='person')[0] + b_child_medicaid = base.calculate('medicaid', YEAR, map_to='person')[1] + b_child_chip = base.calculate('chip', YEAR, map_to='person')[1] + b_ptc = base.calculate('premium_tax_credit', YEAR)[0] + r_ptc = ref.calculate('premium_tax_credit', YEAR)[0] + + # Child coverage = Medicaid + CHIP (same in both scenarios) + child_total = b_child_medicaid + b_child_chip + child_coverage = f"${child_total:,.0f}" + + print(f"${income:<11,} {fpl_pct:<8} ${b_parent_medicaid:<19,.0f} ${r_parent_medicaid:<19,.0f} {child_coverage:<20} ${b_ptc:<13,.0f} ${r_ptc:<13,.0f} {notes}") + + +def generate_charts(): + """Generate charts for the blog post.""" + + # Single adult situation with axes + single_situation = { + 'people': { + 'adult': { + 'age': {YEAR: 35}, + 'monthly_hours_worked': {YEAR: 100}, + } + }, + 'tax_units': {'tax_unit': {'members': ['adult']}}, + 'spm_units': {'spm_unit': {'members': ['adult']}}, + 'households': {'household': {'members': ['adult'], 'state_code': {YEAR: 'UT'}}}, + 'families': {'family': {'members': ['adult']}}, + 'marital_units': {'marital_unit': {'members': ['adult']}}, + 'axes': [[{'name': 'employment_income', 'count': 500, 'min': 0, 'max': 120000}]], + } + + # Parent + child situation with axes + parent_situation = { + 'people': { + 'parent': { + 'age': {YEAR: 30}, + 'monthly_hours_worked': {YEAR: 100}, + }, + 'child': {'age': {YEAR: 8}}, + }, + 'tax_units': {'tax_unit': {'members': ['parent', 'child']}}, + 'spm_units': {'spm_unit': {'members': ['parent', 'child']}}, + 'households': {'household': {'members': ['parent', 'child'], 'state_code': {YEAR: 'UT'}}}, + 'families': {'family': {'members': ['parent', 'child']}}, + 'marital_units': {'marital_unit': {'members': ['parent']}}, + 'axes': [[{'name': 'employment_income', 'count': 500, 'min': 0, 'max': 120000}]], + } + + print('Creating simulations...') + single_base = Simulation(situation=single_situation) + single_reform = Simulation(situation=single_situation, reform=create_ut_medicaid_expansion_repeal()) + parent_base = Simulation(situation=parent_situation) + parent_reform = Simulation(situation=parent_situation, reform=create_ut_medicaid_expansion_repeal()) + + print('Calculating single adult data...') + single_income = single_base.calculate('employment_income', YEAR) + single_baseline_medicaid = single_base.calculate('medicaid', YEAR, map_to='person') + single_baseline_ptc = single_base.calculate('premium_tax_credit', YEAR, map_to='person') + single_reform_medicaid = single_reform.calculate('medicaid', YEAR, map_to='person') + single_reform_ptc = single_reform.calculate('premium_tax_credit', YEAR, map_to='person') + + print('Calculating parent+child data...') + parent_income = parent_base.calculate('employment_income', YEAR) + # Parent is every other starting at 0, child is every other starting at 1 + parent_baseline_medicaid = parent_base.calculate('medicaid', YEAR, map_to='person')[::2] + parent_baseline_ptc = parent_base.calculate('premium_tax_credit', YEAR, map_to='person')[::2] + parent_reform_medicaid = parent_reform.calculate('medicaid', YEAR, map_to='person')[::2] + parent_reform_ptc = parent_reform.calculate('premium_tax_credit', YEAR, map_to='person')[::2] + # Child coverage = Medicaid + CHIP (same in baseline and reform) + child_medicaid = parent_base.calculate('medicaid', YEAR, map_to='person')[1::2] + child_chip = parent_base.calculate('chip', YEAR, map_to='person')[1::2] + child_coverage = child_medicaid + child_chip + parent_income = parent_income[::2] + + # Single Adult Chart + print('Creating single adult chart...') + fig = go.Figure() + fig.add_trace(go.Scatter(x=single_income, y=single_baseline_medicaid, mode='lines', name='Medicaid (Baseline)', line=dict(color=TEAL_ACCENT, width=2))) + fig.add_trace(go.Scatter(x=single_income, y=single_baseline_ptc, mode='lines', name='ACA PTC (Baseline)', line=dict(color=BLUE_PRIMARY, width=2))) + fig.add_trace(go.Scatter(x=single_income, y=single_reform_medicaid, mode='lines', name='Medicaid (Reform)', line=dict(color=TEAL_ACCENT, width=2, dash='dot'))) + fig.add_trace(go.Scatter(x=single_income, y=single_reform_ptc, mode='lines', name='ACA PTC (Reform)', line=dict(color=BLUE_PRIMARY, width=2, dash='dot'))) + fig.update_layout( + title='Single Adult: Health Benefits by Income', + xaxis_title='Household Income', + yaxis_title='Benefit Amount', + xaxis=dict(tickformat='$,.0f', range=[0, 120000]), + yaxis=dict(tickformat='$,.0f'), + height=600, + width=1000, + legend=dict(orientation='h', yanchor='bottom', y=-0.2, xanchor='center', x=0.5), + ) + fig = format_fig(fig) + fig.write_image('hb15_single_adult.png', scale=2) + print(' Saved hb15_single_adult.png') + + # Parent + Child Chart (including child's Medicaid/CHIP) + print('Creating parent+child chart...') + fig = go.Figure() + fig.add_trace(go.Scatter(x=parent_income, y=parent_baseline_medicaid, mode='lines', name='Parent Medicaid (Baseline)', line=dict(color=TEAL_ACCENT, width=2))) + fig.add_trace(go.Scatter(x=parent_income, y=child_coverage, mode='lines', name='Child Medicaid/CHIP', line=dict(color=GRAY, width=2))) + fig.add_trace(go.Scatter(x=parent_income, y=parent_baseline_ptc, mode='lines', name='ACA PTC (Baseline)', line=dict(color=BLUE_PRIMARY, width=2))) + fig.add_trace(go.Scatter(x=parent_income, y=parent_reform_medicaid, mode='lines', name='Parent Medicaid (Reform)', line=dict(color=TEAL_ACCENT, width=2, dash='dot'))) + fig.add_trace(go.Scatter(x=parent_income, y=parent_reform_ptc, mode='lines', name='ACA PTC (Reform)', line=dict(color=BLUE_PRIMARY, width=2, dash='dot'))) + fig.update_layout( + title='Single Parent + Child: Health Benefits by Income', + xaxis_title='Household Income', + yaxis_title='Benefit Amount', + xaxis=dict(tickformat='$,.0f', range=[0, 120000]), + yaxis=dict(tickformat='$,.0f'), + height=600, + width=1000, + legend=dict(orientation='h', yanchor='bottom', y=-0.2, xanchor='center', x=0.5), + ) + fig = format_fig(fig) + fig.write_image('hb15_parent_child.png', scale=2) + print(' Saved hb15_parent_child.png') + + print('Done with charts!') + + +def main(): + generate_table_data() + print() + generate_charts() + + +if __name__ == '__main__': + main() diff --git a/us/states/ut/hb15_benefit_change.png b/us/states/ut/hb15_benefit_change.png new file mode 100644 index 0000000..396e524 Binary files /dev/null and b/us/states/ut/hb15_benefit_change.png differ diff --git a/us/states/ut/hb15_benefits_by_household.png b/us/states/ut/hb15_benefits_by_household.png new file mode 100644 index 0000000..79dcc1f Binary files /dev/null and b/us/states/ut/hb15_benefits_by_household.png differ diff --git a/us/states/ut/hb15_benefits_comparison.png b/us/states/ut/hb15_benefits_comparison.png new file mode 100644 index 0000000..fbafdac Binary files /dev/null and b/us/states/ut/hb15_benefits_comparison.png differ diff --git a/us/states/ut/hb15_parent_child.png b/us/states/ut/hb15_parent_child.png new file mode 100644 index 0000000..3cb84ad Binary files /dev/null and b/us/states/ut/hb15_parent_child.png differ diff --git a/us/states/ut/hb15_single_adult.png b/us/states/ut/hb15_single_adult.png new file mode 100644 index 0000000..811aa82 Binary files /dev/null and b/us/states/ut/hb15_single_adult.png differ diff --git a/us/states/ut/utah_hb15_aca_transition_investigation.py b/us/states/ut/utah_hb15_aca_transition_investigation.py new file mode 100644 index 0000000..df3fcdb --- /dev/null +++ b/us/states/ut/utah_hb15_aca_transition_investigation.py @@ -0,0 +1,300 @@ +""" +Utah HB 15 - ACA Transition Investigation +========================================= + +This script investigates why so few people (~489) gain ACA Premium Tax +Credit eligibility when ~48,000 lose Medicaid under Utah HB 15. + +Key Finding (using Utah-calibrated dataset with 93% takeup): +------------------------------------------------------------- +Of ~117,000 losing Medicaid enrollment: +- 77% are below 100% FPL -> fall into "coverage gap" (no ACA available) +- 23% are at 100-138% FPL -> could potentially get ACA + - Some of these have ESI coverage + - ~26,700 actually gain ACA eligibility + +Note: The Utah-calibrated dataset gives much more plausible results +than the national CPS, which showed 76% ESI at 100-138% FPL. + +Author: PolicyEngine +Date: January 2025 +""" + +import numpy as np +from policyengine_us import Microsimulation +from policyengine_core.periods import instant +from policyengine_core.reforms import Reform + +# ============================================================================= +# Configuration +# ============================================================================= + +YEAR = 2027 + +# Use Utah-specific calibrated dataset (more accurate than national CPS) +# See: https://huggingface.co/policyengine/policyengine-us-data/tree/main/states +UT_DATASET = "hf://policyengine/policyengine-us-data/states/UT.h5" + + +# ============================================================================= +# Define Reform +# ============================================================================= + +def create_ut_medicaid_expansion_repeal(): + """Repeal Utah Medicaid expansion by setting income limit to -inf.""" + def modify_parameters(parameters): + parameters.gov.hhs.medicaid.eligibility.categories.adult.income_limit.UT.update( + start=instant(f"{YEAR}-01-01"), + stop=instant("2100-12-31"), + value=float("-inf"), + ) + return parameters + + class reform(Reform): + def apply(self): + self.modify_parameters(modify_parameters) + + return reform + + +# ============================================================================= +# Analysis Functions +# ============================================================================= + +def analyze_income_distribution(baseline, weights): + """ + Analyze income distribution of expansion Medicaid adults. + + This explains why most people fall into the coverage gap: + ACA subsidies start at 100% FPL, but most expansion adults + are below that threshold. + """ + print("=" * 70) + print("PART 1: Income Distribution of Expansion Medicaid Adults") + print("=" * 70) + print() + + # Get expansion Medicaid adults + is_adult_medicaid = baseline.calculate("is_adult_for_medicaid", YEAR).values + + # Get income as % of FPL + medicaid_income = baseline.calculate("medicaid_income_level", YEAR).values + + # Filter to expansion adults + expansion_income = medicaid_income[is_adult_medicaid] + expansion_weights = weights[is_adult_medicaid] + + print("Medicaid Income Level People % of Total") + print("-" * 70) + + brackets = [ + (float("-inf"), 0.5, "Below 50% FPL"), + (0.5, 1.0, "50-100% FPL"), + (1.0, 1.38, "100-138% FPL"), + ] + + total = expansion_weights.sum() + cumulative = 0 + + for low, high, label in brackets: + mask = (expansion_income >= low) & (expansion_income < high) + count = expansion_weights[mask].sum() + cumulative += count + pct = count / total * 100 + print(f"{label:<25} {count:>12,.0f} {pct:>5.1f}%") + + print("-" * 70) + print(f"{'Total':<25} {total:>12,.0f}") + print() + + below_100 = expansion_weights[expansion_income < 1.0].sum() + print(f"KEY: {below_100/total*100:.0f}% are below 100% FPL (coverage gap)") + print(f" Only {(total-below_100)/total*100:.0f}% could potentially get ACA (100-138% FPL)") + print() + + return { + "total_expansion_adults": total, + "below_100_fpl": below_100, + "pct_below_100_fpl": below_100 / total * 100, + } + + +def analyze_aca_transitions(baseline, reform_sim, weights): + """ + Analyze why people at 100-138% FPL don't all get ACA. + + Finding: Most already have employer-sponsored insurance (ESI). + """ + print("=" * 70) + print("PART 2: Why Don't All 100-138% FPL People Get ACA?") + print("=" * 70) + print() + + # Get people who lose Medicaid (using enrolled which applies 93% takeup) + baseline_medicaid = baseline.calculate("medicaid_enrolled", YEAR).values + reform_medicaid = reform_sim.calculate("medicaid_enrolled", YEAR).values + loses_medicaid = baseline_medicaid & ~reform_medicaid + + # Get income level + medicaid_income = baseline.calculate("medicaid_income_level", YEAR).values + + # Filter to 100-138% FPL (the ACA-eligible income range) + in_aca_range = (medicaid_income >= 1.0) & (medicaid_income < 1.38) + loses_medicaid_in_range = loses_medicaid & in_aca_range + + # Check ACA eligibility in reform + baseline_ptc = baseline.calculate("is_aca_ptc_eligible", YEAR).values + reform_ptc = reform_sim.calculate("is_aca_ptc_eligible", YEAR).values + + # Categorize outcomes + gains_aca = loses_medicaid_in_range & ~baseline_ptc & reform_ptc + not_aca = loses_medicaid_in_range & ~reform_ptc + + total_in_range = (loses_medicaid_in_range.astype(float) * weights).sum() + gains_aca_count = (gains_aca.astype(float) * weights).sum() + not_aca_count = (not_aca.astype(float) * weights).sum() + + print(f"People losing Medicaid at 100-138% FPL: {total_in_range:>10,.0f}") + print(f" Gain ACA PTC eligibility: {gains_aca_count:>10,.0f} ({gains_aca_count/total_in_range*100:.0f}%)") + print(f" Do NOT gain ACA eligibility: {not_aca_count:>10,.0f} ({not_aca_count/total_in_range*100:.0f}%)") + print() + + # Investigate why they don't get ACA + print("Why don't the remaining people qualify for ACA PTC?") + print("-" * 70) + + # Check ESI coverage + has_esi = baseline.calculate("has_esi", YEAR).values + esi_count = ((not_aca & has_esi).astype(float) * weights).sum() + print(f"Have employer coverage (ESI): {esi_count:>10,.0f} ({esi_count/not_aca_count*100:.0f}%)") + + # Check disqualifying ESI offer + disq_esi = baseline.calculate("offered_aca_disqualifying_esi", YEAR).values + disq_esi_count = ((not_aca & disq_esi).astype(float) * weights).sum() + print(f"Offered disqualifying ESI: {disq_esi_count:>10,.0f}") + + # Check Medicare + is_medicare = baseline.calculate("is_medicare_eligible", YEAR).values + medicare_count = ((not_aca & is_medicare).astype(float) * weights).sum() + print(f"Medicare eligible: {medicare_count:>10,.0f}") + + # Check dependents + is_dependent = baseline.calculate("is_tax_unit_dependent", YEAR).values + dependent_count = ((not_aca & is_dependent).astype(float) * weights).sum() + print(f"Tax unit dependents: {dependent_count:>10,.0f}") + + # Check incarceration + is_incarcerated = baseline.calculate("is_incarcerated", YEAR).values + incarcerated_count = ((not_aca & is_incarcerated).astype(float) * weights).sum() + print(f"Incarcerated: {incarcerated_count:>10,.0f}") + + print() + + return { + "total_100_138_fpl": total_in_range, + "gains_aca": gains_aca_count, + "not_aca_eligible": not_aca_count, + "have_esi": esi_count, + "pct_with_esi": esi_count / not_aca_count * 100 if not_aca_count > 0 else 0, + } + + +def analyze_esi_at_low_income(baseline, weights): + """ + Investigate the surprisingly high ESI rate at 100-138% FPL. + + This seems high - 76% ESI coverage for people making ~$16k/year. + May warrant investigation into microdata/imputation methods. + """ + print("=" * 70) + print("PART 3: ESI Coverage Investigation at Low Income") + print("=" * 70) + print() + + # Get expansion Medicaid adults + is_adult_medicaid = baseline.calculate("is_adult_for_medicaid", YEAR).values + medicaid_income = baseline.calculate("medicaid_income_level", YEAR).values + has_esi = baseline.calculate("has_esi", YEAR).values + + # Check ESI rates by income bracket + print("ESI Coverage Rate by Income Level (Expansion Medicaid Adults)") + print("-" * 70) + + brackets = [ + (float("-inf"), 0.5, "Below 50% FPL"), + (0.5, 1.0, "50-100% FPL"), + (1.0, 1.38, "100-138% FPL"), + ] + + for low, high, label in brackets: + in_bracket = is_adult_medicaid & (medicaid_income >= low) & (medicaid_income < high) + bracket_weights = weights[in_bracket] + esi_weights = weights[in_bracket & has_esi] + + total = bracket_weights.sum() + with_esi = esi_weights.sum() + pct = with_esi / total * 100 if total > 0 else 0 + + print(f"{label:<25} {with_esi:>10,.0f} / {total:>10,.0f} = {pct:>5.1f}% with ESI") + + print() + print("NOTE: The high ESI rate at 100-138% FPL (~$16k/year) seems") + print(" surprisingly high and may warrant investigation into") + print(" microdata/imputation methods.") + print() + + +def run_investigation(): + """Run the full ACA transition investigation.""" + + print("Loading simulations...") + print("(This may take a moment)\n") + + baseline = Microsimulation(dataset=UT_DATASET) + reform_sim = Microsimulation(dataset=UT_DATASET, reform=create_ut_medicaid_expansion_repeal()) + + weights = baseline.calculate("person_weight", YEAR).values + + # Run analyses + income_results = analyze_income_distribution(baseline, weights) + aca_results = analyze_aca_transitions(baseline, reform_sim, weights) + analyze_esi_at_low_income(baseline, weights) + + # Summary + print("=" * 70) + print("SUMMARY (Utah-Calibrated Dataset)") + print("=" * 70) + print(""" +Why do only ~26,700 people gain ACA eligibility when ~117,000 lose Medicaid? + +1. COVERAGE GAP (77% of those losing Medicaid): + - Below 100% FPL + - ACA subsidies don't exist below 100% FPL + - ~90,500 people have NO coverage option + +2. ALREADY HAVE ESI (some of those at 100-138% FPL): + - Already have employer-sponsored insurance + - Disqualified from ACA Premium Tax Credits + - They keep their ESI when losing Medicaid + +3. GAIN ACA (~26,700 people, 23% of total): + - At 100-138% FPL + - Don't have ESI or other disqualifying coverage + - These transition to ACA subsidies (~$160M/year federal cost) + +Note: Using medicaid_enrolled (with 93% takeup rate) rather than +is_medicaid_eligible for more realistic coverage counts. +""") + + return { + "income": income_results, + "aca": aca_results, + } + + +# ============================================================================= +# Main Entry Point +# ============================================================================= + +if __name__ == "__main__": + results = run_investigation() diff --git a/us/states/ut/utah_hb15_example_households.ipynb b/us/states/ut/utah_hb15_example_households.ipynb new file mode 100644 index 0000000..0b37e3a --- /dev/null +++ b/us/states/ut/utah_hb15_example_households.ipynb @@ -0,0 +1,149 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Utah HB 15 - Example Household Impacts\n", + "\n", + "This notebook shows how Utah's proposed Medicaid expansion repeal (HB 15) would affect different household types.\n", + "\n", + "**Key takeaway:** Whether someone falls into the \"coverage gap\" or transitions to ACA depends primarily on their income relative to the Federal Poverty Level (FPL):\n", + "- Below 100% FPL → Coverage gap (no ACA available)\n", + "- 100-138% FPL → Can transition to ACA subsidies\n", + "\n", + "**Note:** These examples assume federal Medicaid work requirements (80+ hours/month) are in effect, as modeled for 2027. Parents with children under 13 are exempt from work requirements." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "from policyengine_us import Simulation\nfrom policyengine_core.periods import instant\nfrom policyengine_core.reforms import Reform\nimport plotly.graph_objects as go\nimport numpy as np\nfrom policyengine_core.charts import format_fig\n\nYEAR = 2027\n\n# PolicyEngine chart colors\nGRAY = \"#808080\"\nBLUE_PRIMARY = \"#2C6496\"\nTEAL_ACCENT = \"#39C6C0\"\nDARK_GRAY = \"#616161\"\n\ndef create_ut_medicaid_expansion_repeal():\n \"\"\"Repeal Utah Medicaid expansion by setting income limit to -inf.\"\"\"\n def modify_parameters(parameters):\n parameters.gov.hhs.medicaid.eligibility.categories.adult.income_limit.UT.update(\n start=instant(f\"{YEAR}-01-01\"),\n stop=instant(\"2100-12-31\"),\n value=float(\"-inf\"),\n )\n return parameters\n\n class reform(Reform):\n def apply(self):\n self.modify_parameters(modify_parameters)\n\n return reform" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "def analyze_household(situation, name):\n \"\"\"Analyze a household under baseline and reform.\"\"\"\n baseline = Simulation(situation=situation)\n reformed = Simulation(situation=situation, reform=create_ut_medicaid_expansion_repeal())\n \n people = list(situation[\"people\"].keys())\n \n print(f\"{'='*60}\")\n print(f\"{name}\")\n print(f\"{'='*60}\")\n \n for person in people:\n age = situation[\"people\"][person].get(\"age\", {}).get(YEAR, \"N/A\")\n \n b_medicaid = baseline.calculate(\"medicaid\", YEAR, map_to=\"person\")[people.index(person)]\n b_ptc_elig = baseline.calculate(\"is_aca_ptc_eligible\", YEAR)[people.index(person)]\n r_medicaid = reformed.calculate(\"medicaid\", YEAR, map_to=\"person\")[people.index(person)]\n r_ptc_elig = reformed.calculate(\"is_aca_ptc_eligible\", YEAR)[people.index(person)]\n \n print(f\"\\n{person} (age {age}):\")\n print(f\" Baseline: Medicaid=${b_medicaid:,.0f}/yr, ACA eligible={b_ptc_elig}\")\n print(f\" Reform: Medicaid=${r_medicaid:,.0f}/yr, ACA eligible={r_ptc_elig}\")\n \n if b_medicaid > 0 and r_medicaid == 0:\n if r_ptc_elig:\n print(f\" → LOSES MEDICAID, GAINS ACA ELIGIBILITY\")\n else:\n print(f\" → FALLS INTO COVERAGE GAP\")\n \n b_ptc = baseline.calculate(\"premium_tax_credit\", YEAR)[0]\n r_ptc = reformed.calculate(\"premium_tax_credit\", YEAR)[0]\n \n print(f\"\\nHousehold Premium Tax Credit:\")\n print(f\" Baseline: ${b_ptc:,.0f}/yr\")\n print(f\" Reform: ${r_ptc:,.0f}/yr\")\n print()" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Household 1: Single Adult Below Poverty Line → Coverage Gap\n", + "\n", + "A single adult earning $12,000/year (about 75% FPL). Under current law, they qualify for Medicaid expansion. If expansion is repealed, they fall into the coverage gap because ACA subsidies only start at 100% FPL." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "household_1 = {\n \"people\": {\n \"adult\": {\n \"age\": {YEAR: 35},\n \"employment_income\": {YEAR: 12_000},\n \"monthly_hours_worked\": {YEAR: 100},\n }\n },\n \"tax_units\": {\"tax_unit\": {\"members\": [\"adult\"]}},\n \"spm_units\": {\"spm_unit\": {\"members\": [\"adult\"]}},\n \"households\": {\"household\": {\"members\": [\"adult\"], \"state_code\": {YEAR: \"UT\"}}},\n \"families\": {\"family\": {\"members\": [\"adult\"]}},\n \"marital_units\": {\"marital_unit\": {\"members\": [\"adult\"]}},\n}\n\nanalyze_household(household_1, \"HOUSEHOLD 1: Single adult, $12k/yr (75% FPL) → COVERAGE GAP\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Household 2: Single Adult Above Poverty Line → ACA Transition\n", + "\n", + "A single adult earning $18,000/year (about 112% FPL). Under current law, they qualify for Medicaid expansion. If expansion is repealed, they can transition to ACA marketplace coverage with premium subsidies." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "household_2 = {\n \"people\": {\n \"adult\": {\n \"age\": {YEAR: 35},\n \"employment_income\": {YEAR: 18_000},\n \"monthly_hours_worked\": {YEAR: 100},\n }\n },\n \"tax_units\": {\"tax_unit\": {\"members\": [\"adult\"]}},\n \"spm_units\": {\"spm_unit\": {\"members\": [\"adult\"]}},\n \"households\": {\"household\": {\"members\": [\"adult\"], \"state_code\": {YEAR: \"UT\"}}},\n \"families\": {\"family\": {\"members\": [\"adult\"]}},\n \"marital_units\": {\"marital_unit\": {\"members\": [\"adult\"]}},\n}\n\nanalyze_household(household_2, \"HOUSEHOLD 2: Single adult, $18k/yr (112% FPL) → ACA TRANSITION\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Household 3: Parent with Child Below Poverty Line → Coverage Gap for Parent\n", + "\n", + "A single parent with one child, earning $15,000/year (about 68% FPL for family of 2). The parent loses Medicaid expansion and falls into the coverage gap. The child remains eligible for Medicaid/CHIP (children's eligibility is separate from expansion)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "household_3 = {\n \"people\": {\n \"parent\": {\n \"age\": {YEAR: 30},\n \"employment_income\": {YEAR: 15_000},\n \"monthly_hours_worked\": {YEAR: 100},\n },\n \"child\": {\"age\": {YEAR: 8}},\n },\n \"tax_units\": {\"tax_unit\": {\"members\": [\"parent\", \"child\"]}},\n \"spm_units\": {\"spm_unit\": {\"members\": [\"parent\", \"child\"]}},\n \"households\": {\"household\": {\"members\": [\"parent\", \"child\"], \"state_code\": {YEAR: \"UT\"}}},\n \"families\": {\"family\": {\"members\": [\"parent\", \"child\"]}},\n \"marital_units\": {\"marital_unit\": {\"members\": [\"parent\"]}},\n}\n\nanalyze_household(household_3, \"HOUSEHOLD 3: Single parent + child, $15k/yr (68% FPL) → PARENT IN COVERAGE GAP\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Household 4: Couple with Children Above Poverty Line → ACA Transition\n", + "\n", + "A married couple with two children, earning $38,000/year (about 115% FPL for family of 4). Both parents lose Medicaid expansion but can transition to ACA. Children remain on Medicaid/CHIP." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "household_4 = {\n \"people\": {\n \"parent1\": {\n \"age\": {YEAR: 40},\n \"employment_income\": {YEAR: 38_000},\n \"monthly_hours_worked\": {YEAR: 160},\n },\n \"parent2\": {\n \"age\": {YEAR: 38},\n \"monthly_hours_worked\": {YEAR: 0},\n },\n \"child1\": {\"age\": {YEAR: 12}},\n \"child2\": {\"age\": {YEAR: 7}},\n },\n \"tax_units\": {\"tax_unit\": {\"members\": [\"parent1\", \"parent2\", \"child1\", \"child2\"]}},\n \"spm_units\": {\"spm_unit\": {\"members\": [\"parent1\", \"parent2\", \"child1\", \"child2\"]}},\n \"households\": {\"household\": {\"members\": [\"parent1\", \"parent2\", \"child1\", \"child2\"], \"state_code\": {YEAR: \"UT\"}}},\n \"families\": {\"family\": {\"members\": [\"parent1\", \"parent2\", \"child1\", \"child2\"]}},\n \"marital_units\": {\"marital_unit\": {\"members\": [\"parent1\", \"parent2\"]}},\n}\n\nanalyze_household(household_4, \"HOUSEHOLD 4: Married couple + 2 kids, $38k/yr (115% FPL) → ACA TRANSITION\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Visualizing the Impact\n\nThe graphs below compare how the reform affects two different household types across income levels:\n- **Single adult** (no children)\n- **Single parent with one child**" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Generate data across income spectrum for both household types\nincomes = np.arange(5000, 40001, 1000)\nfpl_single = 16334 # FPL for 1 person in 2027\nfpl_parent_child = 22138 # FPL for 2 people in 2027\n\n# Single adult data\nsingle_baseline_medicaid, single_baseline_ptc = [], []\nsingle_reform_medicaid, single_reform_ptc = [], []\n\n# Parent + child data\nparent_baseline_medicaid, parent_baseline_ptc = [], []\nparent_reform_medicaid, parent_reform_ptc = [], []\n\nfor income in incomes:\n # Single adult household\n single_situation = {\n \"people\": {\"adult\": {\n \"age\": {YEAR: 35},\n \"employment_income\": {YEAR: int(income)},\n \"monthly_hours_worked\": {YEAR: 100},\n }},\n \"tax_units\": {\"tax_unit\": {\"members\": [\"adult\"]}},\n \"spm_units\": {\"spm_unit\": {\"members\": [\"adult\"]}},\n \"households\": {\"household\": {\"members\": [\"adult\"], \"state_code\": {YEAR: \"UT\"}}},\n \"families\": {\"family\": {\"members\": [\"adult\"]}},\n \"marital_units\": {\"marital_unit\": {\"members\": [\"adult\"]}},\n }\n \n base = Simulation(situation=single_situation)\n ref = Simulation(situation=single_situation, reform=create_ut_medicaid_expansion_repeal())\n \n single_baseline_medicaid.append(base.calculate(\"medicaid\", YEAR, map_to=\"person\")[0])\n single_baseline_ptc.append(base.calculate(\"premium_tax_credit\", YEAR)[0])\n single_reform_medicaid.append(ref.calculate(\"medicaid\", YEAR, map_to=\"person\")[0])\n single_reform_ptc.append(ref.calculate(\"premium_tax_credit\", YEAR)[0])\n \n # Parent + child household\n parent_situation = {\n \"people\": {\n \"parent\": {\n \"age\": {YEAR: 30},\n \"employment_income\": {YEAR: int(income)},\n \"monthly_hours_worked\": {YEAR: 100},\n },\n \"child\": {\"age\": {YEAR: 8}},\n },\n \"tax_units\": {\"tax_unit\": {\"members\": [\"parent\", \"child\"]}},\n \"spm_units\": {\"spm_unit\": {\"members\": [\"parent\", \"child\"]}},\n \"households\": {\"household\": {\"members\": [\"parent\", \"child\"], \"state_code\": {YEAR: \"UT\"}}},\n \"families\": {\"family\": {\"members\": [\"parent\", \"child\"]}},\n \"marital_units\": {\"marital_unit\": {\"members\": [\"parent\"]}},\n }\n \n base = Simulation(situation=parent_situation)\n ref = Simulation(situation=parent_situation, reform=create_ut_medicaid_expansion_repeal())\n \n # Only count parent's medicaid (child stays on Medicaid/CHIP)\n parent_baseline_medicaid.append(base.calculate(\"medicaid\", YEAR, map_to=\"person\")[0])\n parent_baseline_ptc.append(base.calculate(\"premium_tax_credit\", YEAR)[0])\n parent_reform_medicaid.append(ref.calculate(\"medicaid\", YEAR, map_to=\"person\")[0])\n parent_reform_ptc.append(ref.calculate(\"premium_tax_credit\", YEAR)[0])\n\n# Convert to arrays\nsingle_baseline_medicaid = np.array(single_baseline_medicaid)\nsingle_baseline_ptc = np.array(single_baseline_ptc)\nsingle_reform_medicaid = np.array(single_reform_medicaid)\nsingle_reform_ptc = np.array(single_reform_ptc)\n\nparent_baseline_medicaid = np.array(parent_baseline_medicaid)\nparent_baseline_ptc = np.array(parent_baseline_ptc)\nparent_reform_medicaid = np.array(parent_reform_medicaid)\nparent_reform_ptc = np.array(parent_reform_ptc)\n\n# Calculate totals\nsingle_baseline_total = single_baseline_medicaid + single_baseline_ptc\nsingle_reform_total = single_reform_medicaid + single_reform_ptc\nparent_baseline_total = parent_baseline_medicaid + parent_baseline_ptc\nparent_reform_total = parent_reform_medicaid + parent_reform_ptc" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Single Adult Chart\nincomes_arr = np.array(incomes)\n\nfig = go.Figure()\n\nfig.add_trace(go.Scatter(\n x=incomes_arr/1000, y=single_baseline_medicaid,\n mode='lines', name='Medicaid (Baseline)',\n line=dict(color=TEAL_ACCENT, width=2),\n))\n\nfig.add_trace(go.Scatter(\n x=incomes_arr/1000, y=single_baseline_ptc,\n mode='lines', name='ACA PTC (Baseline)',\n line=dict(color=BLUE_PRIMARY, width=2),\n))\n\nfig.add_trace(go.Scatter(\n x=incomes_arr/1000, y=single_reform_medicaid,\n mode='lines', name='Medicaid (Reform)',\n line=dict(color=TEAL_ACCENT, width=2, dash='dot'),\n))\n\nfig.add_trace(go.Scatter(\n x=incomes_arr/1000, y=single_reform_ptc,\n mode='lines', name='ACA PTC (Reform)',\n line=dict(color=BLUE_PRIMARY, width=2, dash='dot'),\n))\n\nfig.update_layout(\n title=\"Single Adult: Health Benefits by Income\",\n xaxis_title=\"Household Income ($1,000s)\",\n yaxis_title=\"Annual Benefit\",\n yaxis=dict(tickformat=\"$,.0f\"),\n height=500,\n width=800,\n legend=dict(orientation=\"h\", yanchor=\"bottom\", y=-0.25, xanchor=\"center\", x=0.5),\n)\n\nfig = format_fig(fig)\nfig.show()\nfig.write_image(\"hb15_single_adult.png\", scale=2)" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Parent + Child Chart\nfig = go.Figure()\n\nfig.add_trace(go.Scatter(\n x=incomes_arr/1000, y=parent_baseline_medicaid,\n mode='lines', name='Medicaid (Baseline)',\n line=dict(color=TEAL_ACCENT, width=2),\n))\n\nfig.add_trace(go.Scatter(\n x=incomes_arr/1000, y=parent_baseline_ptc,\n mode='lines', name='ACA PTC (Baseline)',\n line=dict(color=BLUE_PRIMARY, width=2),\n))\n\nfig.add_trace(go.Scatter(\n x=incomes_arr/1000, y=parent_reform_medicaid,\n mode='lines', name='Medicaid (Reform)',\n line=dict(color=TEAL_ACCENT, width=2, dash='dot'),\n))\n\nfig.add_trace(go.Scatter(\n x=incomes_arr/1000, y=parent_reform_ptc,\n mode='lines', name='ACA PTC (Reform)',\n line=dict(color=BLUE_PRIMARY, width=2, dash='dot'),\n))\n\nfig.update_layout(\n title=\"Single Parent + Child: Health Benefits by Income\",\n xaxis_title=\"Household Income ($1,000s)\",\n yaxis_title=\"Annual Benefit\",\n yaxis=dict(tickformat=\"$,.0f\"),\n height=500,\n width=800,\n legend=dict(orientation=\"h\", yanchor=\"bottom\", y=-0.25, xanchor=\"center\", x=0.5),\n)\n\nfig = format_fig(fig)\nfig.show()\nfig.write_image(\"hb15_parent_child.png\", scale=2)" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Key Takeaways\n\n1. **Coverage Gap**: At low incomes, adults lose Medicaid with no replacement. The coverage gap is wider for the parent+child household due to higher FPL thresholds.\n\n2. **ACA Transition**: At higher incomes (above 100% FPL for their household size), people can transition to ACA subsidies which partially offset the Medicaid loss.\n\n3. **Children Protected**: Children remain on Medicaid/CHIP regardless of the reform - only adult coverage is affected." + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/us/states/ut/utah_hb15_medicaid_expansion_repeal.py b/us/states/ut/utah_hb15_medicaid_expansion_repeal.py new file mode 100644 index 0000000..fc0653e --- /dev/null +++ b/us/states/ut/utah_hb15_medicaid_expansion_repeal.py @@ -0,0 +1,351 @@ +""" +Utah HB 15 (2026) - Medicaid Expansion Repeal Analysis +====================================================== + +This script analyzes the impact of Utah HB 15, which would repeal +Medicaid expansion if federal matching falls below 85%. + +Bill Reference: https://le.utah.gov/~2026/bills/static/HB0015.html + +Important Context: +------------------ +HB 15 does NOT automatically repeal Medicaid expansion. It creates a +contingent repeal that triggers only if federal FMAP drops below 85%. +This analysis models the scenario WHERE THE TRIGGER CONDITION IS MET +and expansion is repealed. + +What HB 15 actually does: +- Repeals expansion IF federal matching falls below 85% +- Gives state 60 days to implement coverage changes after trigger +- Repeals the 0.15% sales tax that funds expansion (not modeled here) +- Effective date: May 6, 2026 + +What this analysis models: +- Removal of expansion Medicaid eligibility for adults +- Coverage gap impact (people losing Medicaid who don't qualify for ACA) +- Fiscal impact (federal/state savings) + +What this analysis does NOT model: +- The 60-day implementation window (minor for annual analysis) +- The 0.15% sales tax repeal (PolicyEngine doesn't model sales taxes) + +Reform Approach: +---------------- +This analysis uses a simple parametric reform. Utah's Medicaid expansion +eligibility is controlled by the parameter: + + gov.hhs.medicaid.eligibility.categories.adult.income_limit.UT + +- Current value: 1.38 (138% FPL - expansion enabled) +- Reform value: -inf (no one qualifies - expansion repealed) + +No structural reform code is needed - just a parameter change. + +Key Findings (using Utah-calibrated dataset with 93% takeup rate): +- ~117,000 people would lose Medicaid enrollment +- ~90,500 would fall into the "coverage gap" (no ACA subsidies available) +- ~26,700 would gain ACA Premium Tax Credit eligibility +- Utah would save ~$99 million/year (10% state share) +- Federal government would save ~$729 million/year (net of increased ACA costs) + +Author: PolicyEngine +Date: January 2025 +""" + +import numpy as np +from policyengine_us import Microsimulation +from policyengine_core.periods import instant + +# ============================================================================= +# Configuration +# ============================================================================= + +YEAR = 2027 # Analysis year +FEDERAL_FMAP_EXPANSION = 0.90 # Federal share of expansion Medicaid +STATE_FMAP_EXPANSION = 0.10 # State share of expansion Medicaid + +# Use Utah-specific calibrated dataset (more accurate than national CPS) +# See: https://huggingface.co/policyengine/policyengine-us-data/tree/main/states +UT_DATASET = "hf://policyengine/policyengine-us-data/states/UT.h5" + + +# ============================================================================= +# Define Reform (Simple Parametric Approach) +# ============================================================================= + +def create_ut_medicaid_expansion_repeal(): + """ + Create a reform that repeals Utah's Medicaid expansion. + + This is a simple parametric reform that sets Utah's adult Medicaid + income limit to -inf (meaning no income level qualifies). + + The parameter being modified is: + gov.hhs.medicaid.eligibility.categories.adult.income_limit.UT + + Current law: 1.38 (138% FPL) + Reform: -inf (no eligibility) + """ + from policyengine_core.reforms import Reform + + def modify_parameters(parameters): + # Set Utah's adult Medicaid income limit to -inf + # This effectively removes expansion Medicaid eligibility + parameters.gov.hhs.medicaid.eligibility.categories.adult.income_limit.UT.update( + start=instant(f"{YEAR}-01-01"), + stop=instant("2100-12-31"), + value=float("-inf"), + ) + return parameters + + class reform(Reform): + def apply(self): + self.modify_parameters(modify_parameters) + + return reform + + +# ============================================================================= +# Run Analysis +# ============================================================================= + +def run_analysis(): + """Run the full Utah HB 15 Medicaid expansion repeal analysis.""" + + print("Loading simulations...") + print("(This may take a moment to download microdata)\n") + + # Baseline: Current law with Medicaid expansion + # Using Utah-calibrated dataset for more accurate state-level estimates + baseline = Microsimulation(dataset=UT_DATASET) + + # Reform: Medicaid expansion repealed (simple parametric change) + reform = Microsimulation(dataset=UT_DATASET, reform=create_ut_medicaid_expansion_repeal()) + + # ========================================================================= + # Extract Data + # ========================================================================= + + # Person-level weights + person_weights = baseline.calculate("person_weight", YEAR).values + + # Tax unit-level weights (for ACA PTC) + tu_weights = baseline.calculate("tax_unit_weight", YEAR).values + + # Medicaid enrollment (accounts for 93% takeup rate) + baseline_medicaid_enrolled = baseline.calculate( + "medicaid_enrolled", YEAR + ).values + reform_medicaid_enrolled = reform.calculate( + "medicaid_enrolled", YEAR + ).values + + # Adult expansion category flag (for breakdown) + is_adult_for_medicaid = baseline.calculate( + "is_adult_for_medicaid", YEAR + ).values + + # Medicaid benefits (person level, dollar amount) + baseline_medicaid_benefits = baseline.calculate("medicaid", YEAR).values + reform_medicaid_benefits = reform.calculate("medicaid", YEAR).values + + # ACA Premium Tax Credit eligibility (person level) + baseline_ptc_eligible = baseline.calculate( + "is_aca_ptc_eligible", YEAR + ).values + reform_ptc_eligible = reform.calculate("is_aca_ptc_eligible", YEAR).values + + # ACA Premium Tax Credit amount (tax unit level) + baseline_ptc = baseline.calculate("premium_tax_credit", YEAR).values + reform_ptc = reform.calculate("premium_tax_credit", YEAR).values + + # ========================================================================= + # Calculate Coverage Changes + # ========================================================================= + + # People who lose Medicaid enrollment + loses_medicaid = baseline_medicaid_enrolled & ~reform_medicaid_enrolled + + # People who lose Medicaid but gain ACA PTC eligibility + loses_medicaid_gains_ptc = ( + loses_medicaid & ~baseline_ptc_eligible & reform_ptc_eligible + ) + + # People who fall into coverage gap (lose Medicaid, don't get ACA) + loses_medicaid_no_coverage = loses_medicaid & ~reform_ptc_eligible + + # Weighted counts + people_losing_medicaid = np.sum( + loses_medicaid.astype(float) * person_weights + ) + people_gaining_ptc = np.sum( + loses_medicaid_gains_ptc.astype(float) * person_weights + ) + people_in_coverage_gap = np.sum( + loses_medicaid_no_coverage.astype(float) * person_weights + ) + + # Adults in expansion category who lose enrollment + adults_losing_enrollment = np.sum( + (loses_medicaid & is_adult_for_medicaid).astype(float) + * person_weights + ) + + # ========================================================================= + # Calculate Fiscal Impact + # ========================================================================= + + # Total Medicaid spending + baseline_medicaid_total = np.sum( + baseline_medicaid_benefits * person_weights + ) + reform_medicaid_total = np.sum(reform_medicaid_benefits * person_weights) + medicaid_savings = baseline_medicaid_total - reform_medicaid_total + + # Split by federal/state share + federal_medicaid_savings = medicaid_savings * FEDERAL_FMAP_EXPANSION + state_medicaid_savings = medicaid_savings * STATE_FMAP_EXPANSION + + # ACA PTC changes (federal cost) + baseline_ptc_total = np.sum(baseline_ptc * tu_weights) + reform_ptc_total = np.sum(reform_ptc * tu_weights) + ptc_increase = reform_ptc_total - baseline_ptc_total + + # Net fiscal impact + net_federal_savings = federal_medicaid_savings - ptc_increase + net_state_savings = state_medicaid_savings # No offset + net_total_savings = medicaid_savings - ptc_increase + + # Cost per person losing coverage + cost_per_person = medicaid_savings / people_losing_medicaid + + # ========================================================================= + # Print Results + # ========================================================================= + + print("=" * 65) + print("UTAH HB 15 - MEDICAID EXPANSION REPEAL ANALYSIS") + print(f"Analysis Year: {YEAR}") + print("=" * 65) + print() + print("REFORM APPROACH: Simple parametric change") + print(" Parameter: gov.hhs.medicaid.eligibility.categories") + print(" .adult.income_limit.UT") + print(" Baseline: 1.38 (138% FPL)") + print(" Reform: -inf (no eligibility)") + print() + + # Coverage Impact + print("COVERAGE IMPACT") + print("-" * 65) + print( + f"People losing Medicaid eligibility: {people_losing_medicaid:>15,.0f}" + ) + print( + f" Adults (expansion category): {adults_losing_enrollment:>15,.0f}" + ) + print() + print("Coverage transitions for those losing Medicaid:") + print( + f" -> Gain ACA PTC eligibility: {people_gaining_ptc:>15,.0f}" + ) + print( + f" -> Fall into coverage gap: {people_in_coverage_gap:>15,.0f}" + ) + print() + + # Fiscal Impact + print("FISCAL IMPACT") + print("-" * 65) + print(f"Total Medicaid savings: ${medicaid_savings:>14,.0f}") + print( + f" Federal share (90%): ${federal_medicaid_savings:>14,.0f}" + ) + print( + f" State/Utah share (10%): ${state_medicaid_savings:>14,.0f}" + ) + print() + print(f"Offsetting federal ACA costs: ${ptc_increase:>14,.0f}") + print() + print("NET SAVINGS:") + print( + f" Federal government: ${net_federal_savings:>14,.0f}" + ) + print( + f" State of Utah: ${net_state_savings:>14,.0f}" + ) + print(f" Total: ${net_total_savings:>14,.0f}") + print() + + # Summary Statistics + print("SUMMARY STATISTICS") + print("-" * 65) + print(f"Average Medicaid benefit per person: ${cost_per_person:>14,.0f}") + print( + f"Percent falling into coverage gap: " + f"{people_in_coverage_gap / people_losing_medicaid * 100:>14.1f}%" + ) + print() + + # Return results as dictionary for further analysis + return { + "year": YEAR, + "coverage": { + "people_losing_medicaid": people_losing_medicaid, + "adults_losing_enrollment": adults_losing_enrollment, + "people_gaining_ptc": people_gaining_ptc, + "people_in_coverage_gap": people_in_coverage_gap, + }, + "fiscal": { + "medicaid_savings_total": medicaid_savings, + "medicaid_savings_federal": federal_medicaid_savings, + "medicaid_savings_state": state_medicaid_savings, + "aca_ptc_increase": ptc_increase, + "net_federal_savings": net_federal_savings, + "net_state_savings": net_state_savings, + "net_total_savings": net_total_savings, + }, + "per_capita": { + "avg_medicaid_benefit": cost_per_person, + }, + } + + +# ============================================================================= +# Main Entry Point +# ============================================================================= + +if __name__ == "__main__": + results = run_analysis() + + print("=" * 65) + print("POLICY CONTEXT") + print("=" * 65) + print( + """ +Utah HB 15 (2026) creates a CONTINGENT repeal of Medicaid expansion. +Expansion would end only if federal matching (FMAP) drops below 85%. +Currently, the federal government pays 90% of expansion Medicaid costs. + +THIS ANALYSIS ASSUMES THE TRIGGER CONDITION IS MET. + +Key policy implications if expansion is repealed: + +1. COVERAGE GAP: ~90,500 people (77%) would fall into the "coverage + gap" - below 100% FPL where ACA subsidies aren't available. + +2. ACA TRANSITION: ~26,700 people (23%) would gain ACA Premium Tax + Credit eligibility, costing the federal government ~$160M/year. + +3. FISCAL TRADEOFF: Utah saves ~$99M/year, but ~90,500 residents + lose health coverage with no alternative. + +4. FEDERAL IMPACT: Federal government saves ~$729M/year net + ($889M Medicaid savings minus $160M increased ACA costs). + +5. NOT MODELED: The 0.15% sales tax that funds expansion would also + be repealed under HB 15 (PolicyEngine doesn't model sales taxes). + +Bill Reference: https://le.utah.gov/~2026/bills/static/HB0015.html +""" + )