diff --git a/changelog.d/8677-medicare-part-b-enrollment.changed.md b/changelog.d/8677-medicare-part-b-enrollment.changed.md new file mode 100644 index 00000000000..fbd24a7d174 --- /dev/null +++ b/changelog.d/8677-medicare-part-b-enrollment.changed.md @@ -0,0 +1 @@ +Add enrollment-gated gross Medicare Part B premiums and an explicit lagged IRMAA MAGI input path. diff --git a/policyengine_us/programs.yaml b/policyengine_us/programs.yaml index 7d90c813240..d0d0511eca6 100644 --- a/policyengine_us/programs.yaml +++ b/policyengine_us/programs.yaml @@ -906,7 +906,10 @@ programs: variable: is_medicare_eligible parameter_prefix: gov.hhs.medicare verified_start_year: 2024 - notes: Includes Part A (hospital) and Part B (medical) premiums with IRMAA adjustments + notes: >- + Includes Part A (hospital) and Part B (medical) premiums with IRMAA + adjustments; gross Part B premium calibration should use the + enrollment-gated gross_medicare_part_b_premium_if_enrolled variable. # --- HUD programs --- - id: section_8 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/income_adjusted_part_d_premium_surcharge.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/income_adjusted_part_d_premium_surcharge.yaml index e75906361dd..7f95e375c21 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/income_adjusted_part_d_premium_surcharge.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/income_adjusted_part_d_premium_surcharge.yaml @@ -46,3 +46,16 @@ takes_up_medicare_if_eligible: false output: income_adjusted_part_d_premium_surcharge: 0 + +- name: unit test 5 - direct Medicare IRMAA MAGI input + period: 2025 + input: + age: 65 + filing_status: SINGLE + adjusted_gross_income: + 2023: 50_000 + tax_exempt_interest_income: + 2023: 0 + medicare_irmaa_magi_two_years_prior: 500_000 + output: + income_adjusted_part_d_premium_surcharge: 1_029.6 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_part_b_msp_offset.py b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_part_b_msp_offset.py index 55cd5a8410a..fa1e4d45468 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_part_b_msp_offset.py +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_part_b_msp_offset.py @@ -101,6 +101,12 @@ def test_medicare_part_b_premium_is_zero_when_not_enrolled(): assert sim.calculate("msp_part_b_premium_coverage", PERIOD)[0] == pytest.approx(0) assert sim.calculate("medicare_part_b_premium", PERIOD)[0] == pytest.approx(0) + assert sim.calculate("gross_medicare_part_b_premium", PERIOD)[0] == pytest.approx( + 2_220 + ) + assert sim.calculate("gross_medicare_part_b_premium_if_enrolled", PERIOD)[ + 0 + ] == pytest.approx(0) def test_msp_part_b_premium_coverage_scales_with_eligible_months(): @@ -292,3 +298,87 @@ def test_gross_medicare_part_b_premium_handles_direct_filing_status_inputs(): assert result[0] == pytest.approx(7_546.8) assert result[1] == pytest.approx(7_546.8) assert result[2] == pytest.approx(2_220) + + +def test_gross_medicare_part_b_premium_if_enrolled_preserves_irmaa(): + sim = make_simulation( + medicare_enrolled=True, + gross_part_b_premium=4_440, + base_part_b_premium=2_220, + msp_income_eligible=False, + msp_asset_eligible=False, + ) + + assert sim.calculate("gross_medicare_part_b_premium_if_enrolled", PERIOD)[ + 0 + ] == pytest.approx(4_440) + assert sim.calculate("medicare_cost", PERIOD)[0] == pytest.approx(10_060) + + +def test_medicare_irmaa_magi_two_years_prior_falls_back_to_lagged_income(): + sim = Simulation( + tax_benefit_system=SYSTEM, + situation={ + "people": { + "person": { + "age": {PERIOD: 65}, + "base_part_b_premium": {PERIOD: 2_220}, + "is_medicare_eligible": {PERIOD: True}, + "tax_exempt_interest_income": {"2023": 6_002}, + } + }, + "households": {"household": {"members": ["person"]}}, + "tax_units": { + "tax_unit": { + "members": ["person"], + "filing_status": {PERIOD: "SINGLE"}, + "adjusted_gross_income": {"2023": 100_000}, + } + }, + "spm_units": {"spm_unit": {"members": ["person"]}}, + "families": {"family": {"members": ["person"]}}, + "marital_units": {"marital_unit": {"members": ["person"]}}, + }, + ) + + assert sim.calculate("medicare_irmaa_magi_two_years_prior", PERIOD)[ + 0 + ] == pytest.approx(106_002) + assert sim.calculate("gross_medicare_part_b_premium", PERIOD)[0] == pytest.approx( + 3_108 + ) + + +def test_medicare_irmaa_magi_two_years_prior_input_drives_irmaa(): + sim = Simulation( + tax_benefit_system=SYSTEM, + situation={ + "people": { + "person": { + "age": {PERIOD: 65}, + "base_part_b_premium": {PERIOD: 2_220}, + "is_medicare_eligible": {PERIOD: True}, + "tax_exempt_interest_income": {"2023": 0}, + } + }, + "households": {"household": {"members": ["person"]}}, + "tax_units": { + "tax_unit": { + "members": ["person"], + "filing_status": {PERIOD: "SINGLE"}, + "adjusted_gross_income": {"2023": 50_000}, + "medicare_irmaa_magi_two_years_prior": {PERIOD: 500_000}, + } + }, + "spm_units": {"spm_unit": {"members": ["person"]}}, + "families": {"family": {"members": ["person"]}}, + "marital_units": {"marital_unit": {"members": ["person"]}}, + }, + ) + + assert sim.calculate("medicare_irmaa_magi_two_years_prior", PERIOD)[ + 0 + ] == pytest.approx(500_000) + assert sim.calculate("gross_medicare_part_b_premium", PERIOD)[0] == pytest.approx( + 7_546.8 + ) diff --git a/policyengine_us/variables/gov/hhs/medicare/costs/medicare_cost.py b/policyengine_us/variables/gov/hhs/medicare/costs/medicare_cost.py index 70d9b721a67..d331f2cb9d1 100644 --- a/policyengine_us/variables/gov/hhs/medicare/costs/medicare_cost.py +++ b/policyengine_us/variables/gov/hhs/medicare/costs/medicare_cost.py @@ -21,10 +21,11 @@ def formula(person, period, parameters): period ).calibration.gov.hhs.medicare.per_capita_cost - # Premium offsets to Medicare program cost. Use gross Part B premiums - # before MSP offsets so MSP support does not inflate Medicare's value. + # Premium offsets to Medicare program cost. Use the enrollment-gated + # gross Part B premium before MSP offsets so MSP support does not + # inflate Medicare's value. part_a_premium = person("base_part_a_premium", period) - part_b_premium = person("gross_medicare_part_b_premium", period) + part_b_premium = person("gross_medicare_part_b_premium_if_enrolled", period) total_premiums = part_a_premium + part_b_premium # Net benefit = spending - premiums diff --git a/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py new file mode 100644 index 00000000000..69ef7f26626 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py @@ -0,0 +1,26 @@ +from policyengine_us.model_api import * + + +class medicare_irmaa_magi_two_years_prior(Variable): + value_type = float + entity = TaxUnit + label = "Medicare IRMAA MAGI from two years prior" + unit = USD + definition_period = YEAR + reference = "https://www.law.cornell.edu/uscode/text/42/1395r" + documentation = ( + "Modified adjusted gross income used to determine Medicare IRMAA " + "charges. Callers may provide this value directly for the current " + "benefit year. When it is not provided, PolicyEngine computes it as " + "adjusted gross income plus tax-exempt interest from two years prior. " + "Single-year datasets without lagged income inputs therefore default " + "to the modeled prior-year values, which may be zero." + ) + + def formula(tax_unit, period, parameters): + prior_period = period.offset(-2, "year") + return add( + tax_unit, + prior_period, + ["adjusted_gross_income", "tax_exempt_interest_income"], + ) diff --git a/policyengine_us/variables/gov/hhs/medicare/eligibility/part_b/gross_medicare_part_b_premium.py b/policyengine_us/variables/gov/hhs/medicare/eligibility/part_b/gross_medicare_part_b_premium.py index 4c583bf54f1..f984d84e945 100644 --- a/policyengine_us/variables/gov/hhs/medicare/eligibility/part_b/gross_medicare_part_b_premium.py +++ b/policyengine_us/variables/gov/hhs/medicare/eligibility/part_b/gross_medicare_part_b_premium.py @@ -21,12 +21,7 @@ def formula(person, period, parameters): is_head_of_household = filing_status == status.HEAD_OF_HOUSEHOLD is_surviving_spouse = filing_status == status.SURVIVING_SPOUSE is_separated = filing_status == status.SEPARATE - # Medicare Part B IRMAA is based on MAGI from 2 years prior. - # MAGI = AGI + tax-exempt interest. - prior_period = period.offset(-2, "year") - agi = tax_unit("adjusted_gross_income", prior_period) - tax_exempt_interest = tax_unit("tax_exempt_interest_income", prior_period) - magi = agi + tax_exempt_interest + magi = tax_unit("medicare_irmaa_magi_two_years_prior", period) base = person("base_part_b_premium", period) p = parameters(period).gov.hhs.medicare.part_b.irmaa diff --git a/policyengine_us/variables/gov/hhs/medicare/eligibility/part_b/gross_medicare_part_b_premium_if_enrolled.py b/policyengine_us/variables/gov/hhs/medicare/eligibility/part_b/gross_medicare_part_b_premium_if_enrolled.py new file mode 100644 index 00000000000..ba7a0e90d10 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/eligibility/part_b/gross_medicare_part_b_premium_if_enrolled.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * + + +class gross_medicare_part_b_premium_if_enrolled(Variable): + value_type = float + entity = Person + label = "Gross Medicare Part B premium if enrolled" + unit = USD + definition_period = YEAR + defined_for = "medicare_enrolled" + reference = "https://www.medicare.gov/your-medicare-costs/part-b-costs" + documentation = ( + "Annual Medicare Part B premium for enrolled beneficiaries before " + "Medicare Savings Program coverage, including any income-related " + "monthly adjustment amount. Use this enrollment-gated gross premium " + "for CMS premiums-from-enrollees calibration targets." + ) + + def formula(person, period, parameters): + return person("gross_medicare_part_b_premium", period) diff --git a/policyengine_us/variables/gov/hhs/medicare/eligibility/part_d/income_adjusted_part_d_premium_surcharge.py b/policyengine_us/variables/gov/hhs/medicare/eligibility/part_d/income_adjusted_part_d_premium_surcharge.py index 434ee356f44..b4813a80226 100644 --- a/policyengine_us/variables/gov/hhs/medicare/eligibility/part_d/income_adjusted_part_d_premium_surcharge.py +++ b/policyengine_us/variables/gov/hhs/medicare/eligibility/part_d/income_adjusted_part_d_premium_surcharge.py @@ -14,10 +14,7 @@ class income_adjusted_part_d_premium_surcharge(Variable): def formula(person, period, parameters): tax_unit = person.tax_unit filing_status = tax_unit("filing_status", period) - prior_period = period.offset(-2, "year") - agi = tax_unit("adjusted_gross_income", prior_period) - tax_exempt_interest = tax_unit("tax_exempt_interest_income", prior_period) - magi = agi + tax_exempt_interest + magi = tax_unit("medicare_irmaa_magi_two_years_prior", period) status = filing_status.possible_values statuses = [