Healthcare program modeling in PolicyEngine-US — Medicaid, ACA marketplace, CHIP, and Medicare. Covers encoding rules, running analyses, and navigating the unique complexity of US healthcare programs. Triggers: "healthcare", "health insurance", "Medicaid", "ACA", "CHIP", "Medicare", "marketplace", "premium tax credit", "APTC", "PTC", "SLCSP", "benchmark plan", "rating area", "age curve", "family tier", "coverage gap", "Medicaid expansion", "MAGI", "medicaid_magi", "aca_magi", "medicaid_income_level", "medicaid_category", "enrollment", "takeup", "take-up", "per capita", "CSR", "cost sharing", "insurance premium", "second lowest silver", "required contribution percentage", "42 CFR", "IRC 36B", "categorical eligibility", "expansion adult", "healthcare reform", "healthcare analysis", "health policy".
From essentialnpx claudepluginhub policyengine/policyengine-claude --plugin data-scienceThis skill uses the workspace's default tool permissions.
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Scope: This skill covers the healthcare domain — Medicaid, ACA marketplace, CHIP, and Medicare as modeled in PolicyEngine-US. For general PolicyEngine-US patterns, see the
policyengine-usskill. For variable/parameter implementation patterns, see thepolicyengine-variable-patternsandpolicyengine-parameter-patternsskills.
PolicyEngine-US models four interconnected healthcare programs:
| Program | What it does | Key variable |
|---|---|---|
| Medicaid | Free/low-cost coverage for low-income individuals | medicaid |
| ACA marketplace | Subsidized private insurance via premium tax credits | aca_ptc |
| CHIP | Children's health coverage above Medicaid thresholds | per_capita_chip |
| Medicare | Coverage for seniors and disabled individuals | medicare |
These programs are mutually exclusive by design — Medicaid eligibility disqualifies you from ACA subsidies, and CHIP covers children who don't qualify for Medicaid. This interconnection is a key modeling challenge.
Most benefit programs (SNAP, TANF, EITC) have a single income test and a single benefit amount. Healthcare programs are different:
| Variable | Entity | Description |
|---|---|---|
medicaid | person | Annual Medicaid benefit amount |
is_medicaid_eligible | person | Overall eligibility (combines all checks) |
medicaid_enrolled | person | Actually enrolled (eligibility x take-up) |
medicaid_category | person | Enum: SSI_RECIPIENT, INFANT, YOUNG_CHILD, OLDER_CHILD, PREGNANT, PARENT, YOUNG_ADULT, ADULT, SENIOR_OR_DISABLED, NONE |
medicaid_income_level | person | Person's tax unit MAGI as fraction of FPL |
medicaid_magi | tax_unit | Modified AGI for Medicaid (= AGI + state additions) |
takes_up_medicaid_if_eligible | person | Pseudo-random take-up flag (default rate: 93%) |
medicaid_cost | person | Per-capita cost by eligibility group and state |
| Variable | Entity | Description |
|---|---|---|
aca_ptc | tax_unit | Annual premium tax credit amount |
is_aca_ptc_eligible | person | PTC eligibility (income 100-400%+ FPL, no other coverage) |
aca_magi | tax_unit | Delegates to medicaid_magi via adds = ["medicaid_magi"] |
aca_magi_fraction | tax_unit | Income as percentage of FPL |
aca_required_contribution_percentage | tax_unit | Household's required premium contribution rate |
slcsp | tax_unit | Second-lowest silver plan premium (monthly, definition_period=MONTH). Returns $0 for ineligible people — the model skips the calculation when someone doesn't qualify for ACA. To get the unsubsidized benchmark premium regardless of eligibility, look at the underlying rating area cost parameters directly. |
takes_up_aca_if_eligible | tax_unit | Take-up flag (default rate varies) |
| Variable | Entity | Description |
|---|---|---|
per_capita_chip | person | CHIP benefit per capita |
is_chip_eligible | person | CHIP eligibility |
chip_category | person | Enum: CHILD, PREGNANT_STANDARD, PREGNANT_FCEP, NONE |
Healthcare calculations require the state to be specified (unlike some federal-only programs):
from policyengine_us import Simulation
situation = {
"people": {
"person1": {
"age": {"2026": 35},
"employment_income": {"2026": 25_000},
"is_tax_unit_head": {"2026": True},
},
"person2": {
"age": {"2026": 8},
"is_tax_unit_dependent": {"2026": True},
},
},
"tax_units": {"tax_unit": {"members": ["person1", "person2"]}},
"families": {"family": {"members": ["person1", "person2"]}},
"spm_units": {"spm_unit": {"members": ["person1", "person2"]}},
"marital_units": {"marital_unit": {"members": ["person1"]}},
"households": {
"household": {
"members": ["person1", "person2"],
"state_name": {"2026": "NY"},
}
},
}
sim = Simulation(situation=situation)
# Check Medicaid eligibility at person level
medicaid_eligible = sim.calculate("is_medicaid_eligible", 2026)
# Check ACA PTC at tax unit level
aca_ptc = sim.calculate("aca_ptc", 2026)
from policyengine_core.reforms import Reform
from policyengine_core.periods import instant
YEAR = 2026
def create_medicaid_expansion_repeal(state="UT"):
"""Remove adult Medicaid expansion by setting income limit to -inf."""
def modify_parameters(parameters):
parameters.gov.hhs.medicaid.eligibility.categories.adult.income_limit[state].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
The most common ACA policy question: extending the IRA-enhanced subsidies beyond their 2025 sunset. Reform.from_dict() does not work for ACA contribution rate parameters because they are list-valued (threshold + initial + final arrays). Use a variable-override reform instead:
from policyengine_us import Microsimulation
from policyengine_core.reforms import Reform
ANALYSIS_YEAR = 2026 # Don't use "YEAR" — it shadows model_api.YEAR
# Look up actual parameter values to build the reform
from policyengine_us import CountryTaxBenefitSystem
p = CountryTaxBenefitSystem().parameters
contrib = p.gov.aca.required_contribution_percentage
# Check what values look like pre/post sunset
print("2025 initial rates:", contrib.initial("2025-01-01")) # IRA-enhanced
print("2026 initial rates:", contrib.initial("2026-01-01")) # Post-sunset (higher)
# Option A: Use Reform.from_dict for the scalar parameters that DO work
reform = Reform.from_dict({
# The 400% FPL cap removal (IRA made subsidies available above 400%)
'gov.aca.ptc_income_eligibility[2].amount': {
f'{ANALYSIS_YEAR}-01-01.2100-12-31': True
},
}, 'policyengine_us')
# Option B: For contribution rates (list-valued), use modify_parameters
from policyengine_core.periods import instant
def create_ira_extension():
def modify_parameters(parameters):
# Restore IRA-era contribution percentages
# You need to set each bracket's initial and final rates
for bracket_param in ['initial', 'final']:
getattr(
parameters.gov.aca.required_contribution_percentage,
bracket_param,
).update(
start=instant(f"{ANALYSIS_YEAR}-01-01"),
stop=instant("2100-12-31"),
value=getattr(contrib, bracket_param)("2025-01-01"),
)
return parameters
class reform(Reform):
def apply(self):
self.modify_parameters(modify_parameters)
return reform
Also see existing reforms in policyengine_us/reforms/aca/ for production examples of variable-override ACA reforms.
from policyengine_us import Microsimulation
import numpy as np
YEAR = 2026
# Use state-calibrated dataset for state-level analysis (much more accurate)
sim = Microsimulation(
dataset="hf://policyengine/policyengine-us-data/states/UT.h5"
)
# Calculate baseline
weights = sim.calculate("person_weight", YEAR).values
medicaid_enrolled = sim.calculate("medicaid_enrolled", YEAR).values
aca_ptc = sim.calculate("aca_ptc", YEAR, map_to="person").values
# Weighted population counts
total_medicaid = (weights * medicaid_enrolled.astype(float)).sum()
total_aca_spending = (weights * aca_ptc).sum()
print(f"Medicaid enrollment: {total_medicaid:,.0f}")
print(f"Total ACA PTC spending: ${total_aca_spending:,.0f}")
When modeling reforms that change Medicaid eligibility, track where people go:
baseline_medicaid = baseline.calculate("medicaid_enrolled", YEAR).values
reform_medicaid = reform_sim.calculate("medicaid_enrolled", YEAR).values
reform_ptc_eligible = reform_sim.calculate("is_aca_ptc_eligible", YEAR).values
loses_medicaid = baseline_medicaid & ~reform_medicaid
gains_aca = loses_medicaid & reform_ptc_eligible
coverage_gap = loses_medicaid & ~reform_ptc_eligible # Below 100% FPL, no ACA access
print(f"Lose Medicaid: {(weights * loses_medicaid.astype(float)).sum():,.0f}")
print(f"Transition to ACA: {(weights * gains_aca.astype(float)).sum():,.0f}")
print(f"Fall into coverage gap: {(weights * coverage_gap.astype(float)).sum():,.0f}")
income_level = sim.calculate("medicaid_income_level", YEAR).values
# Group by FPL brackets
brackets = [(0, 1.0), (1.0, 1.38), (1.38, 2.0), (2.0, 4.0)]
for low, high in brackets:
mask = (income_level >= low) & (income_level < high)
enrolled = (weights * mask * medicaid_enrolled.astype(float)).sum()
total = (weights * mask.astype(float)).sum()
print(f"{low*100:.0f}-{high*100:.0f}% FPL: {enrolled:,.0f} / {total:,.0f}")
household_net_income (biggest time sink)The microsimulation skill says to use household_net_income change as the budgetary cost measure. This does not work for ACA premium tax credit reforms. The PTC is classified as an in-kind health benefit, not a refundable tax credit, so it flows through a separate chain:
aca_ptc → premium_tax_credit → household_health_benefits → household_benefits
There is a toggle parameter gov.simulation.include_health_benefits_in_net_income that defaults to False. With the default, PTC changes produce a $0 change in household_net_income.
How to measure PTC impact instead:
# Option 1: Use household_net_income_including_health_benefits
baseline_hni = baseline.calc("household_net_income_including_health_benefits", period=YEAR)
reformed_hni = reformed.calc("household_net_income_including_health_benefits", period=YEAR)
cost = (reformed_hni - baseline_hni).sum()
# Option 2: Measure the PTC change directly
baseline_ptc = baseline.calc("aca_ptc", period=YEAR).sum()
reformed_ptc = reformed.calc("aca_ptc", period=YEAR).sum()
ptc_cost = reformed_ptc - baseline_ptc
# Option 3: Enable the toggle in the reform
# Add gov.simulation.include_health_benefits_in_net_income = True
# Then household_net_income will include PTC
In PolicyEngine context, "IRA reform" is shorthand for extending the Inflation Reduction Act's enhanced ACA premium tax credit brackets beyond their 2025 sunset. The IRA (2022) temporarily expanded ACA subsidies by lowering required contribution percentages and removing the 400% FPL subsidy cliff. These enhancements are scheduled to expire — "IRA reform" typically means making them permanent or extending them.
See the variable-override reform example below for how to implement this.
YEAR constant shadows model_api.YEARIf you define YEAR = 2026 in the same file where you also define a reform variable class, it shadows the YEAR period constant imported from model_api. This breaks definition_period = YEAR on variable classes:
# ❌ This breaks — YEAR is now 2026 (int), not the period constant
from policyengine_us.model_api import *
YEAR = 2026 # Shadows model_api.YEAR
class my_reform_variable(Variable):
definition_period = YEAR # Gets 2026, not the YEAR period constant!
# ✅ Use a different name for the analysis year
from policyengine_us.model_api import *
ANALYSIS_YEAR = 2026
class my_reform_variable(Variable):
definition_period = YEAR # Gets the period constant from model_api
slcsp returns $0 for ineligible peopleThe slcsp variable (second-lowest silver plan premium) returns $0 when the person is not ACA-eligible — the model skips the premium lookup entirely. If you need the unsubsidized benchmark premium for comparison purposes (e.g., "what would this person pay without subsidies?"), you can't just read slcsp. Instead, look up the premium from the rating area cost parameters directly:
from policyengine_us import CountryTaxBenefitSystem
p = CountryTaxBenefitSystem().parameters
# state_rating_area_cost is indexed by state and rating area
p.gov.aca.state_rating_area_cost("2026-01-01")
healthcare_benefit_value entity mapping in population analysishealthcare_benefit_value is a household-level variable that aggregates a tax-unit-level variable (aca_ptc). When you use map_to='person', the benefit value gets spread across all household members, including those not in the affected tax unit. This can produce a "mystery group" that appears to gain healthcare benefit value but shows no change in aca_ptc. Measure PTC impact at the tax-unit level when possible.
The national CPS dataset can give implausible state-level results. For example, national CPS showed 76% employer-sponsored insurance at 100-138% FPL in Utah — the state-calibrated dataset gives much more realistic estimates.
# National (less accurate for state analysis)
sim = Microsimulation(dataset="hf://policyengine/policyengine-us-data/enhanced_cps_2024.h5")
# State-calibrated (preferred for state analysis)
sim = Microsimulation(dataset="hf://policyengine/policyengine-us-data/states/UT.h5")
LA county's rating area can cause errors in California simulations. Workaround:
import numpy as np
try:
aca_ptc = sim.calculate("aca_ptc", YEAR)
except Exception as e:
if state == "CA":
sim.set_input("in_la", YEAR, np.zeros(n_households, dtype=bool))
aca_ptc = sim.calculate("aca_ptc", YEAR)
People below 100% FPL who lose Medicaid may not qualify for ACA premium tax credits (which start at 100% FPL in non-expansion states). Always check for this when modeling Medicaid eligibility changes.
When removing one Medicaid eligibility pathway (e.g., adult expansion), some people may remain eligible through a different category (e.g., parent Medicaid). Track medicaid_category in both baseline and reform to identify true coverage loss vs. category reassignment.
When reviewing healthcare PRs, check these domain-specific items in addition to the standard review checklist:
Program interactions:
Eligibility structure:
_fc / _nfc split patternGeographic variation:
Temporal correctness:
Take-up and costs:
Healthcare variables live in three directories under policyengine_us/variables/gov/:
gov/hhs/medicaid/ # ~44 variable files
gov/aca/ # ~24 variable files
gov/hhs/chip/ # 8 variable files
gov/hhs/medicare/ # Parts A, B, savings programs
| Pattern | Example | Purpose |
|---|---|---|
is_[program]_eligible | is_medicaid_eligible | Overall eligibility flag |
[program]_category | medicaid_category | Enum categorization |
is_[category]_for_[program] | is_adult_for_medicaid | Category-specific eligibility |
is_[category]_for_[program]_fc | is_adult_for_medicaid_fc | Financial criteria only |
is_[category]_for_[program]_nfc | is_adult_for_medicaid_nfc | Non-financial criteria only |
[program]_magi | medicaid_magi | Income measure |
[program]_income_level | medicaid_income_level | Income as fraction of FPL |
takes_up_[program]_if_eligible | takes_up_medicaid_if_eligible | Take-up modeling |
[program]_cost | medicaid_cost | Benefit cost/amount |
per_capita_[program] | per_capita_chip | Per-person cost |
_fc / _nfc patternHealthcare eligibility splits financial and non-financial criteria into separate variables. This is important because:
# is_adult_for_medicaid.py combines both using a class attribute (not a formula):
class is_adult_for_medicaid(Variable):
# all_of_variables as class attribute — no formula method needed
formula = all_of_variables([
"is_adult_for_medicaid_fc", # Income < state limit
"is_adult_for_medicaid_nfc", # Age 19-64, not pregnant
])
Categories are evaluated in regulatory precedence order (42 CFR 435.119). Mandatory groups first, then optional:
# From medicaid_category.py — ORDER MATTERS
variable_to_category = dict(
is_ssi_recipient_for_medicaid=MedicaidCategory.SSI_RECIPIENT, # 1st
is_infant_for_medicaid=MedicaidCategory.INFANT, # 2nd
is_young_child_for_medicaid=MedicaidCategory.YOUNG_CHILD, # 3rd
is_older_child_for_medicaid=MedicaidCategory.OLDER_CHILD, # 4th
is_pregnant_for_medicaid=MedicaidCategory.PREGNANT, # 5th
is_parent_for_medicaid=MedicaidCategory.PARENT, # 6th
is_young_adult_for_medicaid=MedicaidCategory.YOUNG_ADULT, # 7th
is_adult_for_medicaid=MedicaidCategory.ADULT, # 8th (expansion)
is_senior_or_disabled_for_medicaid=MedicaidCategory.SENIOR_OR_DISABLED, # Last
)
A person matched to an earlier category is assigned that category even if they also qualify for a later one. This means adult expansion is always the residual category.
Healthcare parameters are deeply nested with state-level overrides:
parameters/gov/hhs/medicaid/
├── eligibility/
│ └── categories/
│ ├── adult/income_limit.yaml # State-by-state limits
│ ├── infant/income_limit.yaml
│ ├── parent/income_limit.yaml
│ └── [5 more categories]
├── income/modification.yaml # AGI adjustments
├── takeup_rate.yaml # 0.93 nationally
└── emergency_medicaid/enabled.yaml
parameters/gov/aca/
├── state_rating_area_cost.yaml # 1,565 lines — SLCSP by state x rating area
├── age_curves/
│ ├── default.yaml # Federal 3:1 ratio
│ ├── al.yaml, dc.yaml, ... # 7 states with custom curves
│ └── ny.yaml, vt.yaml # Family tier states
├── required_contribution_percentage/ # LIST-VALUED — not scalar!
│ ├── threshold.yaml # FPL bracket boundaries (list)
│ ├── initial.yaml # Initial contribution rates by bracket (list)
│ └── final.yaml # Final contribution rates by bracket (list)
│ # These three files form parallel arrays. Reform.from_dict() cannot modify
│ # list-valued parameters — use modify_parameters() instead.
└── ptc_income_eligibility.yaml # 100-400%+ FPL range (bracket-indexed)
The ACA premium calculation involves three layers of geographic variation:
When adding or updating ACA parameters, check whether the state uses the default federal rules or has its own.
Healthcare programs check for mutual exclusion:
# In is_aca_ptc_eligible.py
INELIGIBLE_COVERAGE = [
"is_medicaid_eligible",
"is_chip_eligible",
# ... other coverage sources
]
is_coverage_eligible = add(person, period, INELIGIBLE_COVERAGE) == 0
When modifying one program's eligibility, always verify the downstream effects on other programs. Removing Medicaid expansion, for example, shifts people to ACA eligibility (if above 100% FPL) or into a coverage gap (if below).
Healthcare tests require attention to program interactions and categorical complexity that other benefit programs don't have.
Testing category precedence:
Verify that a person eligible for multiple Medicaid categories gets assigned the highest-precedence one:
- name: Case 1, pregnant adult assigned PREGNANT not ADULT.
period: 2026-01
absolute_error_margin: 0.1
input:
people:
person1:
age: 25
is_pregnant: true
employment_income: 15_000
households:
household1:
state_code_str: NY
output:
medicaid_category: PREGNANT # Not ADULT, even though income qualifies for expansion
Testing program mutual exclusion:
Verify that Medicaid-eligible people are excluded from ACA PTC:
- name: Case 2, Medicaid eligible person gets no ACA PTC.
period: 2026
absolute_error_margin: 0.1
input:
people:
person1:
age: 30
employment_income: 15_000
is_tax_unit_head: true
households:
household1:
state_code_str: NY
output:
is_medicaid_eligible: true
aca_ptc: 0
Testing the _fc / _nfc split independently:
Test financial and non-financial criteria separately to isolate failures:
# Financial criteria only
- name: Case 3, adult income below Medicaid limit.
period: 2026-01
absolute_error_margin: 0.1
input:
people:
person1:
age: 30
employment_income: 15_000
households:
household1:
state_code_str: NY
output:
is_adult_for_medicaid_fc: true
# Non-financial criteria only
- name: Case 4, person in adult age range.
period: 2026-01
absolute_error_margin: 0.1
input:
people:
person1:
age: 30
is_pregnant: false
output:
is_adult_for_medicaid_nfc: true
Testing coverage transitions in reforms:
When testing reforms that change eligibility, verify where people land:
# In integration tests, check all three outcomes:
# 1. Still has coverage (different program)
# 2. Transitions to ACA
# 3. Falls into coverage gap (below 100% FPL, no ACA access)
Testing state-specific ACA rules:
For states with custom age curves or family tiers, include state-specific test cases:
# NY uses family tiers instead of age rating
- name: Case 5, NY family tier premium.
period: 2026
absolute_error_margin: 1
input:
people:
person1:
age: 35
employment_income: 40_000
is_tax_unit_head: true
person2:
age: 8
is_tax_unit_dependent: true
households:
household1:
state_code_str: NY
output:
slcsp: 0 # Verify against NY family tier rates, not age-based
All healthcare programs use pseudo-random seeding for take-up:
class takes_up_medicaid_if_eligible(Variable):
def formula(person, period, parameters):
seed = person("medicaid_take_up_seed", period) # Random 0-1
takeup_rate = parameters(period).gov.hhs.medicaid.takeup_rate
return seed < takeup_rate
Default take-up rates: Medicaid ~93%, ACA PTC ~62-67%. These are parameterized and can be modified in reforms.
Healthcare costs use per-capita lookups calibrated from MACPAC and CMS data, broken down by eligibility group and state:
class medicaid_cost_if_enrolled(Variable):
def formula(person, period, parameters):
group = person("medicaid_group", period) # Enum
state = person.household("state_code", period)
per_capita = parameters(period).calibration.gov.hhs.medicaid.spending
return per_capita.by_eligibility_group[group][state]
policyengine_us/variables/gov/hhs/medicaid/policyengine_us/variables/gov/aca/policyengine_us/variables/gov/hhs/chip/policyengine_us/parameters/gov/hhs/medicaid/, policyengine_us/parameters/gov/aca/policyengine_us/parameters/calibration/gov/hhs/analysis-notebooks/us/healthcare/, analysis-notebooks/us/medicaid/, analysis-notebooks/us/states/ut/