diff --git a/changelog.d/codex-medicare-partb-msp-clean.fixed.md b/changelog.d/codex-medicare-partb-msp-clean.fixed.md new file mode 100644 index 00000000000..44bb375c6a1 --- /dev/null +++ b/changelog.d/codex-medicare-partb-msp-clean.fixed.md @@ -0,0 +1 @@ +Model Medicare Part B premiums in baseline SPM expenses, preserve reported premiums as an audit input, and net out a cycle-free MSP proxy so baseline MOOP better reflects likely beneficiary out-of-pocket premiums. diff --git a/policyengine_us/parameters/gov/irs/gross_income/pre_tax_contributions.yaml b/policyengine_us/parameters/gov/irs/gross_income/pre_tax_contributions.yaml index 07771735119..a2b40c7e36b 100644 --- a/policyengine_us/parameters/gov/irs/gross_income/pre_tax_contributions.yaml +++ b/policyengine_us/parameters/gov/irs/gross_income/pre_tax_contributions.yaml @@ -4,8 +4,9 @@ values: # Assumes all are pre-tax. - traditional_401k_contributions - traditional_403b_contributions - # Assumes employer for now. - - health_insurance_premiums + # Assumes employer-sponsored premiums only; Medicare Part B is not a + # pre-tax payroll deduction. + - health_insurance_premiums_without_medicare_part_b # HSA contributions can be either through pre-tax. - health_savings_account_payroll_contributions metadata: diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/msp_part_b_premium_coverage.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/msp_part_b_premium_coverage.yaml new file mode 100644 index 00000000000..0c450945ee9 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/msp_part_b_premium_coverage.yaml @@ -0,0 +1,29 @@ +- name: MSP part B coverage pays the standard premium for income- and asset-eligible enrollees + period: 2025 + input: + medicare_enrolled: true + msp_income_eligible: true + msp_asset_eligible: true + base_part_b_premium: 2_220 + output: + msp_part_b_premium_coverage: 2_220 + +- name: MSP part B coverage is zero for ineligible enrollees + period: 2025 + input: + medicare_enrolled: true + msp_income_eligible: false + msp_asset_eligible: true + base_part_b_premium: 2_220 + output: + msp_part_b_premium_coverage: 0 + +- name: MSP part B coverage is zero when not enrolled + period: 2025 + input: + medicare_enrolled: false + msp_income_eligible: true + msp_asset_eligible: true + base_part_b_premium: 2_220 + output: + msp_part_b_premium_coverage: 0 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 new file mode 100644 index 00000000000..9c976b8ea41 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_part_b_msp_offset.py @@ -0,0 +1,260 @@ +import pytest + +from policyengine_us import CountryTaxBenefitSystem, Simulation + + +SYSTEM = CountryTaxBenefitSystem() +PERIOD = "2025" + + +def make_simulation( + *, + medicare_enrolled: bool, + gross_part_b_premium: float, + base_part_b_premium: float, + msp_income_eligible: bool, + msp_asset_eligible: bool, +) -> Simulation: + return Simulation( + tax_benefit_system=SYSTEM, + situation={ + "people": { + "person": { + "age": {PERIOD: 65}, + "medicare_enrolled": {PERIOD: medicare_enrolled}, + "income_adjusted_part_b_premium": {PERIOD: gross_part_b_premium}, + "base_part_b_premium": {PERIOD: base_part_b_premium}, + "msp_income_eligible": {f"{PERIOD}-01": msp_income_eligible}, + "msp_asset_eligible": {f"{PERIOD}-01": msp_asset_eligible}, + } + }, + "households": {"household": {"members": ["person"]}}, + "tax_units": {"tax_unit": {"members": ["person"]}}, + "spm_units": {"spm_unit": {"members": ["person"]}}, + "families": {"family": {"members": ["person"]}}, + "marital_units": {"marital_unit": {"members": ["person"]}}, + }, + ) + + +def test_msp_part_b_premium_coverage_pays_standard_premium(): + sim = make_simulation( + medicare_enrolled=True, + gross_part_b_premium=4_440, + base_part_b_premium=2_220, + msp_income_eligible=True, + msp_asset_eligible=True, + ) + + assert sim.calculate("msp_part_b_premium_coverage", PERIOD)[0] == pytest.approx( + 2_220 + ) + + +def test_medicare_part_b_premiums_preserve_only_irmaa_above_msp_support(): + sim = make_simulation( + medicare_enrolled=True, + gross_part_b_premium=4_440, + base_part_b_premium=2_220, + msp_income_eligible=True, + msp_asset_eligible=True, + ) + + assert sim.calculate("medicare_part_b_premiums", PERIOD)[0] == pytest.approx(2_220) + + +def test_medicare_part_b_premiums_are_zero_when_msp_covers_standard_premium(): + sim = make_simulation( + medicare_enrolled=True, + gross_part_b_premium=2_220, + base_part_b_premium=2_220, + msp_income_eligible=True, + msp_asset_eligible=True, + ) + + assert sim.calculate("medicare_part_b_premiums", PERIOD)[0] == pytest.approx(0) + + +def test_medicare_cost_uses_gross_part_b_before_msp_offset(): + sim = Simulation( + tax_benefit_system=SYSTEM, + situation={ + "people": { + "person": { + "age": {PERIOD: 65}, + "medicare_enrolled": {PERIOD: True}, + "base_part_a_premium": {PERIOD: 0}, + "income_adjusted_part_b_premium": {PERIOD: 2_220}, + "base_part_b_premium": {PERIOD: 2_220}, + "msp_income_eligible": {f"{PERIOD}-01": True}, + "msp_asset_eligible": {f"{PERIOD}-01": True}, + } + }, + "households": {"household": {"members": ["person"]}}, + "tax_units": {"tax_unit": {"members": ["person"]}}, + "spm_units": {"spm_unit": {"members": ["person"]}}, + "families": {"family": {"members": ["person"]}}, + "marital_units": {"marital_unit": {"members": ["person"]}}, + }, + ) + + assert sim.calculate("medicare_part_b_premiums", PERIOD)[0] == pytest.approx(0) + assert sim.calculate("medicare_cost", PERIOD)[0] == pytest.approx(12_280) + + +def test_medicare_part_b_premiums_are_zero_when_not_enrolled(): + sim = make_simulation( + medicare_enrolled=False, + gross_part_b_premium=2_220, + base_part_b_premium=2_220, + msp_income_eligible=True, + msp_asset_eligible=True, + ) + + assert sim.calculate("msp_part_b_premium_coverage", PERIOD)[0] == pytest.approx(0) + assert sim.calculate("medicare_part_b_premiums", PERIOD)[0] == pytest.approx(0) + + +def test_legacy_medicare_part_b_input_uprates_forward(): + sim = Simulation( + tax_benefit_system=SYSTEM, + situation={ + "people": { + "person": { + "age": {"2024": 65}, + "medicare_part_b_premiums": {"2024": 1_000}, + } + }, + "households": {"household": {"members": ["person"]}}, + "tax_units": {"tax_unit": {"members": ["person"]}}, + "spm_units": {"spm_unit": {"members": ["person"]}}, + "families": {"family": {"members": ["person"]}}, + "marital_units": {"marital_unit": {"members": ["person"]}}, + }, + ) + + assert sim.calculate("medicare_part_b_premiums", "2025")[0] == pytest.approx( + 1_030.8833, + abs=1e-3, + ) + + +def test_msp_part_b_premium_coverage_scales_with_eligible_months(): + monthly_eligibility = { + f"{PERIOD}-{month:02d}": month <= 3 for month in range(1, 13) + } + sim = Simulation( + tax_benefit_system=SYSTEM, + situation={ + "people": { + "person": { + "age": {PERIOD: 65}, + "medicare_enrolled": {PERIOD: True}, + "base_part_b_premium": {PERIOD: 2_220}, + "msp_income_eligible": monthly_eligibility, + "msp_asset_eligible": monthly_eligibility, + } + }, + "households": {"household": {"members": ["person"]}}, + "tax_units": {"tax_unit": {"members": ["person"]}}, + "spm_units": {"spm_unit": {"members": ["person"]}}, + "families": {"family": {"members": ["person"]}}, + "marital_units": {"marital_unit": {"members": ["person"]}}, + }, + ) + + assert sim.calculate("msp_part_b_premium_coverage", PERIOD)[0] == pytest.approx( + 555, + abs=1e-6, + ) + + +def test_medicare_part_b_premiums_do_not_depend_on_calculation_order(): + no_msp_eligibility = { + f"{year}-{month:02d}": False + for year in ("2025", "2026") + for month in range(1, 13) + } + situation = { + "people": { + "person": { + "age": {"2025": 65, "2026": 66}, + "medicare_enrolled": {"2025": True, "2026": True}, + "income_adjusted_part_b_premium": {"2025": 2_220, "2026": 2_220}, + "base_part_b_premium": {"2025": 2_220, "2026": 2_220}, + "msp_income_eligible": no_msp_eligibility, + "msp_asset_eligible": no_msp_eligibility, + } + }, + "households": {"household": {"members": ["person"]}}, + "tax_units": {"tax_unit": {"members": ["person"]}}, + "spm_units": {"spm_unit": {"members": ["person"]}}, + "families": {"family": {"members": ["person"]}}, + "marital_units": {"marital_unit": {"members": ["person"]}}, + } + + ordered_sim = Simulation(tax_benefit_system=SYSTEM, situation=situation) + ordered_sim.calculate("medicare_part_b_premiums", "2025") + ordered_result = ordered_sim.calculate("medicare_part_b_premiums", "2026")[0] + + fresh_sim = Simulation(tax_benefit_system=SYSTEM, situation=situation) + fresh_result = fresh_sim.calculate("medicare_part_b_premiums", "2026")[0] + + assert ordered_result == pytest.approx(fresh_result) + assert ordered_result == pytest.approx(2_220) + + +def test_income_adjusted_part_b_premium_handles_direct_filing_status_inputs(): + sim = Simulation( + tax_benefit_system=SYSTEM, + situation={ + "people": { + "person_1": { + "age": {PERIOD: 65}, + "base_part_b_premium": {PERIOD: 2_220}, + "is_medicare_eligible": {PERIOD: True}, + "tax_exempt_interest_income": {"2023": 0}, + }, + "person_2": { + "age": {PERIOD: 65}, + "base_part_b_premium": {PERIOD: 2_220}, + "is_medicare_eligible": {PERIOD: True}, + "tax_exempt_interest_income": {"2023": 0}, + }, + "person_3": { + "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_1", "person_2", "person_3"]} + }, + "tax_units": { + "joint_tax_unit": { + "members": ["person_1", "person_2"], + "filing_status": {PERIOD: "JOINT"}, + "adjusted_gross_income": {"2023": 1_000_000}, + }, + "single_tax_unit": { + "members": ["person_3"], + "filing_status": {PERIOD: "SINGLE"}, + "adjusted_gross_income": {"2023": 50_000}, + }, + }, + "spm_units": { + "spm_unit": {"members": ["person_1", "person_2", "person_3"]} + }, + "families": {"family": {"members": ["person_1", "person_2", "person_3"]}}, + "marital_units": { + "marital_unit_1": {"members": ["person_1", "person_2"]}, + "marital_unit_2": {"members": ["person_3"]}, + }, + }, + ) + + result = sim.calculate("income_adjusted_part_b_premium", PERIOD) + assert result[0] == pytest.approx(7_546.8) + assert result[1] == pytest.approx(7_546.8) + assert result[2] == pytest.approx(2_220) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/credits/homestead_property_tax/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/credits/homestead_property_tax/integration.yaml index edf07d41ba2..d07c1c25e06 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/credits/homestead_property_tax/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/credits/homestead_property_tax/integration.yaml @@ -198,6 +198,7 @@ employment_income: 56_000 real_estate_taxes: 2_500 age: 66 + medicare_part_b_premiums: 0 state_code: MI output: # (2500 - 56000 * 0.032) * 0.6 * 0.5 mi_homestead_property_tax_credit_countable_property_tax: 2_500 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/integration.yaml index 8919f13c7c4..aff3d89b179 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/integration.yaml @@ -5,6 +5,7 @@ people: person1: age: 67 + medicare_part_b_premiums: 0 employment_income: 10_010 taxable_interest_income: 11_010 taxable_private_pension_income: 7_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/deductions/mo_pension_and_ss_or_ssd_deduction/integration_tests/mo_pension_and_ss_or_ssd.yaml b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/deductions/mo_pension_and_ss_or_ssd_deduction/integration_tests/mo_pension_and_ss_or_ssd.yaml index 67686256a6c..d82026d04ba 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/deductions/mo_pension_and_ss_or_ssd_deduction/integration_tests/mo_pension_and_ss_or_ssd.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/deductions/mo_pension_and_ss_or_ssd_deduction/integration_tests/mo_pension_and_ss_or_ssd.yaml @@ -5,11 +5,13 @@ people: person1: age: 78 + medicare_part_b_premiums: 0 employment_income: 25_000 is_tax_unit_head: true taxable_social_security: 10_000 person2: age: 72 + medicare_part_b_premiums: 0 employment_income: 25_000 is_tax_unit_spouse: true taxable_public_pension_income: 10_000 @@ -35,11 +37,13 @@ people: person1: age: 78 + medicare_part_b_premiums: 0 employment_income: 25_000 is_tax_unit_head: true taxable_social_security: 10_000 person2: age: 72 + medicare_part_b_premiums: 0 employment_income: 15_000 is_tax_unit_spouse: true taxable_social_security: 10_000 @@ -65,11 +69,13 @@ people: person1: age: 78 + medicare_part_b_premiums: 0 employment_income: 25_000 is_tax_unit_head: true taxable_social_security: 6_000 person2: age: 72 + medicare_part_b_premiums: 0 employment_income: 25_000 is_tax_unit_spouse: true taxable_social_security: 6_000 @@ -95,11 +101,13 @@ people: person1: age: 78 + medicare_part_b_premiums: 0 employment_income: 25_000 is_tax_unit_head: true taxable_social_security: 10_000 person2: age: 72 + medicare_part_b_premiums: 0 employment_income: 25_000 is_tax_unit_spouse: true taxable_private_pension_income: 10_000 @@ -125,6 +133,7 @@ people: person1: age: 78 + medicare_part_b_premiums: 0 employment_income: 25_000 is_tax_unit_head: true taxable_public_pension_income: 5_000 @@ -132,6 +141,7 @@ taxable_social_security: 10_000 person2: age: 72 + medicare_part_b_premiums: 0 employment_income: 25_000 is_tax_unit_spouse: true taxable_public_pension_income: 5_000 @@ -158,6 +168,7 @@ people: person1: age: 78 + medicare_part_b_premiums: 0 employment_income: 25_000 is_tax_unit_head: true taxable_public_pension_income: 2_500 @@ -165,6 +176,7 @@ taxable_social_security: 10_000 person2: age: 72 + medicare_part_b_premiums: 0 employment_income: 25_000 is_tax_unit_spouse: true taxable_public_pension_income: 2_500 @@ -192,6 +204,7 @@ people: person1: age: 78 + medicare_part_b_premiums: 0 employment_income: 25_000 is_tax_unit_head: true taxable_public_pension_income: 5_000 @@ -199,6 +212,7 @@ taxable_social_security: 11_000 person2: age: 72 + medicare_part_b_premiums: 0 employment_income: 25_000 is_tax_unit_spouse: true taxable_public_pension_income: 5_000 @@ -226,11 +240,13 @@ people: person1: age: 78 + medicare_part_b_premiums: 0 employment_income: 25_000 is_tax_unit_head: true taxable_public_pension_income: 10_000 person2: age: 72 + medicare_part_b_premiums: 0 employment_income: 25_000 is_tax_unit_spouse: true taxable_public_pension_income: 10_000 @@ -256,11 +272,13 @@ people: person1: age: 78 + medicare_part_b_premiums: 0 employment_income: 25_000 is_tax_unit_head: true taxable_private_pension_income: 10_000 person2: age: 72 + medicare_part_b_premiums: 0 employment_income: 25_000 is_tax_unit_spouse: true taxable_private_pension_income: 10_000 @@ -286,12 +304,14 @@ people: person1: age: 78 + medicare_part_b_premiums: 0 employment_income: 5_000 is_tax_unit_head: true taxable_private_pension_income: 5_000 taxable_public_pension_income: 5_000 person2: age: 72 + medicare_part_b_premiums: 0 employment_income: 5_000 is_tax_unit_spouse: true taxable_private_pension_income: 5_000 @@ -382,6 +402,7 @@ person1: is_tax_unit_head: true age: 69 + medicare_part_b_premiums: 0 employment_income: 15_010 taxable_interest_income: 5_505 taxable_private_pension_income: 3_000 @@ -392,6 +413,7 @@ person2: is_tax_unit_spouse: true age: 69 + medicare_part_b_premiums: 0 employment_income: 3_010 taxable_interest_income: 5_505 taxable_private_pension_income: 3_000 @@ -429,6 +451,7 @@ people: person1: age: 75 + medicare_part_b_premiums: 0 employment_income: 0 taxable_interest_income: 131 taxable_private_pension_income: 25_717 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/income_tax/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/income_tax/integration.yaml index 74d6e01951e..661eecf194b 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/income_tax/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/income_tax/integration.yaml @@ -150,6 +150,7 @@ people: person1: age: 78 + medicare_part_b_premiums: 0 taxable_interest_income: 21_896.16 short_term_capital_gains: 6_010.3447 long_term_capital_gains: 44_087.02 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/exclusions/nj_other_retirement_income_exclusion.yaml b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/exclusions/nj_other_retirement_income_exclusion.yaml index d565b0e2bc2..01fb22c28be 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/exclusions/nj_other_retirement_income_exclusion.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/exclusions/nj_other_retirement_income_exclusion.yaml @@ -40,6 +40,7 @@ people: person1: age: 68 + medicare_part_b_premiums: 0 employment_income: 0.0 ssi: 0 wic: 0 @@ -53,6 +54,7 @@ is_tax_unit_head: true person2: age: 67 + medicare_part_b_premiums: 0 employment_income: 0.0 ssi: 0 wic: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/integration.yaml index bc669cc84d4..a77b77ef86a 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/integration.yaml @@ -143,6 +143,7 @@ people: person1: age: 71 + medicare_part_b_premiums: 0 taxable_interest_income: 104_762 is_tax_unit_head: true tax_units: diff --git a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/subtractions/nj_social_security_exclusion.yaml b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/subtractions/nj_social_security_exclusion.yaml index 71c6f62c1d1..9a32491f637 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/subtractions/nj_social_security_exclusion.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/subtractions/nj_social_security_exclusion.yaml @@ -11,6 +11,7 @@ people: person1: age: 75 + medicare_part_b_premiums: 0 employment_income: 37_274 social_security: 27_262 is_tax_unit_head: true diff --git a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/integration.yaml index e79403347a5..d73eed92962 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/integration.yaml @@ -62,6 +62,7 @@ people: person1: age: 70 + medicare_part_b_premiums: 0 employment_income: 200_000 ssi: 0 ma_state_supplement: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/integration.yaml index fc95821484c..62861dad744 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/integration.yaml @@ -5,6 +5,7 @@ people: person1: age: 69 + medicare_part_b_premiums: 0 employment_income: 29_010 taxable_interest_income: 11_010 ssi: 0 # not in TAXSIM35 @@ -130,6 +131,7 @@ people: person1: age: 70 + medicare_part_b_premiums: 0 employment_income: 10000.0 ssi: 0 wic: 0 @@ -141,6 +143,7 @@ is_tax_unit_head: true person2: age: 70 + medicare_part_b_premiums: 0 employment_income: 10000.0 ssi: 0 wic: 0 diff --git a/policyengine_us/tests/policy/baseline/household/expense/health/medicare_part_b_premiums.yaml b/policyengine_us/tests/policy/baseline/household/expense/health/medicare_part_b_premiums.yaml new file mode 100644 index 00000000000..ea3748cee5a --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/expense/health/medicare_part_b_premiums.yaml @@ -0,0 +1,35 @@ +- name: Medicare Part B premiums equal modeled premium when enrolled + period: 2025 + input: + medicare_enrolled: true + income_adjusted_part_b_premium: 4_440 + msp_part_b_premium_coverage: 0 + output: + medicare_part_b_premiums: 4_440 + +- name: Medicare Part B premiums are fully offset when MSP covers the standard premium + period: 2025 + input: + medicare_enrolled: true + income_adjusted_part_b_premium: 2_220 + msp_part_b_premium_coverage: 2_220 + output: + medicare_part_b_premiums: 0 + +- name: Medicare Part B premiums preserve IRMAA above the MSP-covered standard premium + period: 2025 + input: + medicare_enrolled: true + income_adjusted_part_b_premium: 4_440 + msp_part_b_premium_coverage: 2_220 + output: + medicare_part_b_premiums: 2_220 + +- name: Medicare Part B premiums are zero when not enrolled + period: 2025 + input: + medicare_enrolled: false + income_adjusted_part_b_premium: 2_220 + msp_part_b_premium_coverage: 0 + output: + medicare_part_b_premiums: 0 diff --git a/policyengine_us/tools/default_uprating.py b/policyengine_us/tools/default_uprating.py index a69922ba8fe..07174e05ba7 100644 --- a/policyengine_us/tools/default_uprating.py +++ b/policyengine_us/tools/default_uprating.py @@ -103,7 +103,7 @@ "strike_benefits", "other_medical_expenses", "over_the_counter_health_expenses", - "medicare_part_b_premiums", + "medicare_part_b_premiums_reported", "health_insurance_premiums_without_medicare_part_b", ] 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 96f78ece9a3..bdbab201270 100644 --- a/policyengine_us/variables/gov/hhs/medicare/costs/medicare_cost.py +++ b/policyengine_us/variables/gov/hhs/medicare/costs/medicare_cost.py @@ -21,7 +21,8 @@ def formula(person, period, parameters): period ).calibration.gov.hhs.medicare.per_capita_cost - # Premiums paid by beneficiary + # Premium offsets to Medicare program cost. Use gross Part B premiums + # 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("income_adjusted_part_b_premium", period) total_premiums = part_a_premium + part_b_premium diff --git a/policyengine_us/variables/gov/hhs/medicare/eligibility/part_b/income_adjusted_part_b_premium.py b/policyengine_us/variables/gov/hhs/medicare/eligibility/part_b/income_adjusted_part_b_premium.py index 0a857e02654..cfd15240248 100644 --- a/policyengine_us/variables/gov/hhs/medicare/eligibility/part_b/income_adjusted_part_b_premium.py +++ b/policyengine_us/variables/gov/hhs/medicare/eligibility/part_b/income_adjusted_part_b_premium.py @@ -13,7 +13,11 @@ class income_adjusted_part_b_premium(Variable): def formula(person, period, parameters): tax_unit = person.tax_unit + filing_status_holder = tax_unit.simulation.get_holder("filing_status") filing_status = tax_unit("filing_status", period) + status = filing_status_holder.variable.possible_values + is_joint = filing_status == status.JOINT + 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") @@ -22,28 +26,18 @@ def formula(person, period, parameters): magi = agi + tax_exempt_interest base = person("base_part_b_premium", period) - # Build boolean masks for each status - status = filing_status.possible_values - statuses = [ - status.SINGLE, - status.JOINT, - status.HEAD_OF_HOUSEHOLD, - status.SURVIVING_SPOUSE, - status.SEPARATE, - ] - in_status = [filing_status == s for s in statuses] - p = parameters(period).gov.hhs.medicare.part_b.irmaa irmaa_amount = select( - in_status, [ - p.single.calc(magi), + is_joint, + is_separated, + ], + [ p.joint.calc(magi), - p.head_of_household.calc(magi), - p.surviving_spouse.calc(magi), p.separate.calc(magi), ], + default=p.single.calc(magi), ) # IRMAA amounts are monthly, multiply by MONTHS_IN_YEAR to get annual diff --git a/policyengine_us/variables/gov/hhs/medicare/savings_programs/msp_part_b_premium_coverage.py b/policyengine_us/variables/gov/hhs/medicare/savings_programs/msp_part_b_premium_coverage.py new file mode 100644 index 00000000000..cac89f2d0d1 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/savings_programs/msp_part_b_premium_coverage.py @@ -0,0 +1,41 @@ +from policyengine_us.model_api import * + + +class msp_part_b_premium_coverage(Variable): + value_type = float + entity = Person + unit = USD + label = "Medicare Part B premium amount covered by MSP" + definition_period = YEAR + reference = ( + "https://www.medicare.gov/basics/costs/help/medicare-savings-programs", + ) + documentation = """ + Annual standard Part B premium amount paid on the enrollee's behalf through a + Medicare Savings Program-like pathway. + + This uses the MSP income and asset rules directly and intentionally avoids the + modeled Medicaid exclusion used in QI eligibility because that path reaches the + medically needy Medicaid formula, which depends on medical_out_of_pocket_expenses + and would create a cycle in SPM MOOP calculations. + + The coverage amount is capped at the standard Part B premium. Any IRMAA amount + above the standard premium remains the enrollee's responsibility. + """ + + def formula(person, period, parameters): + enrolled = person("medicare_enrolled", period) + monthly_standard_premium = ( + person("base_part_b_premium", period) / MONTHS_IN_YEAR + ) + monthly_coverage = 0 + for month in period.get_subperiods(MONTH): + income_eligible = person("msp_income_eligible", month) + asset_eligible = person("msp_asset_eligible", month) + eligible_for_coverage = enrolled & income_eligible & asset_eligible + monthly_coverage += where( + eligible_for_coverage, + monthly_standard_premium, + 0, + ) + return monthly_coverage diff --git a/policyengine_us/variables/household/expense/health/medicare_part_b_premiums.py b/policyengine_us/variables/household/expense/health/medicare_part_b_premiums.py index 90bfde15ad8..e38bb0d33a6 100644 --- a/policyengine_us/variables/household/expense/health/medicare_part_b_premiums.py +++ b/policyengine_us/variables/household/expense/health/medicare_part_b_premiums.py @@ -1,4 +1,32 @@ from policyengine_us.model_api import * +from policyengine_core import periods + + +def _get_explicit_legacy_part_b_inputs(person): + situation_input = getattr(person.simulation, "situation_input", None) + if not isinstance(situation_input, dict): + return {} + + people_inputs = situation_input.get("people") + if not isinstance(people_inputs, dict): + return {} + + explicit_inputs = {} + for person_index, person_id in enumerate(person.ids): + person_input = people_inputs.get(person_id) + if not isinstance(person_input, dict): + continue + legacy_values = person_input.get("medicare_part_b_premiums") + if not isinstance(legacy_values, dict): + continue + + for period_str, value in legacy_values.items(): + period_obj = periods.period(period_str) + if period_obj not in explicit_inputs: + explicit_inputs[period_obj] = np.full(person.count, np.nan) + explicit_inputs[period_obj][person_index] = value + + return explicit_inputs class medicare_part_b_premiums(Variable): @@ -7,4 +35,40 @@ class medicare_part_b_premiums(Variable): label = "Medicare Part B premiums" definition_period = YEAR unit = USD - uprating = "calibration.gov.hhs.cms.moop_per_capita" + + def formula(person, period, parameters): + # Backward-compatibility: preserve legacy direct inputs on + # medicare_part_b_premiums if callers still provide them. + enrolled = person("medicare_enrolled", period) + gross_premium = person("income_adjusted_part_b_premium", period) + msp_coverage = person("msp_part_b_premium_coverage", period) + modeled_value = max_(where(enrolled, gross_premium, 0) - msp_coverage, 0) + + explicit_inputs = _get_explicit_legacy_part_b_inputs(person) + if period in explicit_inputs: + current_input = explicit_inputs[period] + current_mask = ~np.isnan(current_input) + return where(current_mask, current_input, modeled_value) + + eligible_periods = sorted( + known_period + for known_period in explicit_inputs + if known_period.start < period.start + ) + if not eligible_periods: + return modeled_value + + last_known_period = eligible_periods[-1] + last_known_value = explicit_inputs[last_known_period] + legacy_mask = ~np.isnan(last_known_value) + if not legacy_mask.any(): + return modeled_value + + moop_per_capita = parameters(period).calibration.gov.hhs.cms.moop_per_capita + last_known_moop_per_capita = parameters( + last_known_period + ).calibration.gov.hhs.cms.moop_per_capita + uprated_legacy_value = ( + last_known_value * moop_per_capita / last_known_moop_per_capita + ) + return where(legacy_mask, uprated_legacy_value, modeled_value) diff --git a/policyengine_us/variables/household/expense/health/medicare_part_b_premiums_reported.py b/policyengine_us/variables/household/expense/health/medicare_part_b_premiums_reported.py new file mode 100644 index 00000000000..500970c641d --- /dev/null +++ b/policyengine_us/variables/household/expense/health/medicare_part_b_premiums_reported.py @@ -0,0 +1,10 @@ +from policyengine_us.model_api import * + + +class medicare_part_b_premiums_reported(Variable): + value_type = float + entity = Person + label = "Medicare Part B premiums (reported)" + definition_period = YEAR + unit = USD + uprating = "calibration.gov.hhs.cms.moop_per_capita"