diff --git a/us/aca_enrollment_spending.py b/us/aca_enrollment_spending.py new file mode 100644 index 0000000..97c4c23 --- /dev/null +++ b/us/aca_enrollment_spending.py @@ -0,0 +1,80 @@ +from policyengine_us import Microsimulation +import pandas as pd + +YEAR = 2025 + +STATES = [ + "AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "DC", "FL", + "GA", "HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", + "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", + "NJ", "NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", + "SC", "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", +] + +results = [] + +for state in STATES: + print(f"Processing {state}...") + try: + sim = Microsimulation( + dataset=f"hf://policyengine/policyengine-us-data/states/{state}.h5" + ) + aca_ptc = sim.calc("aca_ptc", period=YEAR, map_to="person") + enrolled = (aca_ptc > 0).sum() + spending = aca_ptc.sum() + results.append({ + "state": state, + "aca_enrollment": enrolled, + "aca_spending": spending, + }) + print(f" {state}: {enrolled:,.0f} enrolled, ${spending:,.0f} spending") + except Exception as e: + print(f" ERROR for {state}: {e}") + # CA workaround: set in_la=False to avoid LA county rating area bug + if state == "CA": + import numpy as np + print(f" Retrying {state} with in_la workaround...") + sim = Microsimulation( + dataset=f"hf://policyengine/policyengine-us-data/states/{state}.h5" + ) + household = sim.populations['household'] + sim.set_input('in_la', YEAR, np.zeros(household.count, dtype=bool)) + aca_ptc = sim.calc("aca_ptc", period=YEAR, map_to="person") + enrolled = (aca_ptc > 0).sum() + spending = aca_ptc.sum() + results.append({ + "state": state, + "aca_enrollment": enrolled, + "aca_spending": spending, + }) + print(f" {state}: {enrolled:,.0f} enrolled, ${spending:,.0f} spending") + else: + results.append({ + "state": state, + "aca_enrollment": None, + "aca_spending": None, + }) + +# National +print("\nProcessing national...") +sim_national = Microsimulation() +aca_ptc_national = sim_national.calc("aca_ptc", period=YEAR, map_to="person") +national_enrolled = (aca_ptc_national > 0).sum() +national_spending = aca_ptc_national.sum() +results.append({ + "state": "US (National)", + "aca_enrollment": national_enrolled, + "aca_spending": national_spending, +}) + +df = pd.DataFrame(results) +df["aca_spending_billions"] = df["aca_spending"] / 1e9 +df = df.sort_values("aca_enrollment", ascending=False) + +print("\n" + "=" * 70) +print("ACA Enrollment and Spending by State") +print("=" * 70) +print(df.to_string(index=False, float_format=lambda x: f"{x:,.0f}")) + +df.to_csv(f"us/aca_enrollment_spending_by_state_{YEAR}.csv", index=False) +print("\nSaved to us/aca_enrollment_spending_by_state.csv") diff --git a/us/aca_enrollment_spending_by_state.csv b/us/aca_enrollment_spending_by_state.csv new file mode 100644 index 0000000..5801e7d --- /dev/null +++ b/us/aca_enrollment_spending_by_state.csv @@ -0,0 +1,52 @@ +state,aca_enrollment,aca_spending,aca_spending_billions +US (National),17383484.732709058,179014488228.1197,179.0144882281197 +TX,7045259.248884158,86460101552.99954,86.46010155299955 +CA,6392819.0,61266319340.0,61.26631934 +FL,4763130.907424998,61079815728.53207,61.07981572853207 +GA,2457752.326743666,27439860750.8346,27.4398607508346 +PA,2348139.8470139103,23420307290.99984,23.42030729099984 +OH,2305060.443552648,22196402330.640697,22.196402330640694 +IL,2211039.985772523,26555439405.93521,26.55543940593521 +NC,2086741.8142803991,24947377457.642536,24.947377457642535 +NY,1953039.1004624225,16089885227.700983,16.089885227700986 +MI,1857987.0081065544,17767462914.75386,17.76746291475386 +TN,1592600.8250531708,19973526197.83516,19.97352619783516 +NJ,1539928.6046926808,13172055697.809889,13.172055697809888 +VA,1474410.3597104885,11479779070.163813,11.479779070163811 +AZ,1381595.3213190753,13732783973.741192,13.732783973741192 +IN,1377786.4813249563,10268298367.75582,10.268298367755822 +WI,1354486.004249754,14638132714.766865,14.638132714766863 +WA,1347994.4825802264,13545412240.742018,13.545412240742015 +MO,1312163.1666086228,14013492428.452984,14.013492428452984 +SC,1239063.7865523277,12912301890.222267,12.912301890222269 +AL,1229309.7415236607,14151670647.668756,14.151670647668755 +CO,1027351.5600919002,9107220686.89117,9.107220686891171 +MA,1022073.3352083332,6560450294.139649,6.560450294139649 +KY,959811.3184823088,12092156264.34015,12.09215626434015 +MD,929678.3681728876,5579924673.534227,5.579924673534228 +OK,879446.0107236379,10898721075.430405,10.898721075430403 +LA,876879.9132559479,10524063400.097616,10.524063400097615 +UT,836792.5388366659,9348062271.00832,9.34806227100832 +MS,747166.1928366562,9404532792.797682,9.40453279279768 +KS,721101.5106305224,9157055464.51527,9.15705546451527 +MN,699327.0917778963,5201652953.358235,5.201652953358235 +AR,688070.3462367074,10445357164.006168,10.445357164006168 +IA,664351.792916852,5089952648.955997,5.089952648955998 +OR,656362.3123666104,5548745613.863974,5.548745613863973 +NV,613462.5641283542,5409709120.288629,5.409709120288628 +CT,581912.3486924558,9054273485.205309,9.054273485205307 +NE,490228.6737917632,6991057381.380745,6.991057381380745 +ID,440370.6405512402,4038585402.305117,4.0385854023051175 +NM,392587.7489387901,4553466809.422207,4.553466809422207 +WV,384198.72947609494,8510778710.398851,8.510778710398851 +NH,292619.7376561556,2099425092.7739608,2.0994250927739606 +ME,284222.17484532436,4529157327.2300625,4.529157327230062 +MT,235317.3814670497,2952751928.720352,2.9527519287203523 +HI,220836.27874800004,1703108104.3514469,1.7031081043514469 +ND,201077.59784668495,2583745310.161701,2.583745310161701 +SD,197912.42513002065,2461979782.0080175,2.461979782008018 +RI,194962.17444041232,1679774483.108898,1.679774483108898 +DE,189378.5689020855,2528841844.144252,2.5288418441442517 +VT,123538.29485612042,2501834368.098485,2.501834368098485 +AK,98996.27022879756,1652613679.3413544,1.6526136793413544 +DC,55924.91141050868,426306591.2642381,0.4263065912642381 diff --git a/us/aca_enrollment_spending_by_state_2024.csv b/us/aca_enrollment_spending_by_state_2024.csv new file mode 100644 index 0000000..b48119e --- /dev/null +++ b/us/aca_enrollment_spending_by_state_2024.csv @@ -0,0 +1,52 @@ +state,aca_enrollment,aca_spending,aca_spending_billions +AL,0.0,0.0,0.0 +PA,0.0,0.0,0.0 +NV,0.0,0.0,0.0 +NH,0.0,0.0,0.0 +NJ,0.0,0.0,0.0 +NM,0.0,0.0,0.0 +NY,0.0,0.0,0.0 +NC,0.0,0.0,0.0 +ND,0.0,0.0,0.0 +OH,0.0,0.0,0.0 +OK,0.0,0.0,0.0 +OR,0.0,0.0,0.0 +RI,0.0,0.0,0.0 +MT,0.0,0.0,0.0 +SC,0.0,0.0,0.0 +SD,0.0,0.0,0.0 +TN,0.0,0.0,0.0 +TX,0.0,0.0,0.0 +UT,0.0,0.0,0.0 +VT,0.0,0.0,0.0 +VA,0.0,0.0,0.0 +WA,0.0,0.0,0.0 +WV,0.0,0.0,0.0 +WI,0.0,0.0,0.0 +NE,0.0,0.0,0.0 +MO,0.0,0.0,0.0 +AK,0.0,0.0,0.0 +ID,0.0,0.0,0.0 +AZ,0.0,0.0,0.0 +AR,0.0,0.0,0.0 +CA,0.0,0.0,0.0 +CO,0.0,0.0,0.0 +CT,0.0,0.0,0.0 +DE,0.0,0.0,0.0 +DC,0.0,0.0,0.0 +FL,0.0,0.0,0.0 +GA,0.0,0.0,0.0 +HI,0.0,0.0,0.0 +IL,0.0,0.0,0.0 +MS,0.0,0.0,0.0 +IN,0.0,0.0,0.0 +IA,0.0,0.0,0.0 +KS,0.0,0.0,0.0 +KY,0.0,0.0,0.0 +LA,0.0,0.0,0.0 +ME,0.0,0.0,0.0 +MD,0.0,0.0,0.0 +MA,0.0,0.0,0.0 +MI,0.0,0.0,0.0 +MN,0.0,0.0,0.0 +US (National),0.0,0.0,0.0 diff --git a/us/aca_enrollment_spending_by_state_2025.csv b/us/aca_enrollment_spending_by_state_2025.csv new file mode 100644 index 0000000..91f5458 --- /dev/null +++ b/us/aca_enrollment_spending_by_state_2025.csv @@ -0,0 +1,52 @@ +state,aca_enrollment,aca_spending,aca_spending_billions +US (National),21572726.942273073,181223754299.774,181.223754299774 +CA,8258359.718990697,75333815139.14964,75.33381513914965 +TX,8154141.478774493,77021600904.5361,77.0216009045361 +FL,5480503.306234306,55610399173.550514,55.61039917355051 +NY,2920759.347690541,26876070556.55341,26.876070556553408 +GA,2855316.4533272963,26911336485.505817,26.911336485505817 +PA,2845628.6703047836,24197944419.820126,24.197944419820125 +OH,2801191.123286927,25536017657.964554,25.536017657964553 +IL,2716568.46740863,24011346931.944237,24.011346931944235 +NC,2543226.848436172,25369382439.49144,25.36938243949144 +MI,2206450.235455502,17810806397.0245,17.810806397024503 +NJ,2030025.1104657375,17360635430.15796,17.36063543015796 +TN,1877230.9010269186,17877734072.469265,17.877734072469266 +VA,1822987.0508065247,12605701272.806177,12.605701272806177 +IN,1644125.78897559,11031265485.617647,11.031265485617647 +WI,1632773.8356423066,15222107733.353607,15.222107733353607 +WA,1632212.747060252,11107151098.56737,11.10715109856737 +AZ,1611555.536846539,12774439338.496689,12.77443933849669 +MO,1589053.149089912,14907236215.289948,14.907236215289947 +SC,1456141.7433837387,13911916925.125687,13.911916925125686 +AL,1440763.2141796688,14659416638.629711,14.659416638629711 +MA,1364252.0275837937,9265263267.48693,9.265263267486931 +CO,1315091.3830277754,10486010855.399256,10.486010855399256 +MD,1231546.1830028284,7439723643.565978,7.439723643565978 +KY,1123867.6968579488,10866795607.628288,10.866795607628289 +LA,1032368.7955663666,10887378833.949318,10.887378833949318 +OK,1030973.3978550192,11047539448.776329,11.047539448776329 +UT,1022815.1581275805,10815686650.487175,10.815686650487175 +MN,1007975.4702420774,6160068826.019312,6.1600688260193115 +OR,885710.2963893601,7807541204.385212,7.807541204385212 +KS,851624.0196430138,8652615702.235592,8.652615702235591 +MS,831854.577155319,8088174705.164504,8.088174705164503 +IA,813172.183629359,6221756669.238905,6.221756669238905 +CT,809628.3426085422,10478832726.64508,10.47883272664508 +AR,769524.7271320827,6873299358.368113,6.873299358368112 +NV,730709.7043699923,5934058476.354357,5.934058476354357 +NE,600562.0920331676,7707765423.0975685,7.707765423097569 +ID,529698.3963936316,4688614115.276829,4.688614115276828 +NM,475964.13492398657,4911455805.543261,4.911455805543261 +WV,468581.0576358518,9470285869.939915,9.470285869939914 +ME,355824.7205406721,4292957621.2281113,4.292957621228111 +NH,327098.38002613944,2010970739.2548814,2.0109707392548812 +HI,318478.70823075064,2718272465.394373,2.718272465394373 +MT,300547.43412913976,3311576180.466132,3.3115761804661323 +RI,247608.49538935843,1919906280.4145892,1.9199062804145892 +ND,247107.05076210294,3277744693.109666,3.277744693109666 +DE,242876.5929896055,2533376397.566221,2.5333763975662213 +SD,242241.82739121973,2931362862.695174,2.9313628626951744 +VT,169877.2291643463,3535068119.476025,3.535068119476025 +AK,161816.04310611496,3612140008.017561,3.612140008017561 +DC,95431.3645341415,792773104.7096058,0.7927731047096058 diff --git a/us/states/ut/analyze_hb15_transitions.py b/us/states/ut/analyze_hb15_transitions.py new file mode 100644 index 0000000..d8a3143 --- /dev/null +++ b/us/states/ut/analyze_hb15_transitions.py @@ -0,0 +1,137 @@ +"""Analyze Utah HB 15 Medicaid transitions with microsimulation.""" + +from policyengine_us import Microsimulation +from policyengine_core.periods import instant +from policyengine_core.reforms import Reform +import numpy as np + +YEAR = 2027 +DATASET = "hf://policyengine/policyengine-us-data/enhanced_cps_2024.h5" + +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 main(): + print("Loading baseline microsimulation...") + baseline = Microsimulation(dataset=DATASET) + + print("Loading reform microsimulation...") + reform = Microsimulation(dataset=DATASET, reform=create_ut_medicaid_expansion_repeal()) + + # Filter to Utah residents - map household state to person level + # state_code is defined at household level, map to person + state_code = baseline.calculate("state_code", YEAR, map_to="person").values + in_utah = state_code == "UT" + + # Get person weights + person_weight = baseline.calculate("person_weight", YEAR).values + + # Get Medicaid categories (baseline vs reform) - these return string values + baseline_category = baseline.calculate("medicaid_category", YEAR).values + reform_category = reform.calculate("medicaid_category", YEAR).values + + # Get Medicaid amounts (person level) + baseline_medicaid = baseline.calculate("medicaid", YEAR).values + reform_medicaid = reform.calculate("medicaid", YEAR).values + + print(f"\nDebug: Total people in sample: {len(baseline_category):,}") + print(f"Debug: People in Utah: {in_utah.sum():,}") + print(f"Debug: Unique baseline categories: {np.unique(baseline_category)}") + + # Categories are strings now + ADULT = "ADULT" + PARENT = "PARENT" + NONE = "NONE" + + print("\n" + "="*70) + print("UTAH HB 15 MEDICAID TRANSITION ANALYSIS") + print("="*70) + + # People on expansion Medicaid in baseline (in Utah) + on_expansion_baseline = (baseline_category == ADULT) & in_utah + expansion_count = (on_expansion_baseline * person_weight).sum() + print(f"\nPeople on expansion Medicaid (baseline): {expansion_count:,.0f}") + + # What happens to expansion adults under reform? + # Transition to parent Medicaid + to_parent = on_expansion_baseline & (reform_category == PARENT) + to_parent_count = (to_parent * person_weight).sum() + + # Transition to other Medicaid categories (not NONE, not ADULT, not PARENT) + to_other_medicaid = on_expansion_baseline & (reform_category != NONE) & (reform_category != ADULT) & (reform_category != PARENT) + to_other_count = (to_other_medicaid * person_weight).sum() + + # Fall to no Medicaid + to_none = on_expansion_baseline & (reform_category == NONE) + to_none_count = (to_none * person_weight).sum() + + print(f"\nTransitions from expansion Medicaid:") + print(f" → Parent Medicaid: {to_parent_count:,.0f}") + print(f" → Other Medicaid: {to_other_count:,.0f}") + print(f" → No Medicaid: {to_none_count:,.0f}") + + # Of those who lose all Medicaid, how many gain ACA? + loses_medicaid = on_expansion_baseline & (reform_medicaid == 0) + loses_medicaid_count = (loses_medicaid * person_weight).sum() + + # People above 100% FPL can get ACA + income_level = baseline.calculate("medicaid_income_level", YEAR).values + above_100_fpl = income_level >= 1.0 + + gains_aca = loses_medicaid & above_100_fpl + gains_aca_count = (gains_aca * person_weight).sum() + + # Coverage gap = loses Medicaid and below 100% FPL (can't get ACA) + coverage_gap = loses_medicaid & ~above_100_fpl + coverage_gap_count = (coverage_gap * person_weight).sum() + + if loses_medicaid_count > 0: + print(f"\nOf those losing Medicaid ({loses_medicaid_count:,.0f}):") + print(f" → Can get ACA (>=100% FPL): {gains_aca_count:,.0f} ({gains_aca_count/loses_medicaid_count*100:.1f}%)") + print(f" → Coverage gap (<100% FPL): {coverage_gap_count:,.0f} ({coverage_gap_count/loses_medicaid_count*100:.1f}%)") + + # Fiscal impact (person-level Medicaid only for now) + utah_weight = person_weight * in_utah + + baseline_medicaid_total = (baseline_medicaid * utah_weight).sum() + reform_medicaid_total = (reform_medicaid * utah_weight).sum() + medicaid_savings = baseline_medicaid_total - reform_medicaid_total + + print(f"\n" + "="*70) + print("FISCAL IMPACT") + print("="*70) + print(f"\nMedicaid spending change: -${medicaid_savings/1e6:,.0f} million") + print(f" Federal share (90%): -${medicaid_savings*0.9/1e6:,.0f} million") + print(f" State share (10%): -${medicaid_savings*0.1/1e6:,.0f} million") + + # Summary for blog post + print(f"\n" + "="*70) + print("SUMMARY FOR BLOG POST") + print("="*70) + print(f"\n- ~{expansion_count/1000:.0f},000 people currently on expansion Medicaid") + if to_parent_count > 0: + print(f"- ~{to_parent_count/1000:.0f},000 would transition to parent Medicaid") + if to_other_count > 0: + print(f"- ~{to_other_count/1000:.0f},000 would transition to other Medicaid categories") + print(f"- ~{loses_medicaid_count/1000:.0f},000 would lose Medicaid coverage entirely") + if loses_medicaid_count > 0: + print(f" - ~{gains_aca_count/1000:.0f},000 ({gains_aca_count/loses_medicaid_count*100:.0f}%) could transition to ACA") + print(f" - ~{coverage_gap_count/1000:.0f},000 ({coverage_gap_count/loses_medicaid_count*100:.0f}%) would fall into coverage gap") + print(f"- State savings: ~${medicaid_savings*0.1/1e6:.0f} million/year") + + +if __name__ == "__main__": + main() 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/generate_hb15_distributional_charts.py b/us/states/ut/generate_hb15_distributional_charts.py new file mode 100644 index 0000000..818d2f9 --- /dev/null +++ b/us/states/ut/generate_hb15_distributional_charts.py @@ -0,0 +1,223 @@ +"""Generate interactive distributional charts for Utah HB 15 blog post.""" + +import plotly.graph_objects as go +import numpy as np +from policyengine_us import Microsimulation +from policyengine_core.periods import instant +from policyengine_core.reforms import Reform + +YEAR = 2027 +OUTPUT_DIR = "/Users/daphnehansell/Documents/GitHub/utah-hb15-charts" +UT_DATASET = "hf://policyengine/policyengine-us-data/states/UT.h5" + +# PolicyEngine colors +BLUE_PRIMARY = '#2C6496' +TEAL_ACCENT = '#39C6C0' +GRAY = '#808080' +DARK_GRAY = '#616161' +LIGHT_BLUE = '#6FA8DC' + + +def create_ut_medicaid_expansion_repeal(): + """Create reform that repeals Utah's Medicaid expansion.""" + 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 create_chart_html(fig): + """Create standalone HTML with Plotly chart.""" + fig.update_layout( + font=dict(family="Roboto, sans-serif"), + paper_bgcolor='white', + plot_bgcolor='white', + margin=dict(l=60, r=40, t=60, b=60), + ) + + html = fig.to_html( + include_plotlyjs='cdn', + full_html=True, + config={'displayModeBar': False, 'responsive': True} + ) + + html = html.replace( + '', + '\n' + ) + + return html + + +def generate_distributional_charts(): + """Generate distributional analysis charts.""" + + print("Loading simulations...") + baseline = Microsimulation(dataset=UT_DATASET) + reform = Microsimulation(dataset=UT_DATASET, reform=create_ut_medicaid_expansion_repeal()) + + # Get data + person_weights = baseline.calculate("person_weight", YEAR).values + baseline_medicaid = baseline.calculate("medicaid_enrolled", YEAR).values + reform_medicaid = reform.calculate("medicaid_enrolled", YEAR).values + loses_medicaid = baseline_medicaid & ~reform_medicaid + + reform_ptc_eligible = reform.calculate("is_aca_ptc_eligible", YEAR).values + coverage_gap = loses_medicaid & ~reform_ptc_eligible + gains_aca = loses_medicaid & reform_ptc_eligible + + # Income and demographics + spm_income = baseline.calculate("spm_unit_net_income", YEAR, map_to="person").values + age = baseline.calculate("age", YEAR).values + + # Weighted data for those losing coverage + affected_weights = person_weights * loses_medicaid.astype(float) + gap_weights = person_weights * coverage_gap.astype(float) + aca_weights = person_weights * gains_aca.astype(float) + + # ========================================================================= + # Chart 1: Income Distribution by Decile + # ========================================================================= + print("Creating income distribution chart by decile...") + + # Calculate income deciles for the entire Utah population + # Use weighted percentiles to define decile boundaries + + # Calculate weighted decile boundaries for all people + sorted_indices = np.argsort(spm_income) + sorted_income = spm_income[sorted_indices] + sorted_weights = person_weights[sorted_indices] + cumulative_weights = np.cumsum(sorted_weights) + total_weight = cumulative_weights[-1] + + # Find income thresholds at each decile + decile_thresholds = [0] + for decile in range(1, 10): + target_weight = total_weight * decile / 10 + idx = np.searchsorted(cumulative_weights, target_weight) + decile_thresholds.append(sorted_income[min(idx, len(sorted_income) - 1)]) + decile_thresholds.append(float('inf')) + + # Create decile labels with income ranges + decile_labels = [] + for i in range(10): + low = decile_thresholds[i] + high = decile_thresholds[i + 1] + if i == 9: + decile_labels.append(f'10th\n(>${low/1000:.0f}k)') + else: + decile_labels.append(f'{i+1}{"st" if i==0 else "nd" if i==1 else "rd" if i==2 else "th"}\n(${low/1000:.0f}-{high/1000:.0f}k)') + + # Calculate weighted counts for each decile + gap_counts = [] + aca_counts = [] + + for i in range(10): + low = decile_thresholds[i] + high = decile_thresholds[i + 1] + in_decile = (spm_income >= low) & (spm_income < high) + gap_counts.append((gap_weights * in_decile.astype(float)).sum()) + aca_counts.append((aca_weights * in_decile.astype(float)).sum()) + + fig = go.Figure() + + fig.add_trace(go.Bar( + name='Coverage Gap', + x=decile_labels, + y=[c / 1000 for c in gap_counts], + marker_color=TEAL_ACCENT, + hovertemplate='Decile %{x}
Coverage Gap: %{y:.1f}k people' + )) + + fig.add_trace(go.Bar( + name='ACA Transition', + x=decile_labels, + y=[c / 1000 for c in aca_counts], + marker_color=BLUE_PRIMARY, + hovertemplate='Decile %{x}
ACA Transition: %{y:.1f}k people' + )) + + fig.update_layout( + title='Affected Population by Household Income Decile', + xaxis_title='Household Income Decile', + yaxis_title='People (thousands)', + barmode='stack', + height=500, + width=700, + legend=dict(orientation='h', yanchor='top', y=1.05, xanchor='center', x=0.5), + hovermode='x unified', + xaxis=dict(tickangle=0), + margin=dict(b=100, t=80), + ) + + html = create_chart_html(fig) + with open(f'{OUTPUT_DIR}/income-distribution.html', 'w') as f: + f.write(html) + print(f' Saved {OUTPUT_DIR}/income-distribution.html') + + # ========================================================================= + # Chart 2: Age Distribution + # ========================================================================= + print("Creating age distribution chart...") + + age_bins = [(18, 25), (26, 34), (35, 44), (45, 54), (55, 64)] + age_labels = ['18-25', '26-34', '35-44', '45-54', '55-64'] + + gap_age_counts = [] + aca_age_counts = [] + + for min_age, max_age in age_bins: + in_bin = (age >= min_age) & (age <= max_age) + gap_age_counts.append((gap_weights * in_bin.astype(float)).sum()) + aca_age_counts.append((aca_weights * in_bin.astype(float)).sum()) + + fig = go.Figure() + + fig.add_trace(go.Bar( + name='Coverage Gap', + x=age_labels, + y=[c / 1000 for c in gap_age_counts], + marker_color=TEAL_ACCENT, + hovertemplate='Age %{x}
Coverage Gap: %{y:.1f}k people' + )) + + fig.add_trace(go.Bar( + name='ACA Transition', + x=age_labels, + y=[c / 1000 for c in aca_age_counts], + marker_color=BLUE_PRIMARY, + hovertemplate='Age %{x}
ACA Transition: %{y:.1f}k people' + )) + + fig.update_layout( + title='Affected Population by Age', + xaxis_title='Age Group', + yaxis_title='People (thousands)', + barmode='stack', + height=450, + width=700, + legend=dict(orientation='h', yanchor='top', y=1.05, xanchor='center', x=0.5), + hovermode='x unified', + xaxis=dict(tickangle=0), + margin=dict(t=80), + ) + + html = create_chart_html(fig) + with open(f'{OUTPUT_DIR}/age-distribution.html', 'w') as f: + f.write(html) + print(f' Saved {OUTPUT_DIR}/age-distribution.html') + + print("Done!") + + +if __name__ == "__main__": + generate_distributional_charts() diff --git a/us/states/ut/generate_hb15_interactive_charts.py b/us/states/ut/generate_hb15_interactive_charts.py new file mode 100644 index 0000000..cbbf81f --- /dev/null +++ b/us/states/ut/generate_hb15_interactive_charts.py @@ -0,0 +1,223 @@ +"""Generate interactive HTML charts 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 +import os + +YEAR = 2027 +OUTPUT_DIR = "/Users/daphnehansell/Documents/GitHub/utah-hb15-charts" + +# PolicyEngine colors +BLUE_PRIMARY = '#2C6496' +TEAL_ACCENT = '#39C6C0' +GRAY = '#808080' +DARK_GRAY = '#616161' + + +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 create_chart_html(fig, title): + """Create standalone HTML with Plotly chart.""" + # Update layout for embedding + fig.update_layout( + font=dict(family="Roboto, sans-serif"), + paper_bgcolor='white', + plot_bgcolor='white', + margin=dict(l=60, r=40, t=60, b=60), + ) + + # Get Plotly HTML + html = fig.to_html( + include_plotlyjs='cdn', + full_html=True, + config={'displayModeBar': False, 'responsive': True} + ) + + # Add Roboto font + html = html.replace( + '', + '\n' + ) + + return html + + +def generate_charts(): + """Generate interactive charts for the blog post.""" + + os.makedirs(OUTPUT_DIR, exist_ok=True) + + # 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': 300, 'min': 0, 'max': 100000}]], + } + + # 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': 300, 'min': 0, 'max': 100000}]], + } + + 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 coverage + 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 (same under baseline and reform - children keep Medicaid/CHIP) + child_baseline_medicaid = parent_base.calculate('medicaid', YEAR, map_to='person')[1::2] + child_baseline_chip = parent_base.calculate('chip', YEAR, map_to='person')[1::2] + child_reform_medicaid = parent_reform.calculate('medicaid', YEAR, map_to='person')[1::2] + child_reform_chip = parent_reform.calculate('chip', YEAR, map_to='person')[1::2] + # Household totals (parent + child) + household_baseline_medicaid = parent_baseline_medicaid + child_baseline_medicaid + child_baseline_chip + household_reform_medicaid = parent_reform_medicaid + child_reform_medicaid + child_reform_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=3), + hovertemplate='Income: $%{x:,.0f}
Medicaid: $%{y:,.0f}' + )) + fig.add_trace(go.Scatter( + x=single_income, y=single_baseline_ptc, + mode='lines', name='ACA PTC (Baseline)', + line=dict(color=BLUE_PRIMARY, width=3), + hovertemplate='Income: $%{x:,.0f}
ACA PTC: $%{y:,.0f}' + )) + fig.add_trace(go.Scatter( + x=single_income, y=single_reform_medicaid, + mode='lines', name='Medicaid (Reform)', + line=dict(color=TEAL_ACCENT, width=3, dash='dot'), + hovertemplate='Income: $%{x:,.0f}
Medicaid: $%{y:,.0f}' + )) + fig.add_trace(go.Scatter( + x=single_income, y=single_reform_ptc, + mode='lines', name='ACA PTC (Reform)', + line=dict(color=BLUE_PRIMARY, width=3, dash='dot'), + hovertemplate='Income: $%{x:,.0f}
ACA PTC: $%{y:,.0f}' + )) + + fig.update_layout( + title='Single Adult: Health Benefits by Income (Utah 2027)', + xaxis_title='Household Income', + yaxis_title='Annual Benefit Amount', + xaxis=dict(tickformat='$,.0f', range=[0, 100000]), + yaxis=dict(tickformat='$,.0f'), + height=500, + width=800, + legend=dict(orientation='h', yanchor='bottom', y=-0.25, xanchor='center', x=0.5), + hovermode='x unified', + ) + + html = create_chart_html(fig, 'Single Adult') + with open(f'{OUTPUT_DIR}/single-adult.html', 'w') as f: + f.write(html) + print(f' Saved {OUTPUT_DIR}/single-adult.html') + + # Parent + Child Chart + print('Creating parent+child chart...') + fig = go.Figure() + fig.add_trace(go.Scatter( + x=parent_income, y=household_baseline_medicaid, + mode='lines', name='Medicaid/CHIP (Baseline)', + line=dict(color=TEAL_ACCENT, width=3), + hovertemplate='Income: $%{x:,.0f}
Medicaid/CHIP: $%{y:,.0f}' + )) + fig.add_trace(go.Scatter( + x=parent_income, y=parent_baseline_ptc, + mode='lines', name='ACA PTC (Baseline)', + line=dict(color=BLUE_PRIMARY, width=3), + hovertemplate='Income: $%{x:,.0f}
ACA PTC: $%{y:,.0f}' + )) + fig.add_trace(go.Scatter( + x=parent_income, y=household_reform_medicaid, + mode='lines', name='Medicaid/CHIP (Reform)', + line=dict(color=TEAL_ACCENT, width=3, dash='dot'), + hovertemplate='Income: $%{x:,.0f}
Medicaid/CHIP: $%{y:,.0f}' + )) + fig.add_trace(go.Scatter( + x=parent_income, y=parent_reform_ptc, + mode='lines', name='ACA PTC (Reform)', + line=dict(color=BLUE_PRIMARY, width=3, dash='dot'), + hovertemplate='Income: $%{x:,.0f}
ACA PTC: $%{y:,.0f}' + )) + + fig.update_layout( + title='Single Parent + Child: Health Benefits by Income (Utah 2027)', + xaxis_title='Household Income', + yaxis_title='Annual Benefit Amount', + xaxis=dict(tickformat='$,.0f', range=[0, 100000]), + yaxis=dict(tickformat='$,.0f'), + height=500, + width=800, + legend=dict(orientation='h', yanchor='bottom', y=-0.25, xanchor='center', x=0.5), + hovermode='x unified', + ) + + html = create_chart_html(fig, 'Parent + Child') + with open(f'{OUTPUT_DIR}/parent-child.html', 'w') as f: + f.write(html) + print(f' Saved {OUTPUT_DIR}/parent-child.html') + + print('Done!') + + +if __name__ == '__main__': + generate_charts() 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..c8b49a5 --- /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 gain ACA Premium Tax +Credit eligibility when losing Medicaid under Utah HB 15. + +Key Finding (using Utah-calibrated dataset with 93% takeup): +------------------------------------------------------------- +Of ~84,200 losing Medicaid enrollment: +- 86.5% are below 100% FPL -> fall into "coverage gap" (no ACA available) +- 13.5% are at 100-138% FPL -> could potentially get ACA + - Some of these have ESI coverage + - ~11,400 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 ~11,400 people gain ACA eligibility when ~84,200 lose Medicaid? + +1. COVERAGE GAP (86.5% of those losing Medicaid): + - Below 100% FPL + - ACA subsidies don't exist below 100% FPL + - ~72,800 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 (~11,400 people, 13.5% of total): + - At 100-138% FPL + - Don't have ESI or other disqualifying coverage + - These transition to ACA subsidies (~$85M/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_distributional_analysis.py b/us/states/ut/utah_hb15_distributional_analysis.py new file mode 100644 index 0000000..3d591ae --- /dev/null +++ b/us/states/ut/utah_hb15_distributional_analysis.py @@ -0,0 +1,309 @@ +""" +Utah HB 15 - Distributional Analysis +===================================== + +This script analyzes the demographics of people affected by Utah HB 15's +Medicaid expansion repeal, including: +- Age distribution +- Income distribution +- Household composition +- Gender breakdown + +Uses Microseries objects for weighted calculations per PolicyEngine best practices. +""" + +import numpy as np +from policyengine_us import Microsimulation +from policyengine_core.periods import instant +from policyengine_core.reforms import Reform + +YEAR = 2027 +UT_DATASET = "hf://policyengine/policyengine-us-data/states/UT.h5" + + +def create_ut_medicaid_expansion_repeal(): + """Create reform that repeals Utah's Medicaid expansion.""" + 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 run_distributional_analysis(): + """Run distributional analysis of who loses coverage.""" + + print("Loading simulations...") + baseline = Microsimulation(dataset=UT_DATASET) + reform = Microsimulation(dataset=UT_DATASET, reform=create_ut_medicaid_expansion_repeal()) + + # Get Microseries objects (keep weighted) + baseline_medicaid = baseline.calculate("medicaid_enrolled", YEAR) + reform_medicaid = reform.calculate("medicaid_enrolled", YEAR) + + # Create boolean masks for filtering + loses_medicaid = baseline_medicaid.values & ~reform_medicaid.values + + # ACA eligibility + reform_ptc_eligible = reform.calculate("is_aca_ptc_eligible", YEAR) + coverage_gap = loses_medicaid & ~reform_ptc_eligible.values + gains_aca = loses_medicaid & reform_ptc_eligible.values + + # Demographics - get Microseries + age = baseline.calculate("age", YEAR) + is_male = baseline.calculate("is_male", YEAR) + spm_income = baseline.calculate("spm_unit_net_income", YEAR, map_to="person") + employment_income = baseline.calculate("employment_income", YEAR) + spm_unit_size = baseline.calculate("spm_unit_size", YEAR, map_to="person") + is_child = baseline.calculate("is_child", YEAR) + fpl_fraction = baseline.calculate("tax_unit_medicaid_income_level", YEAR, map_to="person") + + # Get weights for filtering + weights = baseline.calculate("person_weight", YEAR).values + + # ========================================================================== + # Helper function for weighted calculations on filtered populations + # ========================================================================== + def weighted_sum(mask): + """Sum of weights for people matching mask.""" + return (weights * mask.astype(float)).sum() + + def weighted_mean(values, mask): + """Weighted mean of values for people matching mask.""" + w = weights * mask.astype(float) + if w.sum() == 0: + return 0 + return np.average(values, weights=w) + + # ========================================================================== + # Analysis + # ========================================================================== + + total_affected = weighted_sum(loses_medicaid) + total_coverage_gap = weighted_sum(coverage_gap) + total_gains_aca = weighted_sum(gains_aca) + + print("\n" + "=" * 70) + print("UTAH HB 15 - DISTRIBUTIONAL ANALYSIS") + print(f"Analysis Year: {YEAR}") + print("=" * 70) + + # ------------------------------------------------------------------------- + # Overall Numbers + # ------------------------------------------------------------------------- + print("\n" + "-" * 70) + print("OVERALL COVERAGE IMPACT") + print("-" * 70) + print(f"Total people losing Medicaid: {total_affected:>12,.0f}") + print(f" -> Fall into coverage gap: {total_coverage_gap:>12,.0f} ({total_coverage_gap/total_affected*100:.1f}%)") + print(f" -> Transition to ACA: {total_gains_aca:>12,.0f} ({total_gains_aca/total_affected*100:.1f}%)") + + # ------------------------------------------------------------------------- + # Age Distribution + # ------------------------------------------------------------------------- + print("\n" + "-" * 70) + print("AGE DISTRIBUTION OF THOSE LOSING COVERAGE") + print("-" * 70) + + age_values = age.values + age_brackets = [ + (0, 17, "Children (0-17)"), + (18, 25, "Young adults (18-25)"), + (26, 34, "Adults (26-34)"), + (35, 44, "Adults (35-44)"), + (45, 54, "Adults (45-54)"), + (55, 64, "Adults (55-64)"), + (65, 200, "Seniors (65+)"), + ] + + for min_age, max_age, label in age_brackets: + in_bracket = (age_values >= min_age) & (age_values <= max_age) & loses_medicaid + bracket_count = weighted_sum(in_bracket) + if bracket_count > 0: + pct = bracket_count / total_affected * 100 + print(f" {label:30s} {bracket_count:>10,.0f} ({pct:>5.1f}%)") + + # Average ages + avg_age_affected = weighted_mean(age_values, loses_medicaid) + avg_age_coverage_gap = weighted_mean(age_values, coverage_gap) + avg_age_aca = weighted_mean(age_values, gains_aca) + print(f"\n Average age (all affected): {avg_age_affected:>10.1f} years") + print(f" Average age (coverage gap): {avg_age_coverage_gap:>10.1f} years") + print(f" Average age (ACA transition): {avg_age_aca:>10.1f} years") + + # ------------------------------------------------------------------------- + # Gender Distribution + # ------------------------------------------------------------------------- + print("\n" + "-" * 70) + print("GENDER DISTRIBUTION") + print("-" * 70) + + is_male_values = is_male.values + male_affected = weighted_sum(loses_medicaid & is_male_values) + female_affected = weighted_sum(loses_medicaid & ~is_male_values) + + print(f" Male: {male_affected:>10,.0f} ({male_affected/total_affected*100:>5.1f}%)") + print(f" Female: {female_affected:>10,.0f} ({female_affected/total_affected*100:>5.1f}%)") + + # ------------------------------------------------------------------------- + # Income Distribution + # ------------------------------------------------------------------------- + print("\n" + "-" * 70) + print("INCOME DISTRIBUTION (Household Income)") + print("-" * 70) + + spm_income_values = spm_income.values + avg_income_affected = weighted_mean(spm_income_values, loses_medicaid) + avg_income_coverage_gap = weighted_mean(spm_income_values, coverage_gap) + avg_income_aca = weighted_mean(spm_income_values, gains_aca) + + print(f" Average household income (all affected): ${avg_income_affected:>10,.0f}") + print(f" Average household income (coverage gap): ${avg_income_coverage_gap:>10,.0f}") + print(f" Average household income (ACA transition):${avg_income_aca:>10,.0f}") + + # Income brackets + print("\n Income distribution of affected population:") + income_brackets = [ + (0, 10000, "$0 - $10,000"), + (10000, 20000, "$10,000 - $20,000"), + (20000, 30000, "$20,000 - $30,000"), + (30000, 40000, "$30,000 - $40,000"), + (40000, 50000, "$40,000 - $50,000"), + (50000, float('inf'), "$50,000+"), + ] + + for min_inc, max_inc, label in income_brackets: + in_bracket = (spm_income_values >= min_inc) & (spm_income_values < max_inc) & loses_medicaid + bracket_count = weighted_sum(in_bracket) + if bracket_count > 0: + pct = bracket_count / total_affected * 100 + print(f" {label:25s} {bracket_count:>10,.0f} ({pct:>5.1f}%)") + + # ------------------------------------------------------------------------- + # FPL Distribution + # ------------------------------------------------------------------------- + print("\n" + "-" * 70) + print("FEDERAL POVERTY LEVEL (FPL) DISTRIBUTION") + print("-" * 70) + + fpl_percent = fpl_fraction.values * 100 + avg_fpl_affected = weighted_mean(fpl_percent, loses_medicaid) + avg_fpl_coverage_gap = weighted_mean(fpl_percent, coverage_gap) + avg_fpl_aca = weighted_mean(fpl_percent, gains_aca) + + print(f" Average FPL % (all affected): {avg_fpl_affected:>10.1f}%") + print(f" Average FPL % (coverage gap): {avg_fpl_coverage_gap:>10.1f}%") + print(f" Average FPL % (ACA transition): {avg_fpl_aca:>10.1f}%") + + fpl_brackets = [ + (0, 50, "0-50% FPL"), + (50, 100, "50-100% FPL"), + (100, 138, "100-138% FPL"), + (138, 200, "138-200% FPL"), + ] + + print("\n FPL distribution of affected population:") + for min_fpl, max_fpl, label in fpl_brackets: + in_bracket = (fpl_percent >= min_fpl) & (fpl_percent < max_fpl) & loses_medicaid + bracket_count = weighted_sum(in_bracket) + if bracket_count > 0: + pct = bracket_count / total_affected * 100 + print(f" {label:25s} {bracket_count:>10,.0f} ({pct:>5.1f}%)") + + # ------------------------------------------------------------------------- + # Household Size + # ------------------------------------------------------------------------- + print("\n" + "-" * 70) + print("HOUSEHOLD SIZE DISTRIBUTION") + print("-" * 70) + + spm_size_values = spm_unit_size.values + avg_hh_size = weighted_mean(spm_size_values, loses_medicaid) + print(f" Average household size of affected: {avg_hh_size:>10.1f}") + + for hh_size in range(1, 7): + if hh_size < 6: + in_size = (spm_size_values == hh_size) & loses_medicaid + label = f"Household size {hh_size}" + else: + in_size = (spm_size_values >= hh_size) & loses_medicaid + label = f"Household size 6+" + size_count = weighted_sum(in_size) + if size_count > 0: + pct = size_count / total_affected * 100 + print(f" {label:25s} {size_count:>10,.0f} ({pct:>5.1f}%)") + + # ------------------------------------------------------------------------- + # Employment Status + # ------------------------------------------------------------------------- + print("\n" + "-" * 70) + print("EMPLOYMENT STATUS") + print("-" * 70) + + emp_income_values = employment_income.values + has_employment = emp_income_values > 0 + employed_affected = weighted_sum(loses_medicaid & has_employment) + unemployed_affected = weighted_sum(loses_medicaid & ~has_employment) + + avg_emp_income = weighted_mean(emp_income_values, loses_medicaid & has_employment) + + print(f" With employment income: {employed_affected:>10,.0f} ({employed_affected/total_affected*100:>5.1f}%)") + print(f" Without employment income: {unemployed_affected:>10,.0f} ({unemployed_affected/total_affected*100:>5.1f}%)") + print(f" Average employment income (if employed): ${avg_emp_income:>10,.0f}") + + # ------------------------------------------------------------------------- + # Adults vs Children + # ------------------------------------------------------------------------- + print("\n" + "-" * 70) + print("ADULTS VS CHILDREN") + print("-" * 70) + + is_child_values = is_child.values + adults_affected = weighted_sum(loses_medicaid & ~is_child_values) + children_affected = weighted_sum(loses_medicaid & is_child_values) + + print(f" Adults (18+): {adults_affected:>10,.0f} ({adults_affected/total_affected*100:>5.1f}%)") + print(f" Children (under 18): {children_affected:>10,.0f} ({children_affected/total_affected*100:>5.1f}%)") + + # ------------------------------------------------------------------------- + # Summary Table for Blog + # ------------------------------------------------------------------------- + print("\n" + "=" * 70) + print("SUMMARY FOR BLOG POST") + print("=" * 70) + print(f""" +Key demographic findings: + +| Metric | All Affected | Coverage Gap | ACA Transition | +|--------|--------------|--------------|----------------| +| Number of people | {total_affected:,.0f} | {total_coverage_gap:,.0f} | {total_gains_aca:,.0f} | +| Average age | {avg_age_affected:.0f} years | {avg_age_coverage_gap:.0f} years | {avg_age_aca:.0f} years | +| Average household income | ${avg_income_affected:,.0f} | ${avg_income_coverage_gap:,.0f} | ${avg_income_aca:,.0f} | +| Average FPL | {avg_fpl_affected:.0f}% | {avg_fpl_coverage_gap:.0f}% | {avg_fpl_aca:.0f}% | +| With employment income | {employed_affected/total_affected*100:.0f}% | — | — | +""") + + return { + "total_affected": total_affected, + "coverage_gap": total_coverage_gap, + "aca_transition": total_gains_aca, + "avg_age": avg_age_affected, + "avg_age_coverage_gap": avg_age_coverage_gap, + "avg_age_aca": avg_age_aca, + "avg_income": avg_income_affected, + "avg_fpl": avg_fpl_affected, + "pct_adults": adults_affected / total_affected * 100, + "pct_employed": employed_affected / total_affected * 100, + } + + +if __name__ == "__main__": + results = run_distributional_analysis() 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..2163515 --- /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): +- ~84,200 people would lose Medicaid enrollment +- ~72,800 would fall into the "coverage gap" (no ACA subsidies available) +- ~11,400 would gain ACA Premium Tax Credit eligibility +- Utah would save ~$73 million/year (10% state share) +- Federal government would save ~$575 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: ~72,800 people (86.5%) would fall into the "coverage + gap" - below 100% FPL where ACA subsidies aren't available. + +2. ACA TRANSITION: ~11,400 people (13.5%) would gain ACA Premium Tax + Credit eligibility, costing the federal government ~$85M/year. + +3. FISCAL TRADEOFF: Utah saves ~$73M/year, but ~72,800 residents + lose health coverage with no alternative. + +4. FEDERAL IMPACT: Federal government saves ~$575M/year net + ($660M Medicaid savings minus $85M 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 +""" + ) diff --git a/us/states/ut/utah_hb15_with_transitions.py b/us/states/ut/utah_hb15_with_transitions.py new file mode 100644 index 0000000..2138ac5 --- /dev/null +++ b/us/states/ut/utah_hb15_with_transitions.py @@ -0,0 +1,139 @@ +"""Utah HB 15 analysis with parent Medicaid transitions tracked.""" + +import numpy as np +from policyengine_us import Microsimulation +from policyengine_core.periods import instant +from policyengine_core.reforms import Reform + +YEAR = 2027 +UT_DATASET = "hf://policyengine/policyengine-us-data/states/UT.h5" + +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 run_analysis(): + print("Loading simulations with Utah-specific dataset...") + baseline = Microsimulation(dataset=UT_DATASET) + reform = Microsimulation(dataset=UT_DATASET, reform=create_ut_medicaid_expansion_repeal()) + + # Weights + person_weights = baseline.calculate("person_weight", YEAR).values + + # Medicaid categories + baseline_category = baseline.calculate("medicaid_category", YEAR).values + reform_category = reform.calculate("medicaid_category", YEAR).values + + # Medicaid enrollment (accounts for takeup rate) + baseline_enrolled = baseline.calculate("medicaid_enrolled", YEAR).values + reform_enrolled = reform.calculate("medicaid_enrolled", YEAR).values + + # Medicaid benefits + baseline_medicaid = baseline.calculate("medicaid", YEAR).values + reform_medicaid = reform.calculate("medicaid", YEAR).values + + # ACA eligibility + baseline_ptc_eligible = baseline.calculate("is_aca_ptc_eligible", YEAR).values + reform_ptc_eligible = reform.calculate("is_aca_ptc_eligible", YEAR).values + + # Adult expansion flag + is_adult_for_medicaid = baseline.calculate("is_adult_for_medicaid", YEAR).values + + # Category constants + ADULT = "ADULT" + PARENT = "PARENT" + NONE = "NONE" + + print("\n" + "="*70) + print("UTAH HB 15 - MEDICAID TRANSITION ANALYSIS") + print("="*70) + + # People ENROLLED in expansion Medicaid (not just eligible) + # Using is_adult_for_medicaid to identify expansion adults who are enrolled + on_expansion_enrolled = baseline_enrolled & is_adult_for_medicaid + expansion_enrolled_count = (on_expansion_enrolled * person_weights).sum() + print(f"\nPeople enrolled in expansion Medicaid (baseline): {expansion_enrolled_count:,.0f}") + + # Also check using category + on_expansion_by_cat = (baseline_category == ADULT) & baseline_enrolled + expansion_by_cat_count = (on_expansion_by_cat * person_weights).sum() + print(f" (Using category check: {expansion_by_cat_count:,.0f})") + + # What happens to enrolled expansion adults under reform? + # Still enrolled (transitioned to another category) + still_enrolled = on_expansion_enrolled & reform_enrolled + still_enrolled_count = (still_enrolled * person_weights).sum() + + # Transitioned to parent Medicaid + to_parent = on_expansion_enrolled & reform_enrolled & (reform_category == PARENT) + to_parent_count = (to_parent * person_weights).sum() + + # Transitioned to other Medicaid + to_other = on_expansion_enrolled & reform_enrolled & (reform_category != PARENT) & (reform_category != ADULT) + to_other_count = (to_other * person_weights).sum() + + # Lost enrollment entirely + lost_enrollment = on_expansion_enrolled & ~reform_enrolled + lost_enrollment_count = (lost_enrollment * person_weights).sum() + + print(f"\nTransitions from expansion Medicaid:") + print(f" → Still enrolled (other category): {still_enrolled_count:,.0f} ({still_enrolled_count/expansion_enrolled_count*100:.1f}%)") + print(f" - Parent Medicaid: {to_parent_count:,.0f}") + print(f" - Other categories: {to_other_count:,.0f}") + print(f" → Lost enrollment: {lost_enrollment_count:,.0f} ({lost_enrollment_count/expansion_enrolled_count*100:.1f}%)") + + # Of those who lost enrollment, who gains ACA? + gains_aca = lost_enrollment & ~baseline_ptc_eligible & reform_ptc_eligible + gains_aca_count = (gains_aca * person_weights).sum() + + coverage_gap = lost_enrollment & ~reform_ptc_eligible + coverage_gap_count = (coverage_gap * person_weights).sum() + + print(f"\nOf those losing enrollment ({lost_enrollment_count:,.0f}):") + print(f" → Gain ACA eligibility: {gains_aca_count:,.0f} ({gains_aca_count/lost_enrollment_count*100:.1f}%)") + print(f" → Coverage gap: {coverage_gap_count:,.0f} ({coverage_gap_count/lost_enrollment_count*100:.1f}%)") + + # Fiscal impact + baseline_total = (baseline_medicaid * person_weights).sum() + reform_total = (reform_medicaid * person_weights).sum() + medicaid_savings = baseline_total - reform_total + + print(f"\n" + "="*70) + print("FISCAL IMPACT") + print("="*70) + print(f"\nMedicaid spending savings: ${medicaid_savings/1e6:,.0f} million") + print(f" Federal share (90%): ${medicaid_savings*0.9/1e6:,.0f} million") + print(f" State share (10%): ${medicaid_savings*0.1/1e6:,.0f} million") + + # Summary for blog post + print(f"\n" + "="*70) + print("REVISED SUMMARY FOR BLOG POST") + print("="*70) + print(f""" +Key results for 2027: + +- ~{expansion_enrolled_count/1000:.0f},000 people enrolled in expansion Medicaid +- ~{still_enrolled_count/1000:.0f},000 ({still_enrolled_count/expansion_enrolled_count*100:.0f}%) would retain Medicaid (other categories) + - ~{to_parent_count/1000:.0f},000 via parent Medicaid + - ~{to_other_count/1000:.0f},000 via other categories +- ~{lost_enrollment_count/1000:.0f},000 ({lost_enrollment_count/expansion_enrolled_count*100:.0f}%) would lose Medicaid enrollment + - ~{gains_aca_count/1000:.0f},000 ({gains_aca_count/lost_enrollment_count*100:.0f}%) could transition to ACA + - ~{coverage_gap_count/1000:.0f},000 ({coverage_gap_count/lost_enrollment_count*100:.0f}%) would fall into coverage gap +- State savings: ~${medicaid_savings*0.1/1e6:.0f} million/year +""") + + +if __name__ == "__main__": + run_analysis()