From e750e4ba4696b7f6eb43bd14f36154d0eedddc4e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 9 Apr 2026 21:08:51 -0400 Subject: [PATCH 01/12] Model Medicare Part B premiums net of MSP coverage --- .../income_adjusted_part_b_premium.yaml | 6 +- .../msp_part_b_premium_coverage.yaml | 29 ++++++ .../hhs/medicare/test_part_b_msp_offset.py | 88 +++++++++++++++++++ .../health/medicare_part_b_premiums.yaml | 35 ++++++++ policyengine_us/tools/default_uprating.py | 2 +- .../part_b/income_adjusted_part_b_premium.py | 23 ++--- .../msp_part_b_premium_coverage.py | 33 +++++++ .../health/medicare_part_b_premiums.py | 7 +- .../medicare_part_b_premiums_reported.py | 10 +++ 9 files changed, 212 insertions(+), 21 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/msp_part_b_premium_coverage.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_part_b_msp_offset.py create mode 100644 policyengine_us/tests/policy/baseline/household/expense/health/medicare_part_b_premiums.yaml create mode 100644 policyengine_us/variables/gov/hhs/medicare/savings_programs/msp_part_b_premium_coverage.py create mode 100644 policyengine_us/variables/household/expense/health/medicare_part_b_premiums_reported.py diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/income_adjusted_part_b_premium.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/income_adjusted_part_b_premium.yaml index 22c5dedfdbc..edb20958203 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/income_adjusted_part_b_premium.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/income_adjusted_part_b_premium.yaml @@ -15,7 +15,7 @@ period: 2025 input: age: 65 - filing_status: JOINT + tax_unit_married: true adjusted_gross_income: 2023: 266_001 # IRMAA uses income from 2 years prior tax_exempt_interest_income: @@ -54,7 +54,7 @@ period: 2025 input: age: 65 - filing_status: JOINT + tax_unit_married: true adjusted_gross_income: 2023: 200_000 # IRMAA uses income from 2 years prior tax_exempt_interest_income: @@ -67,7 +67,7 @@ period: 2025 input: age: 65 - filing_status: JOINT + tax_unit_married: true adjusted_gross_income: 2023: 1_000_000 # IRMAA uses income from 2 years prior tax_exempt_interest_income: 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..b08e43e99e7 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_part_b_msp_offset.py @@ -0,0 +1,88 @@ +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_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) 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/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..006e0d9adaa 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,8 @@ class income_adjusted_part_b_premium(Variable): def formula(person, period, parameters): tax_unit = person.tax_unit - filing_status = tax_unit("filing_status", period) + is_joint = tax_unit("tax_unit_married", period) + is_separated = tax_unit.any(person("is_separated", period)) # 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 +23,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..b0a717a4b4e --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/savings_programs/msp_part_b_premium_coverage.py @@ -0,0 +1,33 @@ +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): + first_month = period.first_month + enrolled = person("medicare_enrolled", period) + income_eligible = person("msp_income_eligible", first_month) + asset_eligible = person("msp_asset_eligible", first_month) + covered_standard_premium = person("base_part_b_premium", period) + eligible_for_coverage = enrolled & income_eligible & asset_eligible + return where(eligible_for_coverage, covered_standard_premium, 0) 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..59a023b2ebd 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 @@ -7,4 +7,9 @@ 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): + enrolled = person("medicare_enrolled", period) + gross_premium = person("income_adjusted_part_b_premium", period) + msp_coverage = person("msp_part_b_premium_coverage", period) + return max_(where(enrolled, gross_premium, 0) - msp_coverage, 0) 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" From a3c0724dce525868b3273c59479586a8a7c2e34b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 9 Apr 2026 21:17:19 -0400 Subject: [PATCH 02/12] Add Part B MSP changelog fragment --- changelog.d/codex-medicare-partb-msp-clean.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/codex-medicare-partb-msp-clean.fixed.md 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. From ae6057ef2e7ecaadba6040d1c8bdce8adcaea388 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 9 Apr 2026 21:58:35 -0400 Subject: [PATCH 03/12] Fix Medicare Part B baseline regressions --- .../gross_income/pre_tax_contributions.yaml | 5 +- .../income_adjusted_part_b_premium.yaml | 6 +- .../hhs/medicare/test_part_b_msp_offset.py | 55 +++++++++++++++++++ .../gov/hhs/medicare/costs/medicare_cost.py | 2 +- .../part_b/income_adjusted_part_b_premium.py | 11 +++- .../msp_part_b_premium_coverage.py | 18 ++++-- .../health/medicare_part_b_premiums.py | 29 ++++++++++ 7 files changed, 112 insertions(+), 14 deletions(-) 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/eligibility/income_adjusted_part_b_premium.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/income_adjusted_part_b_premium.yaml index edb20958203..22c5dedfdbc 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/income_adjusted_part_b_premium.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/income_adjusted_part_b_premium.yaml @@ -15,7 +15,7 @@ period: 2025 input: age: 65 - tax_unit_married: true + filing_status: JOINT adjusted_gross_income: 2023: 266_001 # IRMAA uses income from 2 years prior tax_exempt_interest_income: @@ -54,7 +54,7 @@ period: 2025 input: age: 65 - tax_unit_married: true + filing_status: JOINT adjusted_gross_income: 2023: 200_000 # IRMAA uses income from 2 years prior tax_exempt_interest_income: @@ -67,7 +67,7 @@ period: 2025 input: age: 65 - tax_unit_married: true + filing_status: JOINT adjusted_gross_income: 2023: 1_000_000 # IRMAA uses income from 2 years prior tax_exempt_interest_income: 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 b08e43e99e7..e8ae331e619 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 @@ -86,3 +86,58 @@ def test_medicare_part_b_premiums_are_zero_when_not_enrolled(): 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, + ) 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..497d5ae84c9 100644 --- a/policyengine_us/variables/gov/hhs/medicare/costs/medicare_cost.py +++ b/policyengine_us/variables/gov/hhs/medicare/costs/medicare_cost.py @@ -23,7 +23,7 @@ def formula(person, period, parameters): # Premiums paid by beneficiary part_a_premium = person("base_part_a_premium", period) - part_b_premium = person("income_adjusted_part_b_premium", period) + part_b_premium = person("medicare_part_b_premiums", period) total_premiums = part_a_premium + part_b_premium # Net benefit = spending - premiums 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 006e0d9adaa..442c12246e4 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,8 +13,15 @@ class income_adjusted_part_b_premium(Variable): def formula(person, period, parameters): tax_unit = person.tax_unit - is_joint = tax_unit("tax_unit_married", period) - is_separated = tax_unit.any(person("is_separated", period)) + filing_status_holder = tax_unit.simulation.get_holder("filing_status") + if period in filing_status_holder.get_known_periods(): + filing_status = filing_status_holder.get_array(period) + status = filing_status_holder.variable.possible_values + is_joint = filing_status == status.JOINT + is_separated = filing_status == status.SEPARATE + else: + is_joint = tax_unit("tax_unit_married", period) + is_separated = tax_unit.any(person("is_separated", period)) # Medicare Part B IRMAA is based on MAGI from 2 years prior # MAGI = AGI + tax-exempt interest prior_period = period.offset(-2, "year") 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 index b0a717a4b4e..33dac1e776e 100644 --- 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 @@ -24,10 +24,16 @@ class msp_part_b_premium_coverage(Variable): """ def formula(person, period, parameters): - first_month = period.first_month enrolled = person("medicare_enrolled", period) - income_eligible = person("msp_income_eligible", first_month) - asset_eligible = person("msp_asset_eligible", first_month) - covered_standard_premium = person("base_part_b_premium", period) - eligible_for_coverage = enrolled & income_eligible & asset_eligible - return where(eligible_for_coverage, covered_standard_premium, 0) + 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 59a023b2ebd..d688add2ef2 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 @@ -9,6 +9,35 @@ class medicare_part_b_premiums(Variable): unit = USD def formula(person, period, parameters): + # Backward-compatibility: preserve legacy direct inputs on + # medicare_part_b_premiums if callers still provide them. + holder = person.simulation.get_holder("medicare_part_b_premiums") + known_periods = holder.get_known_periods() + if known_periods: + if period in known_periods: + current_input = holder.get_array(period) + if current_input is not None: + return current_input + + eligible_periods = sorted( + known_period + for known_period in known_periods + if known_period.start < period.start + ) + if eligible_periods: + last_known_period = eligible_periods[-1] + last_known_value = holder.get_array(last_known_period) + if last_known_value is not None: + 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 + return ( + last_known_value + * moop_per_capita + / last_known_moop_per_capita + ) + enrolled = person("medicare_enrolled", period) gross_premium = person("income_adjusted_part_b_premium", period) msp_coverage = person("msp_part_b_premium_coverage", period) From 9b14095dd3532b2d802adfff0f42fe6f6afea09b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 9 Apr 2026 22:01:21 -0400 Subject: [PATCH 04/12] Format Medicare Part B follow-up files --- .../baseline/gov/hhs/medicare/test_part_b_msp_offset.py | 3 +-- .../savings_programs/msp_part_b_premium_coverage.py | 4 +++- .../household/expense/health/medicare_part_b_premiums.py | 8 ++++---- 3 files changed, 8 insertions(+), 7 deletions(-) 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 e8ae331e619..2cf44ed7a74 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 @@ -114,8 +114,7 @@ def test_legacy_medicare_part_b_input_uprates_forward(): 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) + f"{PERIOD}-{month:02d}": month <= 3 for month in range(1, 13) } sim = Simulation( tax_benefit_system=SYSTEM, 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 index 33dac1e776e..cac89f2d0d1 100644 --- 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 @@ -25,7 +25,9 @@ class msp_part_b_premium_coverage(Variable): def formula(person, period, parameters): enrolled = person("medicare_enrolled", period) - monthly_standard_premium = person("base_part_b_premium", period) / MONTHS_IN_YEAR + 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) 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 d688add2ef2..3772132c40c 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 @@ -28,14 +28,14 @@ def formula(person, period, parameters): last_known_period = eligible_periods[-1] last_known_value = holder.get_array(last_known_period) if last_known_value is not None: - moop_per_capita = parameters(period).calibration.gov.hhs.cms.moop_per_capita + 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 return ( - last_known_value - * moop_per_capita - / last_known_moop_per_capita + last_known_value * moop_per_capita / last_known_moop_per_capita ) enrolled = person("medicare_enrolled", period) From dbeb37f1fecc4ce93a885f254363e4332129fef5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 9 Apr 2026 23:23:05 -0400 Subject: [PATCH 05/12] Fix Medicare Part B compatibility regressions --- .../hhs/medicare/test_part_b_msp_offset.py | 91 +++++++++++++++++++ .../part_b/income_adjusted_part_b_premium.py | 12 +-- .../health/medicare_part_b_premiums.py | 86 ++++++++++++------ 3 files changed, 153 insertions(+), 36 deletions(-) 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 2cf44ed7a74..77bd3f73047 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 @@ -140,3 +140,94 @@ def test_msp_part_b_premium_coverage_scales_with_eligible_months(): 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/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 442c12246e4..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 @@ -14,14 +14,10 @@ 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") - if period in filing_status_holder.get_known_periods(): - filing_status = filing_status_holder.get_array(period) - status = filing_status_holder.variable.possible_values - is_joint = filing_status == status.JOINT - is_separated = filing_status == status.SEPARATE - else: - is_joint = tax_unit("tax_unit_married", period) - is_separated = tax_unit.any(person("is_separated", period)) + 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") 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 3772132c40c..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): @@ -11,34 +39,36 @@ class medicare_part_b_premiums(Variable): def formula(person, period, parameters): # Backward-compatibility: preserve legacy direct inputs on # medicare_part_b_premiums if callers still provide them. - holder = person.simulation.get_holder("medicare_part_b_premiums") - known_periods = holder.get_known_periods() - if known_periods: - if period in known_periods: - current_input = holder.get_array(period) - if current_input is not None: - return current_input - - eligible_periods = sorted( - known_period - for known_period in known_periods - if known_period.start < period.start - ) - if eligible_periods: - last_known_period = eligible_periods[-1] - last_known_value = holder.get_array(last_known_period) - if last_known_value is not None: - 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 - return ( - last_known_value * moop_per_capita / last_known_moop_per_capita - ) - enrolled = person("medicare_enrolled", period) gross_premium = person("income_adjusted_part_b_premium", period) msp_coverage = person("msp_part_b_premium_coverage", period) - return max_(where(enrolled, gross_premium, 0) - msp_coverage, 0) + 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) From 96e990088fcb67fea07d6a27776c7479d54cd5d5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 10 Apr 2026 09:50:38 -0400 Subject: [PATCH 06/12] Fix Medicare Part B CI regressions --- .../hhs/medicare/test_part_b_msp_offset.py | 27 +++++++++++++++++++ .../homestead_property_tax/integration.yaml | 1 + .../gov/states/mi/tax/income/integration.yaml | 1 + .../mo_pension_and_ss_or_ssd.yaml | 23 ++++++++++++++++ .../mo/tax/income/income_tax/integration.yaml | 1 + .../nj_other_retirement_income_exclusion.yaml | 2 ++ .../gov/states/nj/tax/income/integration.yaml | 1 + .../nj_social_security_exclusion.yaml | 1 + .../gov/states/nm/tax/income/integration.yaml | 1 + .../gov/states/oh/tax/income/integration.yaml | 3 +++ .../gov/hhs/medicare/costs/medicare_cost.py | 5 ++-- 11 files changed, 64 insertions(+), 2 deletions(-) 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 77bd3f73047..9c976b8ea41 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 @@ -75,6 +75,33 @@ def test_medicare_part_b_premiums_are_zero_when_msp_covers_standard_premium(): 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, 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/variables/gov/hhs/medicare/costs/medicare_cost.py b/policyengine_us/variables/gov/hhs/medicare/costs/medicare_cost.py index 497d5ae84c9..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,9 +21,10 @@ 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("medicare_part_b_premiums", period) + part_b_premium = person("income_adjusted_part_b_premium", period) total_premiums = part_a_premium + part_b_premium # Net benefit = spending - premiums From b90e3f1b07ff402ace8a39d9eda4bd0cbbbc11c3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 27 Apr 2026 14:59:23 -0400 Subject: [PATCH 07/12] Exclude health premiums from AGI wage deductions --- .../codex-medicare-partb-msp-clean.fixed.md | 2 +- .../gross_income/pre_tax_contributions.yaml | 5 ++-- .../irs_employment_income.yaml | 29 +++++++++++++++++++ .../pre_tax_health_insurance_premiums.py | 15 ++++++++++ 4 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 policyengine_us/variables/household/expense/health/pre_tax_health_insurance_premiums.py diff --git a/changelog.d/codex-medicare-partb-msp-clean.fixed.md b/changelog.d/codex-medicare-partb-msp-clean.fixed.md index 44bb375c6a1..3936169e968 100644 --- a/changelog.d/codex-medicare-partb-msp-clean.fixed.md +++ b/changelog.d/codex-medicare-partb-msp-clean.fixed.md @@ -1 +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. +Model Medicare Part B premiums in baseline SPM expenses, preserve reported premiums as an audit input, net out a cycle-free MSP proxy so baseline MOOP better reflects likely beneficiary out-of-pocket premiums, and stop treating reported health insurance premiums as pre-tax wage deductions in federal AGI. 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 a2b40c7e36b..9caa7374de8 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,9 +4,8 @@ values: # Assumes all are pre-tax. - traditional_401k_contributions - traditional_403b_contributions - # Assumes employer-sponsored premiums only; Medicare Part B is not a - # pre-tax payroll deduction. - - health_insurance_premiums_without_medicare_part_b + # Only explicit pre-tax payroll health premiums reduce taxable wages. + - pre_tax_health_insurance_premiums # HSA contributions can be either through pre-tax. - health_savings_account_payroll_contributions metadata: diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/irs_employment_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/irs_employment_income.yaml index 1f23a615716..bee61f8c966 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/irs_employment_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/irs_employment_income.yaml @@ -14,3 +14,32 @@ output: irs_employment_income: 70 +- name: Health insurance premiums do not reduce IRS employment income. + period: 2024 + input: + employment_income: 100 + health_insurance_premiums_without_medicare_part_b: 20 + medicare_part_b_premiums: 30 + output: + pre_tax_contributions: 0 + irs_employment_income: 100 + +- name: Explicit pre-tax health insurance premiums reduce IRS employment income. + period: 2024 + input: + employment_income: 100 + pre_tax_health_insurance_premiums: 20 + output: + pre_tax_contributions: 20 + irs_employment_income: 80 + +- name: Retirement and HSA payroll deductions reduce IRS employment income. + period: 2024 + input: + employment_income: 100 + traditional_401k_contributions: 10 + traditional_403b_contributions: 5 + health_savings_account_payroll_contributions: 3 + output: + pre_tax_contributions: 18 + irs_employment_income: 82 diff --git a/policyengine_us/variables/household/expense/health/pre_tax_health_insurance_premiums.py b/policyengine_us/variables/household/expense/health/pre_tax_health_insurance_premiums.py new file mode 100644 index 00000000000..57064157628 --- /dev/null +++ b/policyengine_us/variables/household/expense/health/pre_tax_health_insurance_premiums.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * + + +class pre_tax_health_insurance_premiums(Variable): + value_type = float + entity = Person + label = "Pre-tax health insurance premiums" + unit = USD + documentation = ( + "Health insurance premiums paid through pre-tax payroll deductions. " + "This excludes Medicare Part B premiums and other post-tax medical " + "out-of-pocket premiums." + ) + definition_period = YEAR + uprating = "calibration.gov.hhs.cms.moop_per_capita" From d7e8fedb1b13d941895f4432f983761120dcc5cf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 27 Apr 2026 15:06:47 -0400 Subject: [PATCH 08/12] Cover Part B premium legacy input branches --- .../hhs/medicare/test_part_b_msp_offset.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) 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 9c976b8ea41..32dd72a9592 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 @@ -1,12 +1,37 @@ +from types import SimpleNamespace + +import numpy as np import pytest +from policyengine_core import periods from policyengine_us import CountryTaxBenefitSystem, Simulation +import policyengine_us.variables.household.expense.health.medicare_part_b_premiums as part_b_module +from policyengine_us.variables.household.expense.health.medicare_part_b_premiums import ( + _get_explicit_legacy_part_b_inputs, + medicare_part_b_premiums, +) SYSTEM = CountryTaxBenefitSystem() PERIOD = "2025" +class FakePartBPerson: + ids = ["person"] + count = 1 + + def __init__(self, situation_input=None): + self.simulation = SimpleNamespace(situation_input=situation_input) + self.values = { + "medicare_enrolled": np.array([True]), + "income_adjusted_part_b_premium": np.array([123.0]), + "msp_part_b_premium_coverage": np.array([0.0]), + } + + def __call__(self, variable, period): + return self.values[variable] + + def make_simulation( *, medicare_enrolled: bool, @@ -139,6 +164,60 @@ def test_legacy_medicare_part_b_input_uprates_forward(): ) +def test_legacy_part_b_helper_handles_missing_situation_input(): + person = SimpleNamespace(simulation=SimpleNamespace(situation_input=None)) + + assert _get_explicit_legacy_part_b_inputs(person) == {} + + +def test_legacy_part_b_helper_handles_missing_people_input(): + person = SimpleNamespace(simulation=SimpleNamespace(situation_input={})) + + assert _get_explicit_legacy_part_b_inputs(person) == {} + + +def test_legacy_part_b_helper_skips_non_dict_person_input(): + person = SimpleNamespace( + ids=["person"], + count=1, + simulation=SimpleNamespace( + situation_input={"people": {"person": "not a dict"}} + ), + ) + + assert _get_explicit_legacy_part_b_inputs(person) == {} + + +def test_legacy_part_b_formula_uses_current_direct_input(): + situation_input = { + "people": {"person": {"medicare_part_b_premiums": {PERIOD: 1_000}}} + } + person = FakePartBPerson(situation_input) + + result = medicare_part_b_premiums.formula( + person, periods.period(PERIOD), parameters=None + ) + + assert result[0] == pytest.approx(1_000) + + +def test_legacy_part_b_formula_uses_modeled_value_when_prior_input_is_missing( + monkeypatch, +): + monkeypatch.setattr( + part_b_module, + "_get_explicit_legacy_part_b_inputs", + lambda person: {periods.period("2024"): np.array([np.nan])}, + ) + person = FakePartBPerson() + + result = medicare_part_b_premiums.formula( + person, periods.period(PERIOD), parameters=None + ) + + assert result[0] == pytest.approx(123) + + 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) From acb445ef3cba9f7ed8048530bb4f0d24f2097d4f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 27 Apr 2026 15:30:11 -0400 Subject: [PATCH 09/12] Cover multiple legacy Part B premium inputs --- .../expense/health/medicare_part_b_premiums.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 index ea3748cee5a..59607ea60da 100644 --- 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 @@ -33,3 +33,14 @@ msp_part_b_premium_coverage: 0 output: medicare_part_b_premiums: 0 + +- name: Medicare Part B premiums preserve multiple legacy person inputs + period: 2025 + input: + people: + person1: + medicare_part_b_premiums: 1_200 + person2: + medicare_part_b_premiums: 2_400 + output: + medicare_part_b_premiums: [1_200, 2_400] From 746c8879affe8c02d4315af7300220d75f29075c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 27 Apr 2026 17:54:26 -0400 Subject: [PATCH 10/12] Simplify legacy Part B premium input collection --- .../household/expense/health/medicare_part_b_premiums.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 e38bb0d33a6..b3b553d3075 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 @@ -22,9 +22,10 @@ def _get_explicit_legacy_part_b_inputs(person): 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 + period_inputs = explicit_inputs.setdefault( + period_obj, np.full(person.count, np.nan) + ) + period_inputs[person_index] = value return explicit_inputs From 32960b160abbcd9202b1345dc95d353e6aabb7f8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 29 Apr 2026 21:58:22 -0400 Subject: [PATCH 11/12] Add historical MSP asset eligibility parameters --- .../eligibility/asset/applies.yaml | 54 ++++++++++++++++++- .../eligibility/asset/couple.yaml | 10 ++++ .../eligibility/asset/individual.yaml | 10 ++++ .../hhs/medicare/test_part_b_msp_offset.py | 16 ++++++ 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/policyengine_us/parameters/gov/hhs/medicare/savings_programs/eligibility/asset/applies.yaml b/policyengine_us/parameters/gov/hhs/medicare/savings_programs/eligibility/asset/applies.yaml index d306f048d9d..4042920630b 100644 --- a/policyengine_us/parameters/gov/hhs/medicare/savings_programs/eligibility/asset/applies.yaml +++ b/policyengine_us/parameters/gov/hhs/medicare/savings_programs/eligibility/asset/applies.yaml @@ -10,106 +10,158 @@ metadata: href: https://www.medicare.gov/basics/costs/help/medicare-savings-programs - title: Medicare Interactive - Medicare Savings Program Income and Asset Limits href: https://www.medicareinteractive.org/understanding-medicare/cost-saving-programs/medicare-savings-programs-qmb-slmb-qi/medicare-savings-program-income-and-asset-limits -# States that have eliminated the asset test are set to false +# Federal MSP rules apply the asset test by default; states that have +# eliminated it in current law are set to false from 2024. AL: + 0000-01-01: true 2024-01-01: false AK: + 0000-01-01: true 2024-01-01: true AZ: + 0000-01-01: true 2024-01-01: false AR: + 0000-01-01: true 2024-01-01: true CA: + 0000-01-01: true 2024-01-01: false CO: + 0000-01-01: true 2024-01-01: true CT: + 0000-01-01: true 2024-01-01: false DE: + 0000-01-01: true 2024-01-01: false DC: + 0000-01-01: true 2024-01-01: false FL: + 0000-01-01: true 2024-01-01: true GA: + 0000-01-01: true 2024-01-01: true HI: + 0000-01-01: true 2024-01-01: true ID: + 0000-01-01: true 2024-01-01: true IL: + 0000-01-01: true 2024-01-01: true IN: + 0000-01-01: true 2024-01-01: true IA: + 0000-01-01: true 2024-01-01: true KS: + 0000-01-01: true 2024-01-01: true KY: + 0000-01-01: true 2024-01-01: true LA: + 0000-01-01: true 2024-01-01: false ME: + 0000-01-01: true 2024-01-01: true MD: + 0000-01-01: true 2024-01-01: true MA: + 0000-01-01: true 2024-01-01: true MI: + 0000-01-01: true 2024-01-01: true MN: + 0000-01-01: true 2024-01-01: true MS: + 0000-01-01: true 2024-01-01: false MO: + 0000-01-01: true 2024-01-01: true MT: + 0000-01-01: true 2024-01-01: true NE: + 0000-01-01: true 2024-01-01: true NV: + 0000-01-01: true 2024-01-01: true NH: + 0000-01-01: true 2024-01-01: true NJ: + 0000-01-01: true 2024-01-01: true NM: + 0000-01-01: true 2024-01-01: false NY: + 0000-01-01: true 2024-01-01: false NC: + 0000-01-01: true 2024-01-01: true ND: + 0000-01-01: true 2024-01-01: true OH: + 0000-01-01: true 2024-01-01: true OK: + 0000-01-01: true 2024-01-01: true OR: + 0000-01-01: true 2024-01-01: false PA: + 0000-01-01: true 2024-01-01: true RI: + 0000-01-01: true 2024-01-01: true SC: + 0000-01-01: true 2024-01-01: true SD: + 0000-01-01: true 2024-01-01: true TN: + 0000-01-01: true 2024-01-01: true TX: + 0000-01-01: true 2024-01-01: true UT: + 0000-01-01: true 2024-01-01: true VT: + 0000-01-01: true 2024-01-01: false VA: + 0000-01-01: true 2024-01-01: true WA: + 0000-01-01: true 2024-01-01: true WV: + 0000-01-01: true 2024-01-01: true WI: + 0000-01-01: true 2024-01-01: true WY: + 0000-01-01: true 2024-01-01: true diff --git a/policyengine_us/parameters/gov/hhs/medicare/savings_programs/eligibility/asset/couple.yaml b/policyengine_us/parameters/gov/hhs/medicare/savings_programs/eligibility/asset/couple.yaml index 8d5e5ce2a0e..60d8671a402 100644 --- a/policyengine_us/parameters/gov/hhs/medicare/savings_programs/eligibility/asset/couple.yaml +++ b/policyengine_us/parameters/gov/hhs/medicare/savings_programs/eligibility/asset/couple.yaml @@ -1,5 +1,15 @@ description: The Department of Health and Human Services limits resources to this amount for couples under the Medicare Savings Programs. values: + 0000-01-01: 10_620 + 2014-01-01: 10_750 + 2015-01-01: 10_930 + 2017-01-01: 11_090 + 2018-01-01: 11_340 + 2019-01-01: 11_600 + 2020-01-01: 11_800 + 2021-01-01: 11_960 + 2022-01-01: 12_600 + 2023-01-01: 13_630 2024-01-01: 14_130 2025-01-01: 14_470 2026-01-01: 14_910 diff --git a/policyengine_us/parameters/gov/hhs/medicare/savings_programs/eligibility/asset/individual.yaml b/policyengine_us/parameters/gov/hhs/medicare/savings_programs/eligibility/asset/individual.yaml index 5787d7e2255..3299f4d2f2a 100644 --- a/policyengine_us/parameters/gov/hhs/medicare/savings_programs/eligibility/asset/individual.yaml +++ b/policyengine_us/parameters/gov/hhs/medicare/savings_programs/eligibility/asset/individual.yaml @@ -1,5 +1,15 @@ description: The Department of Health and Human Services limits resources to this amount for individuals under the Medicare Savings Programs. values: + 0000-01-01: 7_080 + 2014-01-01: 7_160 + 2015-01-01: 7_280 + 2017-01-01: 7_390 + 2018-01-01: 7_560 + 2019-01-01: 7_730 + 2020-01-01: 7_860 + 2021-01-01: 7_970 + 2022-01-01: 8_400 + 2023-01-01: 9_090 2024-01-01: 9_430 2025-01-01: 9_660 2026-01-01: 9_950 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 5e2274a4cb0..026d06d29a5 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 @@ -134,6 +134,22 @@ def test_msp_part_b_premium_coverage_scales_with_eligible_months(): ) +def test_historical_msp_asset_eligibility_uses_federal_default(): + sim = Simulation( + tax_benefit_system=SYSTEM, + situation={ + "people": {"person": {"ssi_countable_resources": {"2014": 0}}}, + "households": {"household": {"members": ["person"], "state_code": "AR"}}, + "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_asset_eligible", "2014-01")[0] + + def test_medicare_part_b_premium_does_not_depend_on_calculation_order(): no_msp_eligibility = { f"{year}-{month:02d}": False From 188d7b1736e5d8ae40ba24757c6b4572ece55d45 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 30 Apr 2026 08:40:56 -0400 Subject: [PATCH 12/12] Backfill historical MSP asset limits --- .../eligibility/asset/couple.yaml | 9 +++- .../eligibility/asset/individual.yaml | 9 +++- .../hhs/medicare/test_part_b_msp_offset.py | 52 +++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/policyengine_us/parameters/gov/hhs/medicare/savings_programs/eligibility/asset/couple.yaml b/policyengine_us/parameters/gov/hhs/medicare/savings_programs/eligibility/asset/couple.yaml index 60d8671a402..b6c246e4f73 100644 --- a/policyengine_us/parameters/gov/hhs/medicare/savings_programs/eligibility/asset/couple.yaml +++ b/policyengine_us/parameters/gov/hhs/medicare/savings_programs/eligibility/asset/couple.yaml @@ -1,6 +1,9 @@ description: The Department of Health and Human Services limits resources to this amount for couples under the Medicare Savings Programs. values: - 0000-01-01: 10_620 + 0000-01-01: 9_910 + 2011-01-01: 10_020 + 2012-01-01: 10_410 + 2013-01-01: 10_620 2014-01-01: 10_750 2015-01-01: 10_930 2017-01-01: 11_090 @@ -23,3 +26,7 @@ metadata: href: https://www.medicare.gov/basics/costs/help/medicare-savings-programs - title: SSA POMS SI 01715.010 - Resource Eligibility href: https://secure.ssa.gov/apps10/poms.nsf/lnx/0501715010 + - title: "CMS SMDL #10-003: 2010 Medicare Savings Program resource standards" + href: https://downloads.cms.gov/cmsgov/archived-downloads/smdl/downloads/smd10003.pdf + - title: Missouri DSS Historical QMB Resource Limits + href: https://dss.mo.gov/fsd/iman/medasst/0865-010-15.html diff --git a/policyengine_us/parameters/gov/hhs/medicare/savings_programs/eligibility/asset/individual.yaml b/policyengine_us/parameters/gov/hhs/medicare/savings_programs/eligibility/asset/individual.yaml index 3299f4d2f2a..baedf21f20f 100644 --- a/policyengine_us/parameters/gov/hhs/medicare/savings_programs/eligibility/asset/individual.yaml +++ b/policyengine_us/parameters/gov/hhs/medicare/savings_programs/eligibility/asset/individual.yaml @@ -1,6 +1,9 @@ description: The Department of Health and Human Services limits resources to this amount for individuals under the Medicare Savings Programs. values: - 0000-01-01: 7_080 + 0000-01-01: 6_600 + 2011-01-01: 6_680 + 2012-01-01: 6_940 + 2013-01-01: 7_080 2014-01-01: 7_160 2015-01-01: 7_280 2017-01-01: 7_390 @@ -23,3 +26,7 @@ metadata: href: https://www.medicare.gov/basics/costs/help/medicare-savings-programs - title: SSA POMS SI 01715.010 - Resource Eligibility href: https://secure.ssa.gov/apps10/poms.nsf/lnx/0501715010 + - title: "CMS SMDL #10-003: 2010 Medicare Savings Program resource standards" + href: https://downloads.cms.gov/cmsgov/archived-downloads/smdl/downloads/smd10003.pdf + - title: Missouri DSS Historical QMB Resource Limits + href: https://dss.mo.gov/fsd/iman/medasst/0865-010-15.html 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 026d06d29a5..55cd5a8410a 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 @@ -150,6 +150,58 @@ def test_historical_msp_asset_eligibility_uses_federal_default(): assert sim.calculate("msp_asset_eligible", "2014-01")[0] +def test_historical_msp_asset_limits_before_2013(): + sim = Simulation( + tax_benefit_system=SYSTEM, + situation={ + "people": { + "below_limit": {"ssi_countable_resources": {"2012": 6_940}}, + "above_limit": {"ssi_countable_resources": {"2012": 7_000}}, + "spouse_1": {"ssi_countable_resources": {"2012": 5_000}}, + "spouse_2": {"ssi_countable_resources": {"2012": 5_500}}, + }, + "households": { + "household": { + "members": [ + "below_limit", + "above_limit", + "spouse_1", + "spouse_2", + ], + "state_code": "AR", + } + }, + "tax_units": { + "tax_unit_1": {"members": ["below_limit"]}, + "tax_unit_2": {"members": ["above_limit"]}, + "tax_unit_3": {"members": ["spouse_1", "spouse_2"]}, + }, + "spm_units": { + "spm_unit_1": {"members": ["below_limit"]}, + "spm_unit_2": {"members": ["above_limit"]}, + "spm_unit_3": {"members": ["spouse_1", "spouse_2"]}, + }, + "families": { + "family_1": {"members": ["below_limit"]}, + "family_2": {"members": ["above_limit"]}, + "family_3": {"members": ["spouse_1", "spouse_2"]}, + }, + "marital_units": { + "marital_unit_1": {"members": ["below_limit"]}, + "marital_unit_2": {"members": ["above_limit"]}, + "marital_unit_3": {"members": ["spouse_1", "spouse_2"]}, + }, + }, + ) + + assert sim.calculate("msp_asset_eligible", "2012-01").tolist() == [ + True, + False, + False, + False, + ] + + def test_medicare_part_b_premium_does_not_depend_on_calculation_order(): no_msp_eligibility = { f"{year}-{month:02d}": False