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()