diff --git a/changelog.d/medicare-part-d-lis.added.md b/changelog.d/medicare-part-d-lis.added.md new file mode 100644 index 00000000000..52e90cc1e3a --- /dev/null +++ b/changelog.d/medicare-part-d-lis.added.md @@ -0,0 +1 @@ +Model the Medicare Part D low-income subsidy (Extra Help): statutory eligibility, with CPI-indexed resource limits, valued at the Trustees' average subsidy per subsidy enrollee for people in a Part D plan; count it as a CBO means-tested transfer and a household health benefit. diff --git a/policyengine_us/parameters/calibration/gov/hhs/medicare/part_d/low_income_subsidy_per_recipient.yaml b/policyengine_us/parameters/calibration/gov/hhs/medicare/part_d/low_income_subsidy_per_recipient.yaml new file mode 100644 index 00000000000..3fa0e50fd86 --- /dev/null +++ b/policyengine_us/parameters/calibration/gov/hhs/medicare/part_d/low_income_subsidy_per_recipient.yaml @@ -0,0 +1,68 @@ +description: The Medicare Trustees report and project these Part D low-income subsidy payments per low-income subsidy enrollee. +metadata: + unit: currency-USD + period: year + label: Medicare Part D low-income subsidy per recipient + # From 2036, each value is the 2035 value times the growth in Medicare + # spending per enrollee, as for the per-enrollee series beside it. + uprating: calibration.gov.hhs.medicare.per_capita_cost + reference: + # Table IV.B9, "Low-income subsidy", "Subsidy amount": incurred payments + # per low-income subsidy enrollee, beside the subsidy's enrollment (in + # millions). Enrollment times the amount gives Table IV.B10's "Low-income + # subsidy" payments, e.g. 14.4 million x $1,254 = $18.1 billion in 2026. + # 2016 to 2035 come from the 2026 report (file page 158); 2015, which that + # table no longer shows, from the 2025 report (file page 158); 2013 and + # 2014 from the 2023 report (file page 158), the newest that shows both, + # as for the per-enrollee series beside this one. The 2026 report moves + # 2021 to 2023 by $1 to $2 from the 2025 report, and 2024 by $15 ($2,848 + # in the 2025 report, $2,833 in the 2026 report). 2026 Medicare Trustees + # Report, transmitted 2026-06-09, retrieved 2026-10-11, sha256 + # ffa56b9137006872300b0346149eae1613d09a172b6ba118aad48e66dfc48fa8; 2025 + # report sha256 + # 1b3de7a4faeee5ad42059e10740603182b716a5fa7b53b30d360b45b9e474d95; 2023 + # report retrieved 2026-11-07, sha256 + # 89daaf2909ae18648783f652c05114d007c5f902cc5fa5fad0f76d43f6ccfa67. + # The average includes enrollees in institutions, whom CBO removes along + # with their benefits (Habib 2018, file page 52). CBO's adjusted 2015 + # totals, $22.6 billion for 10.7 million recipients (file page 51), give + # $2,112 per recipient, the same as this table's 2015 average; for 2016 + # CBO's $26.5 billion (from its own baseline) for 11.1 million gives + # $2,387 against $2,126 here. + - title: "2026 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, Tables IV.B9 and IV.B10 #page 119,158-159" + href: https://www.cms.gov/oact/tr/2026#page=119 + - title: 2025 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, Table IV.B9 + href: https://www.cms.gov/oact/tr/2025#page=158 + - title: 2023 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, Table IV.B9 + href: https://www.cms.gov/oact/tr/2023#page=158 + - title: "Habib, How CBO Adjusts for Survey Underreporting of Transfer Income in Its Distributional Analyses, CBO Working Paper 2018-07, Appendix C #page 51-52" + href: https://www.cbo.gov/system/files/2018-07/54234-workingpaper.pdf#page=51 +values: + # Historical. + 2013-01-01: 2_023 + 2014-01-01: 2_052 + 2015-01-01: 2_112 + 2016-01-01: 2_126 + 2017-01-01: 2_156 + 2018-01-01: 2_204 + 2019-01-01: 2_273 + 2020-01-01: 2_507 + 2021-01-01: 2_646 + 2022-01-01: 2_873 + 2023-01-01: 3_092 + 2024-01-01: 2_833 + # 2025 is the first year of the full Inflation Reduction Act benefit + # redesign (2026 report, file page 119). The average falls from $2,833 in + # 2024 to $1,226 in 2025; the report gives no cause. + 2025-01-01: 1_226 + # Trustees' intermediate estimates. + 2026-01-01: 1_254 + 2027-01-01: 1_384 + 2028-01-01: 1_459 + 2029-01-01: 1_579 + 2030-01-01: 1_833 + 2031-01-01: 1_922 + 2032-01-01: 1_956 + 2033-01-01: 1_982 + 2034-01-01: 2_014 + 2035-01-01: 2_076 diff --git a/policyengine_us/parameters/gov/bls/cpi/cpi_u_nsa_september.yaml b/policyengine_us/parameters/gov/bls/cpi/cpi_u_nsa_september.yaml index 738737b408f..555a2337d3e 100644 --- a/policyengine_us/parameters/gov/bls/cpi/cpi_u_nsa_september.yaml +++ b/policyengine_us/parameters/gov/bls/cpi/cpi_u_nsa_september.yaml @@ -1,4 +1,4 @@ -description: The Bureau of Labor Statistics publishes this September value of the Consumer Price Index for All Urban Consumers, not seasonally adjusted, from which CMS takes the September-to-September percentage change it applies to the Medicaid long-term care home equity limits. +description: The Bureau of Labor Statistics publishes this September value of the Consumer Price Index for All Urban Consumers, not seasonally adjusted, from which CMS takes the September-to-September change it applies to the Medicaid long-term care home equity limits and the Medicare Part D low-income subsidy resource limits. values: # BLS series CUUR0000SA0 (U.S. city average, all items, 1982-84=100). 2018-09-01: 252.439 @@ -9,6 +9,7 @@ values: 2023-09-01: 307.789 2024-09-01: 315.301 2025-09-01: 324.8 + 2026-09-01: 336.94 metadata: unit: currency-USD label: CPI-U, not seasonally adjusted, September diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/income_limit.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/income_limit.yaml new file mode 100644 index 00000000000..7aa3fcd667a --- /dev/null +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/income_limit.yaml @@ -0,0 +1,15 @@ +description: Medicare limits the Part D low-income subsidy to people whose countable income is below this share of the federal poverty guideline for their family size. +values: + 2006-01-01: 1.5 + +metadata: + unit: /1 + period: year + label: Medicare Part D low-income subsidy income limit + reference: + - title: 42 U.S.C. § 1395w-114(a)(3)(A)(ii) - Subsidy eligible individual defined + href: https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3_A_ii + - title: 42 CFR § 423.773(a)(1) - Subsidy eligible individual + href: https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.773#p-423.773(a)(1) + - title: SSA POMS HI 03020.055 - Income Limits for Subsidy Eligibility + href: https://secure.ssa.gov/poms.nsf/lnx/0603020055 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/ineligible_jurisdictions.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/ineligible_jurisdictions.yaml new file mode 100644 index 00000000000..5dafce4b320 --- /dev/null +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/ineligible_jurisdictions.yaml @@ -0,0 +1,25 @@ +description: Medicare limits the Part D low-income subsidy to residents of the 50 states and the District of Columbia, so residents of these other jurisdictions are ineligible. +values: + 2006-01-01: + # Territories and freely associated states. + - GU + - MP + - PR + - PW + - VI + # Armed Forces mail codes, which are addresses abroad. + - AA + - AE + - AP + +metadata: + unit: list + period: year + label: Medicare Part D low-income subsidy ineligible jurisdictions + reference: + - title: 42 U.S.C. § 1395w-114(a)(3)(F) - Treatment of territorial residents + href: https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3_F + - title: 42 CFR § 423.772 - Definitions (State) + href: https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.772 + - title: 20 CFR § 418.3010(b)(11) - Definitions (State) + href: https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3010#p-418.3010(b)(11) diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/burial_exclusion.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/burial_exclusion.yaml new file mode 100644 index 00000000000..d6a04e3f272 --- /dev/null +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/burial_exclusion.yaml @@ -0,0 +1,18 @@ +description: Medicare excludes this amount per person, for the applicant and a spouse living with them, from countable resources as funds expected to be used for burial under the Part D low-income subsidy. +values: + 2006-01-01: 1_500 + +metadata: + unit: currency-USD + period: year + label: Medicare Part D low-income subsidy burial funds exclusion + reference: + # SSA applies the exclusion unless the applicant says none of the + # resources will go to funeral or burial expenses, without asking for the + # funds' value or designation (POMS HI 03030.020 and HI 03030.025). + - title: 20 CFR § 418.3425(j) - Burial funds resource exclusion + href: https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3425#p-418.3425(j) + - title: SSA POMS HI 03030.020 - Resource Exclusions + href: https://secure.ssa.gov/poms.nsf/lnx/0603030020 + - title: SSA POMS HI 03030.025 - Resource Limits for Subsidy Eligibility + href: https://secure.ssa.gov/poms.nsf/lnx/0603030025 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/household_sources.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/household_sources.yaml new file mode 100644 index 00000000000..e7320981b42 --- /dev/null +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/household_sources.yaml @@ -0,0 +1,29 @@ +description: Medicare counts these household assets as resources of the household head and spouse under the Part D low-income subsidy. +values: + 2006-01-01: + - household_other_real_estate_equity + +metadata: + unit: list + period: year + label: Medicare Part D low-income subsidy countable household resource sources + reference: + # Equity in real property other than the home counts, whether or not it + # can be sold within 20 workdays. The model holds real estate equity by + # household, so the formula counts it for the household head's couple. + # Rental property equity (household_rental_property_equity) is left out: + # property essential to self-support is excluded (418.3425(c) and (d)), + # rental property used in a trade or business in full and other + # income-producing property up to $6,000 of equity when it returns 6% a + # year (POMS SI 01130.501 and SI 01130.500), and the model can't tell + # which applies. + - title: 20 CFR § 418.3405(b) - What types of resources do we count? + href: https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3405#p-418.3405(b) + - title: 20 CFR § 418.3425 - What resources do we exclude from counting? + href: https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3425 + - title: 42 CFR § 423.772 - Definitions (resources) + href: https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.772 + - title: SSA POMS SI 01130.501 - Essential Property Excluded Regardless of Value or Rate of Return + href: https://secure.ssa.gov/poms.nsf/lnx/0501130501 + - title: SSA POMS SI 01130.500 - Property Essential to Self-Support - Overview + href: https://secure.ssa.gov/poms.nsf/lnx/0501130500 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/limit/couple.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/limit/couple.yaml new file mode 100644 index 00000000000..454dfc127cb --- /dev/null +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/limit/couple.yaml @@ -0,0 +1,61 @@ +description: Medicare limits the Part D low-income subsidy to married people living with their spouse whose combined countable resources do not exceed this amount, before the burial funds exclusion. +values: + 2006-01-01: 20_000 + 2007-01-01: 20_410 + 2008-01-01: 20_970 + 2009-01-01: 22_010 + 2011-01-01: 22_260 + 2012-01-01: 23_120 + 2013-01-01: 23_580 + 2014-01-01: 23_860 + # From 2015 the statute's formula, which runs $10 to $20 above SSA's + # published amounts (see the note under reference). + # Candidate: us:part-d-lis-couple-resource-limit-2015 until registered. + 2015-01-01: 24_260 + 2017-01-01: 24_620 + 2018-01-01: 25_170 + 2019-01-01: 25_740 + 2020-01-01: 26_180 + 2021-01-01: 26_540 + 2022-01-01: 27_970 + 2023-01-01: 30_260 + 2024-01-01: 31_380 + 2025-01-01: 32_150 + 2026-01-01: 33_120 + +metadata: + unit: currency-USD + period: year + label: Medicare Part D low-income subsidy couple resource limit + # Years after the last value here are projected in + # parameters/uprating_extensions.py (extend_part_d_lis_resource_limits): the + # previous year's limit times the change in the not seasonally adjusted + # CPI-U from September two years before to September one year before, + # never lowered, rounded to the nearest $10, effective January 1. The + # published September 2026 index gives $34,360 for 2027. + reference: + # The statute's alternative resource standard: $20,000 in 2006, raised + # each year by the September CPI-U and rounded to $10. Before 2024 it was + # the limit for the partial subsidy (the full subsidy had a lower limit); + # from 2024 it is the limit for the full subsidy, the only one left. SSA's + # table lists both pre-2024 limits; these are the higher ones. SSA's table + # excludes the $3,000 couple burial funds exclusion, which is a separate + # parameter. + # + # Through 2014 these are SSA's amounts. For 2015, CMS's memo multiplies + # the 2014 limit of $23,860 by 1.01658 (238.031 / 234.149) and rounds to + # $10, which gives $24,260 ($24,255.58 unrounded), but it prints $24,250, + # and SSA has raised its own previous amount each year since. The values + # here apply the formula to the previous year's limit as the statute + # sets it, so they are $10 above SSA's in 2015 and 2016 and $20 above + # from 2017 ($33,120 against $33,100 in 2026). + - title: 42 U.S.C. § 1395w-114(a)(3)(E)(i) - Alternative resource standard + href: https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3_E_i + - title: 42 CFR § 423.773(b)(2)(iii) and (d)(2) - Resource thresholds + href: https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.773#p-423.773(d)(2) + - title: SSA POMS HI 03030.025 - Resource Limits for Subsidy Eligibility + href: https://secure.ssa.gov/poms.nsf/lnx/0603030025 + - title: CMS, 2015 Medicare Part D Low-Income Subsidy (LIS) Income and Resource Standards, Attachment 4 + href: https://www.cms.gov/files/document/2015-lis-income-and-resourcespdf#page=5 + - title: CMS, Calendar Year (CY) 2026 Resource and Cost-Sharing Limits for Low-Income Subsidy (LIS), October 31, 2025 + href: https://www.cms.gov/files/document/cy2026-lis-resource-limits-memo.pdf#page=2 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/limit/individual.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/limit/individual.yaml new file mode 100644 index 00000000000..b24f6e8167c --- /dev/null +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/limit/individual.yaml @@ -0,0 +1,49 @@ +description: Medicare limits the Part D low-income subsidy to people who are not married and living with a spouse whose countable resources do not exceed this amount, before the burial funds exclusion. +values: + 2006-01-01: 10_000 + 2007-01-01: 10_210 + 2008-01-01: 10_490 + 2009-01-01: 11_010 + 2011-01-01: 11_140 + 2012-01-01: 11_570 + 2013-01-01: 11_800 + 2014-01-01: 11_940 + 2015-01-01: 12_140 + 2017-01-01: 12_320 + 2018-01-01: 12_600 + 2019-01-01: 12_890 + 2020-01-01: 13_110 + 2021-01-01: 13_290 + 2022-01-01: 14_010 + 2023-01-01: 15_160 + 2024-01-01: 15_720 + 2025-01-01: 16_100 + 2026-01-01: 16_590 + +metadata: + unit: currency-USD + period: year + label: Medicare Part D low-income subsidy individual resource limit + # Years after the last value here are projected in + # parameters/uprating_extensions.py (extend_part_d_lis_resource_limits): the + # previous year's limit times the change in the not seasonally adjusted + # CPI-U from September two years before to September one year before, + # never lowered, rounded to the nearest $10, effective January 1. The + # published September 2026 index gives $17,210 for 2027. + reference: + # The statute's alternative resource standard: $10,000 in 2006, raised + # each year by the September CPI-U and rounded to $10. Before 2024 it was + # the limit for the partial subsidy (the full subsidy had a lower limit); + # from 2024 it is the limit for the full subsidy, the only one left. SSA's + # table lists both pre-2024 limits; these are the higher ones. SSA's table + # excludes the $1,500 burial funds exclusion, which is a separate + # parameter. A person who is married but separated from their spouse + # counts only their own resources against this limit (20 CFR 418.3410(b)). + - title: 42 U.S.C. § 1395w-114(a)(3)(E)(i) - Alternative resource standard + href: https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3_E_i + - title: 42 CFR § 423.773(b)(2)(iii) and (d)(2) - Resource thresholds + href: https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.773#p-423.773(d)(2) + - title: SSA POMS HI 03030.025 - Resource Limits for Subsidy Eligibility + href: https://secure.ssa.gov/poms.nsf/lnx/0603030025 + - title: CMS, Calendar Year (CY) 2026 Resource and Cost-Sharing Limits for Low-Income Subsidy (LIS), October 31, 2025 + href: https://www.cms.gov/files/document/cy2026-lis-resource-limits-memo.pdf#page=2 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/sources.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/sources.yaml new file mode 100644 index 00000000000..f427dbed12a --- /dev/null +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/sources.yaml @@ -0,0 +1,21 @@ +description: Medicare counts these assets as resources under the Part D low-income subsidy. +values: + 2006-01-01: + - bank_account_assets + - stock_assets + - bond_assets + +metadata: + unit: list + period: year + label: Medicare Part D low-income subsidy countable resource sources + reference: + # SSA counts liquid resources (cash, financial accounts, stocks, bonds, + # mutual funds, retirement accounts and similar) and equity in real + # property other than the home. The model has no retirement account + # balances. Real estate is a household amount in the model, so it is in + # household_sources.yaml. + - title: 20 CFR § 418.3405 - What types of resources do we count? + href: https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3405 + - title: 42 CFR § 423.772 - Definitions (resources) + href: https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.772 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/income/excluded_sources.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/income/excluded_sources.yaml new file mode 100644 index 00000000000..18053652968 --- /dev/null +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/income/excluded_sources.yaml @@ -0,0 +1,15 @@ +description: Medicare excludes these Supplemental Security Income unearned income sources from countable income under the Part D low-income subsidy. +values: + 2006-01-01: + - dividend_income + - interest_income + +metadata: + unit: list + period: year + label: Medicare Part D low-income subsidy excluded income sources + reference: + - title: 20 CFR § 418.3350(d) - What types of unearned income do we not count? + href: https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3350#p-418.3350(d) + - title: SSA POMS HI 03020.050 - Unearned Income Exclusions + href: https://secure.ssa.gov/poms.nsf/lnx/0603020050 diff --git a/policyengine_us/parameters/gov/household/cbo_means_tested_transfers.yaml b/policyengine_us/parameters/gov/household/cbo_means_tested_transfers.yaml index f8081bc5a9d..b32ee470846 100644 --- a/policyengine_us/parameters/gov/household/cbo_means_tested_transfers.yaml +++ b/policyengine_us/parameters/gov/household/cbo_means_tested_transfers.yaml @@ -11,6 +11,7 @@ values: - reduced_price_school_meals - spm_unit_energy_subsidy - general_assistance + - part_d_lis metadata: unit: list @@ -19,3 +20,7 @@ metadata: reference: - title: Congressional Budget Office, Reconciling the Official Poverty Measure and CBO’s Distributional Analysis of Household Income, Including In-Kind Transfers href: https://www.cbo.gov/publication/61157 + # CBO counts the Medicare Part D low-income subsidy as a means-tested + # transfer, not as Medicare (see cbo_medicare_benefit). + - title: Congressional Budget Office, Sensitivity of the Distribution of Household Income to the Treatment of Health Insurance From 1979 to 2021 (January 2026) + href: https://www.cbo.gov/publication/61910 diff --git a/policyengine_us/parameters/gov/household/household_health_benefits.yaml b/policyengine_us/parameters/gov/household/household_health_benefits.yaml index ba051538c04..6943f21e6cb 100644 --- a/policyengine_us/parameters/gov/household/household_health_benefits.yaml +++ b/policyengine_us/parameters/gov/household/household_health_benefits.yaml @@ -3,6 +3,7 @@ values: 2022-01-01: - medicaid_cost - msp_cost + - part_d_lis - chip - assigned_aca_ptc - assigned_ca_premium_subsidy diff --git a/policyengine_us/parameters/uprating_extensions.py b/policyengine_us/parameters/uprating_extensions.py index 37caa4f6256..41bfb651905 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -575,6 +575,34 @@ def extend_la_cpi_u_indexed_amounts(parameters: ParameterNode, end_year: int) -> ) +def get_september_cpi_u_values(cpi: ParameterNode, year: int) -> Tuple[float, float]: + """The CPI-U for September two years and one year before ``year``. + + CMS indexes the Medicaid home equity limits and the Part D low-income + subsidy resource limits for ``year`` by the change from September of + ``year - 2`` to September of ``year - 1`` in the not seasonally adjusted + CPI-U. ``cpi`` is the ``gov.bls.cpi`` node. The published Septembers in + ``cpi_u_nsa_september`` are used when both are available. Otherwise both + Septembers come from the model's CPI-U series (seasonally adjusted from + January 2024) through ``get_projected_cpi_u_for_month``, so the two are + never mixed. Returns ``(prior September, later September)``. + """ + september = cpi.cpi_u_nsa_september + # Only September instants are BLS values; the parameter tree also + # backdates the first value to 2015-01-01. + published = { + int(value.instant_str[:4]) + for value in september.values_list + if value.instant_str.endswith("-09-01") + } + if {year - 2, year - 1} <= published: + return september(f"{year - 2}-09-01"), september(f"{year - 1}-09-01") + return ( + get_projected_cpi_u_for_month(cpi.cpi_u, instant(f"{year - 2}-09-01")), + get_projected_cpi_u_for_month(cpi.cpi_u, instant(f"{year - 1}-09-01")), + ) + + def get_cms_cpi_u_percentage_increase(cpi: ParameterNode, year: int) -> float: """The CPI-U percentage increase CMS applies to ``year``'s home equity limits. @@ -591,20 +619,10 @@ def get_cms_cpi_u_percentage_increase(cpi: ParameterNode, year: int) -> float: A fall in the index is not an increase, so the percentage is never negative. - ``cpi`` is the ``gov.bls.cpi`` node. The published Septembers in - ``cpi_u_nsa_september`` are used when both are available. Otherwise both - Septembers come from the model's CPI-U series (seasonally adjusted from - January 2024) through ``get_projected_cpi_u_for_month``, so the two are - never mixed. + ``cpi`` is the ``gov.bls.cpi`` node; see ``get_september_cpi_u_values`` + for where the Septembers come from. """ - september = cpi.cpi_u_nsa_september - published = {int(value.instant_str[:4]) for value in september.values_list} - if {year - 2, year - 1} <= published: - current = september(f"{year - 1}-09-01") - prior = september(f"{year - 2}-09-01") - else: - current = get_projected_cpi_u_for_month(cpi.cpi_u, instant(f"{year - 1}-09-01")) - prior = get_projected_cpi_u_for_month(cpi.cpi_u, instant(f"{year - 2}-09-01")) + prior, current = get_september_cpi_u_values(cpi, year) change = 100 * (Decimal(repr(current)) / Decimal(repr(prior)) - 1) percentage = change.quantize(Decimal("0.1"), rounding=ROUND_HALF_UP) return float(max(percentage, 0)) @@ -674,6 +692,67 @@ def extend_medicaid_home_equity_limits( ) +def adjust_part_d_lis_resource_limit_for_inflation( + prior_limit: float, prior_september: float, current_september: float +) -> float: + """Raise a Part D low-income subsidy resource limit for one year. + + 42 U.S.C. 1395w-114(a)(3)(E)(i)(II) and 42 CFR 423.773(d)(2)(ii) increase + the previous year's limit "by the annual percentage increase in the + consumer price index (all items, U.S. city average) as of September of + the previous year, rounded to the nearest multiple of $10". The increase + is the unrounded ratio of the two Septembers. CMS's CY 2026 memo reports + it as 3.01% (324.800 / 315.301), but the published $16,590 is $16,100 + times the unrounded 1.030127 ($16,585.04), where 1.0301 would give + $16,584.61 and so $16,580. A fall in the index is not an increase, so the + limit never falls: SSA kept the 2009 and 2015 limits for 2010 and 2016. + The arithmetic is decimal, with halves rounding up, so a product that + ends in exactly $5 rounds up as it does on paper. + """ + ratio = max( + Decimal(str(float(current_september))) / Decimal(str(float(prior_september))), + 1, + ) + adjusted = Decimal(str(float(prior_limit))) * ratio + tens = (adjusted / 10).quantize(Decimal(1), rounding=ROUND_HALF_UP) + return float(tens * 10) + + +def extend_part_d_lis_resource_limits(parameters: ParameterNode, end_year: int) -> None: + """Project the CPI-indexed Part D low-income subsidy resource limits. + + Each year after the last value in the YAML, the individual and couple + limits each become the previous year's limit raised by the change from + September two years before to September one year before (see + ``get_september_cpi_u_values``), never lowered, and rounded to the + nearest $10 (see ``adjust_part_d_lis_resource_limit_for_inflation``), + effective January 1. Amounts encoded in the YAML take precedence for + their own years. + + Like the other projections in this module, the values are fixed when the + parameter tree is built: a reform that changes one year's limit does not + carry through to later years. + """ + limits = parameters.gov.hhs.medicare.part_d.low_income_subsidy.eligibility.resources.limit + cpi = parameters.gov.bls.cpi + for limit in (limits.individual, limits.couple): + first_projected_year = 1 + max( + int(value.instant_str[:4]) for value in limit.values_list + ) + for year in range(first_projected_year, end_year + 1): + prior_september, current_september = get_september_cpi_u_values(cpi, year) + limit.update( + period=f"year:{year}-01-01:1", + value=adjust_part_d_lis_resource_limit_for_inflation( + limit(f"{year - 1}-01-01"), prior_september, current_september + ), + ) + limit.update( + start=instant(f"{end_year}-01-01"), + value=limit(f"{end_year}-01-01"), + ) + + def extend_wa_millionaires_standard_deduction( parameters: ParameterNode, end_year: int, @@ -1222,6 +1301,12 @@ def set_all_uprating_parameters(parameters: ParameterNode) -> ParameterNode: # Medicaid long-term care home equity limits, CMS's rounded CPI-U method extend_medicaid_home_equity_limits(parameters, end_year=END_YEAR) + # Part D low-income subsidy resource limits, raised by the unrounded + # September-to-September CPI-U change under 42 U.S.C. + # 1395w-114(a)(3)(E)(i). Must run after the CPI-U extension above so + # projected Septembers are available. + extend_part_d_lis_resource_limits(parameters, end_year=END_YEAR) + # The limits on a dependent's standard deduction follow 26 U.S.C. # 63(c)(4)'s chained CPI-U schedule, computed from the statutory 1987 and # 1997 bases. Must run after the Chained CPI-U extension above so diff --git a/policyengine_us/programs.yaml b/policyengine_us/programs.yaml index 4cb94f1a03b..c5a9a1e6ca8 100644 --- a/policyengine_us/programs.yaml +++ b/policyengine_us/programs.yaml @@ -383,6 +383,28 @@ programs: parameter_prefix: gov.aca verified_start_year: 2024 + - id: medicare_part_d_lis + name: Medicare Part D Extra Help + full_name: Medicare Part D Low-Income Subsidy (Extra Help) + category: Healthcare + agency: HHS + status: partial + coverage: US + variable: part_d_lis + parameter_prefix: gov.hhs.medicare.part_d.low_income_subsidy + verified_start_year: 2015 + notes: >- + Models eligibility (countable income below 150% of the poverty + guideline and resources within the CPI-indexed limits, or deemed + eligibility for Medicaid enrollees, SSI recipients and people eligible + for a Medicare Savings Program) and pays the subsidy only to people in + a Part D plan: Medicaid enrollees and SSI recipients get it without + applying, and other eligible people take it up through + takes_up_part_d_lis_if_eligible, which defaults to true. Each enrollee + gets the Medicare Trustees' average payment per subsidy enrollee, the + amount CBO assigns each recipient; the premium and cost-sharing + subsidies are not computed per person. + - id: tanf name: TANF full_name: Temporary Assistance for Needy Families diff --git a/policyengine_us/tests/core/test_part_d_lis.py b/policyengine_us/tests/core/test_part_d_lis.py new file mode 100644 index 00000000000..574cd01dbbe --- /dev/null +++ b/policyengine_us/tests/core/test_part_d_lis.py @@ -0,0 +1,800 @@ +"""Invariants of the Medicare Part D low-income subsidy (``part_d_lis``). + +The subsidy goes to Medicare enrollees in the 50 states and DC who are deemed +eligible (Medicaid enrollees, SSI recipients, people eligible for a Medicare +Savings Program) or whose countable income is below 150% of the poverty +guideline for their family size and whose countable resources are within the +limit plus $1,500 per person for burial funds (42 U.S.C. 1395w-114(a)(3); 42 +CFR 423.773; 20 CFR 418 subpart D). Each enrollee in a Part D plan is valued +at the Medicare Trustees' average subsidy per subsidy enrollee, as CBO values +it. + +Parameters: + +1. Transcription: ``low_income_subsidy_per_recipient`` equals Table IV.B9's + "Subsidy amount" (2026 report; the 2025 report for 2015; the 2023 report + for 2013 and 2014). +2. Conservation: IV.B9 enrollment times the amount returns Table IV.B10's + low-income subsidy payments, within the cells' rounding. +3. Differential: the same payments over Table V.B3's total enrollment equal + ``part_d_low_income_subsidy_per_enrollee``, the series + ``cbo_medicare_benefit`` subtracts, built from different cells. +4. Indexing: each resource limit is the previous year's limit raised by the + September-to-September change in the CPI-U (not seasonally adjusted), + never lowered, and rounded to the nearest $10, from the statute's 2006 + amounts, through 2027, the last year both Septembers are published. In + 2015 the couple limit is the formula's $24,260, not the $24,250 CMS + printed (source-conflict candidate + ``us:part-d-lis-couple-resource-limit-2015``). The Septembers typed here + equal ``cpi_u_nsa_september`` wherever both have a value. +5. Projection: from 2028 each limit is the previous year's raised by the + model's projected September CPI-U, never lowered, and rounded to $10. On + a synthetic parameter tree, values in the YAML stand, published + Septembers come first, a fall in the index leaves the limit unchanged, + and a missing September sends both Septembers to the model's CPI-U + series. +6. Rounding: the year-to-year adjustment reproduces CMS's published 2026 + limits from the unrounded ratio, and rounds an exact $5 up. +7. Bounds: the amount per recipient is positive and below Medicare spending + per enrollee in every year from 2013, the spending series' first, to 2100. + +Households (a seeded sample in one shared simulation, plus households at each +income and resource limit and just past it, each also run with more Social +Security, more interest and more assets): + +8. Countable income equals the rule restated here: the couple's SSI income + without interest and dividends, each source counted at no less than zero + for each person, less $20 a month from unearned income (the rest from + earnings), $65 a month of earnings and half the remaining earnings. +9. Interest and dividends never change countable income or eligibility. +10. More Social Security never lowers countable income or makes an income + ineligible person income eligible; more assets never make a resource + ineligible person resource eligible. +11. Eligibility equals the restated rule: family size counts the dependents + on both returns when spouses file separately, and resources include the + household's other real estate for the couple that holds it. Enrollees + are the eligible people in a Part D plan who are on Medicaid or SSI or + take the subsidy up, and the benefit is either zero or the year's amount + per recipient, positive exactly for enrollees. +12. Spouses get the same countable income, family size and test results. +13. Reach: the sample reaches every branch the properties above check, so a + change to the generator or the seed can't leave them vacuous. +14. Differential: for people without interest, dividends or rental income, + countable income equals 12 times ``msp_countable_income``, which applies + the same SSI exclusions through a separate formula (single people, and + couples where both spouses are Medicare eligible). This checks how each + formula gathers the couple's income; property 8 checks the exclusions + themselves. The sample's states keep SSI's $20 general exclusion for the + Medicare Savings Programs; Illinois and Mississippi exclude more under 42 + U.S.C. 1396a(r)(2), which the subsidy's income definition sets aside. +""" + +from types import SimpleNamespace + +import numpy as np +import pytest +from policyengine_core.parameters import ParameterNode + +from policyengine_us import Simulation +from policyengine_us.parameters.uprating_extensions import ( + adjust_part_d_lis_resource_limit_for_inflation, + extend_part_d_lis_resource_limits, + get_september_cpi_u_values, +) +from policyengine_us.system import system + +P = system.parameters +LIS = P.gov.hhs.medicare.part_d.low_income_subsidy +KINDS = ["individual", "couple"] + +# 2026 Medicare Trustees Report (sha256 ffa56b91...8fa8), Table IV.B9 (file +# page 158): low-income subsidy enrollment (millions) and subsidy amount ($). +# 2015 from the 2025 report (sha256 1b3de7a4...4d95), Table IV.B9 (page 158); +# 2013 and 2014 from the 2023 report (sha256 89daaf29...fa67), Table IV.B9 +# (page 158). +IV_B9 = { + 2013: (11.5, 2_023), + 2014: (11.8, 2_052), + 2015: (12.1, 2_112), + 2016: (12.4, 2_126), + 2017: (12.7, 2_156), + 2018: (12.9, 2_204), + 2019: (13.1, 2_273), + 2020: (13.1, 2_507), + 2021: (13.2, 2_646), + 2022: (13.7, 2_873), + 2023: (14.3, 3_092), + 2024: (14.5, 2_833), + 2025: (14.2, 1_226), + 2026: (14.4, 1_254), + 2027: (14.8, 1_384), + 2028: (15.2, 1_459), + 2029: (15.5, 1_579), + 2030: (15.8, 1_833), + 2031: (16.0, 1_922), + 2032: (16.2, 1_956), + 2033: (16.4, 1_982), + 2034: (16.6, 2_014), + 2035: (16.7, 2_076), +} +# Table IV.B10 (file page 159), low-income subsidy, $ billions, incurred; +# 2015 from the 2025 report's Table IV.B10 (page 159), 2013 and 2014 from the +# 2023 report's (page 159). +IV_B10 = { + **{2013: 23.2, 2014: 24.3}, + **{2015: 25.6, 2016: 26.4, 2017: 27.3, 2018: 28.5, 2019: 29.7, 2020: 33.0}, + **{2021: 35.0, 2022: 39.4, 2023: 44.2, 2024: 41.2, 2025: 17.4, 2026: 18.1}, + **{2027: 20.5, 2028: 22.1, 2029: 24.5, 2030: 28.9, 2031: 30.7, 2032: 31.7}, + **{2033: 32.4, 2034: 33.3, 2035: 34.8}, +} +# Table V.B3 (file page 196), total enrollment (HI and/or SMI), thousands; +# 2013 and 2014 from the 2021 report's Table V.B3 (sha256 ef3d1fa7...c8e, +# page 199), which the 2022 to 2026 reports drop. +V_B3_TOTAL = dict( + zip( + range(2013, 2036), + (52_504, 54_115) + + (55_589, 57_073, 58_683, 60_020, 61_535, 62_887, 63_980, 65_166) + + (66_606, 68_066, 69_289, 70_743, 72_322, 74_026, 75_436, 76_598) + + (77_521, 78_327, 79_058, 79_827, 80_632), + ) +) +# BLS CPI-U, U.S. city average, all items, not seasonally adjusted +# (CUUR0000SA0), September of each year, from the BLS public API: 2004 to +# 2025 retrieved 2026-10-11, 2026 retrieved 2026-11-08. pe-us's +# gov.bls.cpi.cpi_u_nsa_september starts in 2018; the years both hold must +# agree. +CPI_U_SEPTEMBER = { + **{2004: 189.9, 2005: 198.8, 2006: 202.9, 2007: 208.490, 2008: 218.783}, + **{2009: 215.969, 2010: 218.439, 2011: 226.889, 2012: 231.407}, + **{2013: 234.149, 2014: 238.031, 2015: 237.945, 2016: 241.428}, + **{2017: 246.819, 2018: 252.439, 2019: 256.759, 2020: 260.280}, + **{2021: 274.310, 2022: 296.808, 2023: 307.789, 2024: 315.301}, + **{2025: 324.800, 2026: 336.940}, +} +LAST_SEPTEMBER = max(CPI_U_SEPTEMBER) +STATUTE_2006 = {"individual": 10_000, "couple": 20_000} +# CMS, CY 2026 Resource and Cost-Sharing Limits for LIS (October 31, 2025), +# page 2, before the burial funds exclusion. +CMS_2025 = {"individual": 16_100, "couple": 32_130} +CMS_2026 = {"individual": 16_590, "couple": 33_100} + + +def _per_recipient(year): + return P.calibration.gov.hhs.medicare.part_d.low_income_subsidy_per_recipient( + f"{year}-01-01" + ) + + +def _limit(kind): + return getattr(LIS.eligibility.resources.limit, kind) + + +def _round_to_10(x): + return int(np.floor(x / 10 + 0.5)) * 10 + + +@pytest.mark.parametrize("year", sorted(IV_B9)) +def test_per_recipient_transcribes_table_iv_b9(year): + assert _per_recipient(year) == IV_B9[year][1] + + +@pytest.mark.parametrize("year", sorted(IV_B9)) +def test_enrollment_times_amount_returns_table_iv_b10(year): + enrollment, amount = IV_B9[year] + rebuilt = enrollment * 1e6 * _per_recipient(year) / 1e9 + # Rounding: total to $0.05B, enrollment to 0.05M x amount, amount to $0.5. + tolerance = 0.05 + 0.05e6 * amount / 1e9 + 0.5 * enrollment * 1e6 / 1e9 + assert abs(rebuilt - IV_B10[year]) <= tolerance + + +@pytest.mark.parametrize("year", sorted(IV_B9)) +def test_recipients_times_amount_matches_per_enrollee_series(year): + per_enrollee = ( + P.calibration.gov.hhs.medicare.part_d_low_income_subsidy_per_enrollee( + f"{year}-01-01" + ) + ) + enrollment, _ = IV_B9[year] + rebuilt = enrollment * 1e6 * _per_recipient(year) / (V_B3_TOTAL[year] * 1e3) + # Enrollment rounded to 0.1 million is at worst 0.42% off (2015, 12.1). + assert rebuilt == pytest.approx(per_enrollee, rel=0.005) + + +def test_typed_septembers_match_the_parameter(): + september = P.gov.bls.cpi.cpi_u_nsa_september + # pe-us backdates each parameter's first value to 2015-01-01; only the + # September instants are BLS values. + years = [ + int(value.instant_str[:4]) + for value in september.values_list + if value.instant_str.endswith("-09-01") + ] + assert LAST_SEPTEMBER in years + for year in years: + assert september(f"{year}-09-01") == pytest.approx( + CPI_U_SEPTEMBER[year], abs=5e-4 + ), year + + +def test_september_values_skip_non_september_instants(): + # The backdated 2015-01-01 entry is no BLS September, so 2017, which + # needs Septembers 2015 and 2016, reads both from the model's series. + cpi = ParameterNode( + data={ + "cpi_u_nsa_september": { + "values": {"2015-01-01": 237.945, "2016-09-01": 241.428} + }, + "cpi_u": {"values": {"2015-09-01": 200.0, "2016-09-01": 210.0}}, + } + ) + assert get_september_cpi_u_values(cpi, 2017) == (200.0, 210.0) + + +@pytest.mark.parametrize("kind", KINDS) +def test_resource_limits_follow_the_cpi_rule(kind): + limit = _limit(kind) + assert limit("2006-01-01") == STATUTE_2006[kind] + for year in range(2007, LAST_SEPTEMBER + 2): + growth = CPI_U_SEPTEMBER[year - 1] / CPI_U_SEPTEMBER[year - 2] + expected = _round_to_10(limit(f"{year - 1}-01-01") * max(growth, 1)) + assert limit(f"{year}-01-01") == expected, year + assert limit(f"{year}-07-01") == expected, year + + +@pytest.mark.parametrize("kind", KINDS) +def test_projected_resource_limits_follow_the_projected_cpi(kind): + limit = _limit(kind) + for year in range(LAST_SEPTEMBER + 2, 2101): + prior, current = get_september_cpi_u_values(P.gov.bls.cpi, year) + expected = _round_to_10(limit(f"{year - 1}-01-01") * max(current / prior, 1)) + assert limit(f"{year}-01-01") == expected, year + # The projection is not flat over the Trustees' window. + assert limit("2035-01-01") > limit(f"{LAST_SEPTEMBER + 1}-01-01") + + +def test_published_2026_limits_need_the_unrounded_ratio(): + # CMS reports the CY 2026 increase as 3.01%, but $16,100 x 1.0301 = + # $16,584.61 rounds to $16,580; the unrounded ratio gives $16,585.04 and + # the published $16,590. CMS's couple limits ($32,130 to $33,100) follow + # the same rule; the model's couple limit builds on the formula's 2025 + # amount, $20 above CMS's (see the couple limit's parameter file). + for kind in KINDS: + assert ( + adjust_part_d_lis_resource_limit_for_inflation( + CMS_2025[kind], 315.301, 324.800 + ) + == CMS_2026[kind] + ) + assert _limit("individual")("2026-01-01") == CMS_2026["individual"] + assert _limit("couple")("2026-01-01") == CMS_2026["couple"] + 20 + + +@pytest.mark.parametrize( + "prior_limit, prior, current, expected", + [ + # 2015 couple: 23,860 x 238.031 / 234.149 = 24,255.58. + (23_860, 234.149, 238.031, 24_260), + # 2016: the index fell (237.945 < 238.031), so the limit stays. + (24_260, 238.031, 237.945, 24_260), + # An exact $5 rounds up. + (1_005, 100.0, 100.0, 1_010), + (1_000, 100.0, 100.5, 1_010), + (1_000, 100.0, 100.4, 1_000), + ], +) +def test_adjustment_rounds_to_the_nearest_10(prior_limit, prior, current, expected): + assert ( + adjust_part_d_lis_resource_limit_for_inflation(prior_limit, prior, current) + == expected + ) + + +def _synthetic_tree(nsa_septembers, sa_septembers): + def values(data): + return {"values": data} + + return ParameterNode( + data={ + "gov": { + "hhs": { + "medicare": { + "part_d": { + "low_income_subsidy": { + "eligibility": { + "resources": { + "limit": { + "individual": values( + {"2025-01-01": 16_100} + ), + "couple": values( + { + "2025-01-01": 32_150, + "2026-01-01": 33_120, + } + ), + } + } + } + } + } + } + }, + "bls": { + "cpi": { + "cpi_u_nsa_september": values(nsa_septembers), + "cpi_u": values(sa_septembers), + } + }, + } + } + ) + + +def test_extension_on_a_synthetic_tree(): + nsa = {"2024-09-01": 315.301, "2025-09-01": 324.8, "2026-09-01": 336.94} + nsa["2027-09-01"] = 330.0 # a fall + nsa["2028-09-01"] = 340.0 + # The model's (seasonally adjusted) series differs from the published one, + # so a year that reads it shows which series it used. + sa = {date: 0.99 * value for date, value in nsa.items()} + sa["2029-09-01"] = 0.99 * 350.0 + parameters = _synthetic_tree(nsa, sa) + extend_part_d_lis_resource_limits(parameters, end_year=2031) + limits = parameters.gov.hhs.medicare.part_d.low_income_subsidy.eligibility + limits = limits.resources.limit + individual = {y: limits.individual(f"{y}-01-01") for y in range(2025, 2036)} + couple = {y: limits.couple(f"{y}-01-01") for y in range(2025, 2036)} + # 2026: published Septembers 2024 and 2025; the YAML's 2026 couple stands. + assert individual[2026] == 16_590 + assert couple[2026] == 33_120 + # 2027: published 2025 and 2026. + assert individual[2027] == _round_to_10(16_590 * 336.94 / 324.8) + assert couple[2027] == _round_to_10(33_120 * 336.94 / 324.8) + # 2028: the published index fell, so the limits stay. + assert individual[2028] == individual[2027] + assert couple[2028] == couple[2027] + # 2029: published 2027 and 2028. + assert individual[2029] == _round_to_10(individual[2028] * 340.0 / 330.0) + # 2030: September 2029 is not published, so both Septembers come from + # the model's series (0.99 x 350 / (0.99 x 340), not 0.99 x 350 / 340). + assert individual[2030] == _round_to_10(individual[2029] * 350.0 / 340.0) + # 2031: the model's series holds flat after its last month. + assert individual[2031] == individual[2030] + # After the end year the last value holds. + assert individual[2035] == individual[2031] + assert couple[2035] == couple[2031] + + +def test_amount_positive_and_below_spending(): + for year in range(2013, 2101): + amount = _per_recipient(year) + spending = P.calibration.gov.hhs.medicare.per_capita_cost(f"{year}-01-01") + assert 0 < amount < spending, year + + +# Households. +YEAR = 2026 +N = 300 +SEED = 20261011 +STATES = ["TX", "CA", "NY", "AK", "HI", "FL", "PR", "DC"] +VARIANTS = ["base", "more_ss", "more_interest", "more_assets"] +LIMITS = LIS.eligibility.resources(f"{YEAR}-01-01") +BURIAL = LIMITS.burial_exclusion + + +def _random_households(): + rng = np.random.default_rng(SEED) + # Features added after the first sample come from their own generator, + # so the original draws stay the same. + extra = np.random.default_rng(SEED + 1) + out = [] + for _ in range(N): + married = bool(rng.random() < 0.5) + + def person(): + return dict( + social_security=int(rng.choice([0, 6_000, 12_000, 18_000, 26_000])), + employment_income=int(rng.choice([0, 0, 0, 4_000, 15_000])), + interest_income=int(rng.choice([0, 0, 300, 4_000])), + dividend_income=int(rng.choice([0, 0, 1_000])), + bank_account_assets=int(rng.choice([0, 3_000, 9_000, 16_000, 40_000])), + stock_assets=int(rng.choice([0, 0, 5_000])), + medicare_enrolled=bool(rng.random() < 0.9), + medicaid_enrolled=bool(rng.random() < 0.15), + ssi=int(rng.choice([0, 0, 0, 0, 2_400])), + msp_eligible=bool(rng.random() < 0.15), + takes_up_part_d_lis_if_eligible=bool(rng.random() < 0.7), + takes_up_medicare_part_d_if_eligible=bool(rng.random() < 0.85), + rental_income=int(extra.choice([0, 0, 0, -6_000, 2_400])), + ) + + out.append( + dict( + state=str(rng.choice(STATES)), + dependents=int(rng.choice([0, 0, 0, 1, 2])), + members=[person()] + ([person()] if married else []), + separate=married and bool(extra.random() < 0.3), + real_estate=int(extra.choice([0, 0, 0, 6_000, 60_000])), + ) + ) + return out + + +def _boundary_households(): + # Not deemed, in a Part D plan, taking the subsidy up, in Texas. + def person(social_security, bank_account_assets=0): + return dict( + social_security=social_security, + employment_income=0, + interest_income=0, + dividend_income=0, + bank_account_assets=bank_account_assets, + stock_assets=0, + medicare_enrolled=True, + medicaid_enrolled=False, + ssi=0, + msp_eligible=False, + takes_up_part_d_lis_if_eligible=True, + takes_up_medicare_part_d_if_eligible=True, + rental_income=0, + ) + + def household(members, real_estate=0): + return dict( + state="TX", + dependents=0, + members=members, + separate=False, + real_estate=real_estate, + ) + + single = LIMITS.limit.individual + BURIAL + couple = LIMITS.limit.couple + 2 * BURIAL + single_income = 1.5 * _fpg("TX", 1) + couple_income = 1.5 * _fpg("TX", 2) + out = [] + for over in (0, 1): + out.append(household([person(6_000, single + over)])) + out.append( + household([person(6_000, couple - 9_000 + over), person(6_000, 9_000)]) + ) + out.append(household([person(6_000, single - 10_000 + over)], 10_000)) + # Countable income at 150% of the guideline, and $12 a year below. + out.append(household([person(int(single_income + 240 - 12 * (1 - over)))])) + out.append( + household( + [ + person(20_000), + person(int(couple_income + 240 - 20_000 - 12 * (1 - over))), + ] + ) + ) + return out + + +def _fpg(state, size): + fpg = P.gov.hhs.fpg(f"{YEAR}-01-01") + group = state if state in ("AK", "HI", "PR") else "CONTIGUOUS_US" + return fpg.first_person[group] + fpg.additional_person[group] * (size - 1) + + +HOUSEHOLDS = _random_households() + _boundary_households() + + +def _variant(member, variant, is_head): + member = dict(member) + if is_head and variant == "more_ss": + member["social_security"] += 3_000 + if is_head and variant == "more_interest": + member["interest_income"] += 5_000 + member["dividend_income"] += 2_000 + if is_head and variant == "more_assets": + member["bank_account_assets"] += 5_000 + return member + + +INCOME_INPUTS = ( + "social_security", + "employment_income", + "interest_income", + "dividend_income", + "rental_income", +) + + +def _situation(households, variants, inputs=True): + people, marital_units, tax_units, spm_units, units = {}, {}, {}, {}, {} + rows = [] # (household index, variant, member index, person name) + for v in variants: + for i, h in enumerate(households): + names = [] + for j, member in enumerate(h["members"]): + name = f"{v}_{i}_{j}" + record = _variant(member, v, j == 0) + if not inputs: + record = {k: record[k] for k in INCOME_INPUTS} + people[name] = { + "age": {YEAR: 70}, + **{k: {YEAR: val} for k, val in record.items()}, + } + names.append(name) + rows.append((i, v, j, name)) + spouses = list(names) + returns = [[name] for name in spouses] if h["separate"] else [list(spouses)] + dependents = [] + for k in range(h["dependents"]): + name = f"{v}_{i}_d{k}" + people[name] = { + "age": {YEAR: 10}, + "is_tax_unit_dependent": {YEAR: True}, + } + marital_units[f"mu_{name}"] = {"members": [name]} + # Spouses who file separately claim the dependents in turn. + returns[k % len(returns)].append(name) + dependents.append(name) + marital_units[f"mu_{v}_{i}"] = {"members": spouses} + for r, members in enumerate(returns): + tax_units[f"tu_{v}_{i}_{r}"] = {"members": members} + members = spouses + dependents + spm_units[f"spm_{v}_{i}"] = {"members": members} + units[f"hh_{v}_{i}"] = { + "members": members, + "state_code": {YEAR: h["state"]}, + "household_other_real_estate_equity": {YEAR: h["real_estate"]}, + } + situation = { + "people": people, + "marital_units": marital_units, + "tax_units": tax_units, + "spm_units": spm_units, + "households": units, + } + return situation, rows + + +@pytest.fixture(scope="module") +def sample(): + situation, rows = _situation(HOUSEHOLDS, VARIANTS) + sim = Simulation(situation=situation) + names = list(situation["people"]) + index = {name: k for k, name in enumerate(names)} + variables = [ + "part_d_lis", + "part_d_lis_enrolled", + "part_d_lis_eligible", + "part_d_lis_deemed_eligible", + "part_d_lis_income_eligible", + "part_d_lis_resource_eligible", + "part_d_lis_countable_income", + "part_d_lis_family_size", + ] + values = {v: sim.calculate(v, YEAR) for v in variables} + return rows, index, values + + +def _restated_countable(members, floor_sources=True): + def unearned(m): + rent = max(m["rental_income"], 0) if floor_sources else m["rental_income"] + return m["social_security"] + rent + + earned = sum(m["employment_income"] for m in members) / 12 + total_unearned = sum(unearned(m) for m in members) / 12 + general = min(20, max(total_unearned, 0)) + countable_unearned = max(total_unearned, 0) - general + countable_earned = max(earned - 65 - (20 - general), 0) / 2 + return 12 * (countable_unearned + countable_earned) + + +def _restated_tests(h, members, real_estate=True): + """Family size, income test and resource test for the household's couple.""" + couple = len(members) + size = couple + h["dependents"] + income_ok = _restated_countable(members) < 1.5 * _fpg(h["state"], size) + resources = sum(m["bank_account_assets"] + m["stock_assets"] for m in members) + # Every adult is 70 and every dependent 10, so the couple holds the + # household's real estate. + resources += h["real_estate"] if real_estate else 0 + limit = LIMITS.limit.couple if couple == 2 else LIMITS.limit.individual + resource_ok = resources <= limit + BURIAL * couple + return size, income_ok, resource_ok, resources - (limit + BURIAL * couple) + + +def _value(values, index, variable, name): + return values[variable][index[name]] + + +def _members(i, v): + return [_variant(m, v, j == 0) for j, m in enumerate(HOUSEHOLDS[i]["members"])] + + +def test_countable_income_matches_restated_rule(sample): + rows, index, values = sample + for i, v, _, name in rows: + expected = _restated_countable(_members(i, v)) + actual = _value(values, index, "part_d_lis_countable_income", name) + assert actual == pytest.approx(expected, abs=0.01), (i, v) + + +def test_interest_and_dividends_change_nothing(sample): + rows, index, values = sample + for i, v, j, name in rows: + if v != "more_interest": + continue + base = f"base_{i}_{j}" + for variable in [ + "part_d_lis_countable_income", + "part_d_lis_income_eligible", + "part_d_lis_eligible", + "part_d_lis", + ]: + assert _value(values, index, variable, name) == _value( + values, index, variable, base + ), (i, variable) + + +def test_more_income_or_assets_never_helps(sample): + rows, index, values = sample + for i, v, j, name in rows: + base = f"base_{i}_{j}" + if v == "more_ss": + assert _value(values, index, "part_d_lis_countable_income", name) >= ( + _value(values, index, "part_d_lis_countable_income", base) + ) + if _value(values, index, "part_d_lis_income_eligible", name): + assert _value(values, index, "part_d_lis_income_eligible", base), i + if v == "more_assets": + if _value(values, index, "part_d_lis_resource_eligible", name): + assert _value(values, index, "part_d_lis_resource_eligible", base), i + + +def _restated_enrollment(h, me, income_ok, resource_ok): + excluded = list(LIS.eligibility.ineligible_jurisdictions(f"{YEAR}-01-01")) + deemed = me["medicaid_enrolled"] or me["ssi"] > 0 or me["msp_eligible"] + eligible = ( + me["medicare_enrolled"] + and h["state"] not in excluded + and (deemed or (income_ok and resource_ok)) + ) + automatic = me["medicaid_enrolled"] or me["ssi"] > 0 + in_part_d = me["takes_up_medicare_part_d_if_eligible"] + takes_up = me["takes_up_part_d_lis_if_eligible"] + enrolled = eligible and in_part_d and (automatic or takes_up) + return deemed, eligible, enrolled + + +def test_eligibility_and_benefit_match_restated_rules(sample): + rows, index, values = sample + amount = _per_recipient(YEAR) + for i, v, j, name in rows: + h = HOUSEHOLDS[i] + members = _members(i, v) + size, income_ok, resource_ok, _ = _restated_tests(h, members) + _, eligible, enrolled = _restated_enrollment( + h, members[j], income_ok, resource_ok + ) + assert _value(values, index, "part_d_lis_family_size", name) == size, i + assert _value(values, index, "part_d_lis_income_eligible", name) == income_ok + assert _value(values, index, "part_d_lis_resource_eligible", name) == ( + resource_ok + ), i + assert _value(values, index, "part_d_lis_eligible", name) == eligible, i + assert _value(values, index, "part_d_lis_enrolled", name) == enrolled, i + benefit = _value(values, index, "part_d_lis", name) + assert benefit == (amount if enrolled else 0), i + + +def test_spouses_agree(sample): + rows, index, values = sample + for i, v, j, name in rows: + if j != 1: + continue + head = f"{v}_{i}_0" + for variable in [ + "part_d_lis_countable_income", + "part_d_lis_family_size", + "part_d_lis_income_eligible", + "part_d_lis_resource_eligible", + ]: + assert _value(values, index, variable, name) == _value( + values, index, variable, head + ), (i, variable) + + +def test_sample_reaches_every_branch(): + counts = dict.fromkeys( + [ + "couple", + "dependents", + "territory", + "no_medicare", + "deemed_by_medicaid_only", + "deemed_by_ssi_only", + "deemed_by_msp_only", + "passes_both_tests", + "fails_only_resources", + "fails_only_income", + "eligible_without_part_d", + "eligible_not_taking_up", + "automatic_despite_no_take_up", + "enrolled", + "on_resource_limit", + "one_dollar_over_resource_limit", + "on_income_limit", + "separate_returns_split_dependents", + "split_dependents_change_income_test", + "rental_floor_changes_income_test", + "real_estate_changes_resource_test", + ], + 0, + ) + for i, h in enumerate(HOUSEHOLDS): + members = _members(i, "base") + size, income_ok, resource_ok, over = _restated_tests(h, members) + counts["couple"] += len(members) == 2 + counts["dependents"] += h["dependents"] > 0 + counts["territory"] += h["state"] == "PR" + counts["on_resource_limit"] += over == 0 + counts["one_dollar_over_resource_limit"] += over == 1 + countable = _restated_countable(members) + counts["on_income_limit"] += countable == 1.5 * _fpg(h["state"], size) + if h["separate"] and h["dependents"] == 1: + counts["separate_returns_split_dependents"] += 1 + own_return = _restated_countable(members) < 1.5 * _fpg(h["state"], size - 1) + counts["split_dependents_change_income_test"] += own_return != income_ok + netted = _restated_countable(members, floor_sources=False) + counts["rental_floor_changes_income_test"] += income_ok != ( + netted < 1.5 * _fpg(h["state"], size) + ) + without = _restated_tests(h, members, real_estate=False)[2] + counts["real_estate_changes_resource_test"] += without != resource_ok + for me in members: + counts["no_medicare"] += not me["medicare_enrolled"] + routes = [me["medicaid_enrolled"], me["ssi"] > 0, me["msp_eligible"]] + if sum(routes) == 1: + counts["deemed_by_medicaid_only"] += routes[0] + counts["deemed_by_ssi_only"] += routes[1] + counts["deemed_by_msp_only"] += routes[2] + counts["passes_both_tests"] += income_ok and resource_ok + counts["fails_only_resources"] += income_ok and not resource_ok + counts["fails_only_income"] += resource_ok and not income_ok + _, eligible, enrolled = _restated_enrollment(h, me, income_ok, resource_ok) + in_part_d = me["takes_up_medicare_part_d_if_eligible"] + automatic = me["medicaid_enrolled"] or me["ssi"] > 0 + takes_up = me["takes_up_part_d_lis_if_eligible"] + counts["eligible_without_part_d"] += eligible and not in_part_d + counts["eligible_not_taking_up"] += ( + eligible and in_part_d and not automatic and not takes_up + ) + counts["automatic_despite_no_take_up"] += ( + eligible and in_part_d and automatic and not takes_up + ) + counts["enrolled"] += enrolled + unreached = [branch for branch, count in counts.items() if count == 0] + assert not unreached, counts + + +def test_countable_income_matches_msp_countable_income(): + # No interest, dividends or rental income, everyone 70 and so Medicare + # eligible: the MSP formula then counts the same income the same way, + # through its couple branch for married couples and its personal branch + # otherwise. + households = [ + { + **h, + "members": [ + {**m, "interest_income": 0, "dividend_income": 0, "rental_income": 0} + for m in h["members"] + ], + } + for h in HOUSEHOLDS + ] + situation, rows = _situation(households, ["base"], inputs=False) + sim = Simulation(situation=situation) + names = list(situation["people"]) + lis = sim.calculate("part_d_lis_countable_income", YEAR) + msp = sim.calculate("msp_countable_income", f"{YEAR}-01") + medicare_eligible = sim.calculate("is_medicare_eligible", YEAR) + adults = np.array([not name.split("_")[-1].startswith("d") for name in names]) + assert medicare_eligible[adults].all() + np.testing.assert_allclose(lis[adults], 12 * msp[adults], atol=0.01) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/integration.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/integration.yaml new file mode 100644 index 00000000000..473be095d6d --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/integration.yaml @@ -0,0 +1,341 @@ +# Households computed from their inputs, through the Medicare Savings +# Program and the subsidy's own income and resource tests. 2026 guidelines: +# $15,960 for one person and $21,640 for two (contiguous states); the +# subsidy limit is 150% of them: $23,940 and $32,460. Medicare Savings +# Program QI limit: 135%, $21,546 for one person and $29,214 for two. + +- name: Single retiree deemed eligible through a Medicare Savings Program. + period: 2026 + input: + people: + person: + age: 70 + social_security: 20_000 + bank_account_assets: 5_000 + households: + household: + members: [person] + state_code: TX + output: + # Medicare Savings Program countable income (20,000 / 12 - 20) x 12 = + # 19,760 is above the SLMB limit (120%, 19,152) and below the QI limit, + # with resources under the $9,950 limit. + msp_eligible: true + part_d_lis_deemed_eligible: true + part_d_lis_countable_income: 19_760 + part_d_lis_income_eligible: true + part_d_lis_resource_eligible: true + part_d_lis_eligible: true + part_d_lis_enrolled: true + part_d_lis: 1_254 + +- name: Interest is excluded for the subsidy but not for the Medicare Savings Program. + period: 2026 + input: + people: + person: + age: 70 + social_security: 20_000 + interest_income: 5_000 + bank_account_assets: 15_000 + households: + household: + members: [person] + state_code: TX + output: + # Medicare Savings Program countable income (25,000 / 12 - 20) x 12 = + # 24,760 is over the QI limit, and $15,000 is over its $9,950 resource + # limit. The subsidy excludes interest: 19,760 < 23,940, and $15,000 <= + # 16,590 + 1,500. + msp_eligible: false + part_d_lis_deemed_eligible: false + part_d_lis_countable_income: 19_760 + part_d_lis_eligible: true + part_d_lis: 1_254 + +- name: Single retiree with resources over the limit is not eligible. + period: 2026 + input: + people: + person: + age: 68 + social_security: 22_000 + bank_account_assets: 50_000 + households: + household: + members: [person] + state_code: TX + output: + # Countable income (22,000 / 12 - 20) x 12 = 21,760 is under 23,940, but + # $50,000 is over $18,090. + part_d_lis_income_eligible: true + part_d_lis_resource_eligible: false + part_d_lis_deemed_eligible: false + part_d_lis_eligible: false + part_d_lis: 0 + +- name: Married couple eligible through the income and resource tests. + period: 2026 + input: + people: + spouse1: + age: 70 + social_security: 18_000 + bank_account_assets: 20_000 + spouse2: + age: 70 + social_security: 12_000 + bank_account_assets: 10_000 + marital_units: + marital_unit: + members: [spouse1, spouse2] + tax_units: + tax_unit: + members: [spouse1, spouse2] + spm_units: + spm_unit: + members: [spouse1, spouse2] + households: + household: + members: [spouse1, spouse2] + state_code: TX + output: + # Combined countable income (30,000 / 12 - 20) x 12 = 29,760 is over the + # couple QI limit (29,214) but under 32,460; combined resources $30,000 + # are under 33,120 + 3,000 = 36,120. + msp_eligible: [false, false] + part_d_lis_family_size: [2, 2] + part_d_lis_countable_income: [29_760, 29_760] + part_d_lis_income_eligible: [true, true] + part_d_lis_resource_eligible: [true, true] + part_d_lis_eligible: [true, true] + part_d_lis: [1_254, 1_254] + +- name: Not taking it up leaves an applicant-path eligible person without it. + period: 2026 + input: + people: + person: + age: 70 + social_security: 20_000 + interest_income: 5_000 + bank_account_assets: 15_000 + takes_up_part_d_lis_if_eligible: false + households: + household: + members: [person] + state_code: TX + output: + part_d_lis_eligible: true + part_d_lis_enrolled: false + part_d_lis: 0 + +- name: An eligible person outside Part D gets no subsidy. + period: 2026 + input: + people: + person: + age: 70 + social_security: 20_000 + bank_account_assets: 5_000 + # For example, drug coverage from TRICARE for Life instead. + takes_up_medicare_part_d_if_eligible: false + households: + household: + members: [person] + state_code: TX + output: + # Deemed eligible through a Medicare Savings Program, as in the first + # case, but the subsidy pays only for people in a Part D plan. + part_d_lis_eligible: true + medicare_part_d_enrolled: false + part_d_lis_enrolled: false + part_d_lis: 0 + +- name: 2023, a person in the former partial-subsidy range gets the average. + period: 2023 + input: + people: + person: + age: 70 + social_security: 20_500 + bank_account_assets: 12_000 + households: + household: + members: [person] + state_code: TX + output: + # Countable income (20,500 / 12 - 20) x 12 = 20,260: above 135% (19,683) + # and below 150% (21,870) of the 2023 guideline ($14,580). Resources + # $12,000 are above the 2023 full-subsidy limit (9,090 + 1,500 = 10,590) + # and within the partial limit (15,160 + 1,500 = 16,660). + part_d_lis_countable_income: 20_260 + part_d_lis_eligible: true + part_d_lis: 3_092 + +- name: Under-65 Medicare enrollee with a disability on SSI. + period: 2026 + input: + people: + person: + age: 50 + is_disabled: true + medicare_enrolled: true + ssi: 6_000 + bank_account_assets: 1_000 + households: + household: + members: [person] + state_code: TX + output: + part_d_lis_deemed_eligible: true + part_d_lis_enrolled: true + part_d_lis: 1_254 + +- name: Person aged 70 not enrolled in Medicare gets nothing. + period: 2026 + input: + people: + person: + age: 70 + medicare_enrolled: false + social_security: 10_000 + households: + household: + members: [person] + state_code: TX + output: + part_d_lis_eligible: false + part_d_lis: 0 + +- name: Non-home real estate counts toward the resource limit. + period: 2026 + input: + people: + person: + age: 72 + social_security: 22_800 + bank_account_assets: 1_000 + households: + household: + members: [person] + state_code: TX + household_other_real_estate_equity: 200_000 + output: + # Countable income (22,800 / 12 - 20) x 12 = 22,560 is over the QI limit + # (21,546), so no Medicare Savings Program deems the person eligible, and + # under 23,940. Resources 1,000 + 200,000 exceed 16,590 + 1,500 = 18,090 + # (20 CFR 418.3405(b)). + msp_eligible: false + part_d_lis_income_eligible: true + part_d_lis_resource_eligible: false + part_d_lis_eligible: false + part_d_lis: 0 + +- name: The same person without the real estate gets the subsidy. + period: 2026 + input: + people: + person: + age: 72 + social_security: 22_800 + bank_account_assets: 1_000 + households: + household: + members: [person] + state_code: TX + output: + msp_eligible: false + part_d_lis_resource_eligible: true + part_d_lis_eligible: true + part_d_lis: 1_254 + +- name: A rental loss does not offset Social Security. + period: 2026 + input: + people: + person: + age: 70 + social_security: 30_000 + rental_income: -8_000 + bank_account_assets: 5_000 + households: + household: + members: [person] + state_code: TX + output: + # SSI counts net rental income but does not let excess expenses offset + # other income (POMS SI 00830.505), so the subsidy counts (30,000 / 12 - + # 20) x 12 = 29,760, over 23,940. Netting the loss would give 21,760. + part_d_lis_countable_income: 29_760 + part_d_lis_income_eligible: false + part_d_lis: 0 + +- name: Spouses filing separately share one family size. + period: 2026 + input: + people: + spouse1: + age: 70 + social_security: 24_000 + spouse2: + age: 68 + social_security: 9_000 + grandchild: + age: 10 + is_tax_unit_dependent: true + marital_units: + couple: + members: [spouse1, spouse2] + grandchild_unit: + members: [grandchild] + tax_units: + spouse1_return: + members: [spouse1] + spouse2_return: + members: [spouse2, grandchild] + spm_units: + spm_unit: + members: [spouse1, spouse2, grandchild] + households: + household: + members: [spouse1, spouse2, grandchild] + state_code: TX + output: + # The grandchild is a dependent on the second spouse's separate return, + # so the couple's family is 3 for both spouses. Combined countable + # income (33,000 / 12 - 20) x 12 = 32,760 is under 150% of the 3-person + # guideline, 1.5 x (15,960 + 2 x 5,680) = 40,980, but over the 2-person + # 32,460, so counting only the first spouse's return would deny that + # spouse. The couple's 2,730 a month exceeds the couple QI limit of + # 29,214 / 12 = 2,434.50, so no Medicare Savings Program deems them. + msp_eligible: [false, false, false] + part_d_lis_family_size: [3, 3, 1] + part_d_lis_countable_income: [32_760, 32_760, 0] + part_d_lis_income_eligible: [true, true, true] + part_d_lis_eligible: [true, true, false] + part_d_lis: [1_254, 1_254, 0] + +- name: 2027 uses the CPI-indexed resource limit. + period: 2027 + input: + people: + person: + age: 70 + social_security: 18_000 + bank_account_assets: 18_500 + households: + household: + members: [person] + state_code: TX + output: + # The 2027 limit is 16,590 x 336.940 / 324.800 = 17,210.08, rounded to + # 17,210 (September 2026 and 2025 CPI-U), so resources may reach 17,210 + # + 1,500 = 18,710. $18,500 is over the Medicare Savings Program's + # resource limit, and countable income 17,760 is under 150% of the + # projected 2027 guideline. + msp_eligible: false + part_d_lis_income_eligible: true + part_d_lis_resource_eligible: true + part_d_lis_eligible: true + part_d_lis: 1_384 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis.yaml new file mode 100644 index 00000000000..2402f73921b --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis.yaml @@ -0,0 +1,79 @@ +# Each enrollee gets the Trustees' average subsidy per low-income subsidy +# enrollee (2026 report, Table IV.B9; 2015 from the 2025 report; 2013 and +# 2014 from the 2023 report). + +- name: Enrollee in 2026. + period: 2026 + input: + people: + person: + part_d_lis_enrolled: true + output: + part_d_lis: 1_254 + +- name: Enrollee in 2015. + period: 2015 + input: + people: + person: + part_d_lis_enrolled: true + output: + part_d_lis: 2_112 + +- name: Enrollee in 2013, the first year of the Medicare spending series. + period: 2013 + input: + people: + person: + part_d_lis_enrolled: true + output: + part_d_lis: 2_023 + +- name: Enrollee in 2014. + period: 2014 + input: + people: + person: + part_d_lis_enrolled: true + output: + part_d_lis: 2_052 + +- name: Enrollee in 2024, before the Inflation Reduction Act redesign. + period: 2024 + input: + people: + person: + part_d_lis_enrolled: true + output: + part_d_lis: 2_833 + +- name: Enrollee in 2035, the last Trustees year. + period: 2035 + input: + people: + person: + part_d_lis_enrolled: true + output: + part_d_lis: 2_076 + +- name: Not enrolled. + period: 2026 + input: + people: + person: + part_d_lis_enrolled: false + output: + part_d_lis: 0 + +- name: Two enrollees in one household get the same average. + period: 2026 + input: + people: + spouse1: + part_d_lis_enrolled: true + spouse2: + part_d_lis_enrolled: true + child: + part_d_lis_enrolled: false + output: + part_d_lis: [1_254, 1_254, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_countable_income.yaml new file mode 100644 index 00000000000..66d6d18ca5c --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_countable_income.yaml @@ -0,0 +1,119 @@ +# Countable income: SSI income of the person and a spouse living with them, +# without interest and dividends; the $20 general exclusion and the $65 and +# one-half earned income exclusions apply once to the couple's income +# (20 CFR 418.3310, 418.3325, 418.3350). + +- name: Single person, Social Security and interest; interest excluded. + period: 2026 + input: + people: + person: + age: 70 + social_security: 18_000 + interest_income: 1_000 + dividend_income: 600 + output: + # Unearned 18,000 + 1,000 + 600 - 1,600 excluded = 18,000 a year, + # 1,500 a month; less the $20 general exclusion = 1,480 a month. + # 1,480 x 12 = 17,760. + part_d_lis_countable_income: 17_760 + +- name: Married couple, exclusions applied once to combined income. + period: 2026 + input: + people: + spouse1: + age: 70 + employment_income: 12_000 + spouse2: + age: 67 + social_security: 6_000 + dividend_income: 500 + marital_units: + marital_unit: + members: [spouse1, spouse2] + output: + # Combined unearned 6,000 a year = 500 a month, less $20 = 480. + # Combined earned 12,000 a year = 1,000 a month, less $65 = 935, half + # excluded = 467.50. Monthly 947.50 x 12 = 11,370 for each spouse. + part_d_lis_countable_income: [11_370, 11_370] + +- name: Spouse's income counts even when only one spouse is a Medicare enrollee. + period: 2026 + input: + people: + spouse1: + age: 70 + medicare_enrolled: true + social_security: 12_000 + spouse2: + age: 60 + medicare_enrolled: false + employment_income: 30_000 + marital_units: + marital_unit: + members: [spouse1, spouse2] + output: + # Unearned 1,000 a month less $20 = 980. Earned 2,500 a month less $65 = + # 2,435, half = 1,217.50. Monthly 2,197.50 x 12 = 26,370. + part_d_lis_countable_income: [26_370, 26_370] + +- name: Earned income only; the unused $20 general exclusion applies to earnings. + period: 2026 + input: + people: + person: + age: 66 + employment_income: 6_000 + output: + # 500 a month less $20 less $65 = 415, half = 207.50; x 12 = 2,490. + part_d_lis_countable_income: 2_490 + +- name: No income. + period: 2026 + input: + people: + person: + age: 66 + output: + part_d_lis_countable_income: 0 + +- name: A rental loss counts as zero and does not offset other income. + period: 2026 + input: + people: + person: + age: 70 + social_security: 12_000 + rental_income: -3_000 + output: + # (12,000 / 12 - 20) x 12 = 11,760 (POMS SI 00830.505). + part_d_lis_countable_income: 11_760 + +- name: One spouse's rental loss does not offset the other's income. + period: 2026 + input: + people: + spouse1: + age: 70 + social_security: 12_000 + spouse2: + age: 68 + rental_income: -6_000 + marital_units: + marital_unit: + members: [spouse1, spouse2] + output: + part_d_lis_countable_income: [11_760, 11_760] + +- name: Rental income counts. + period: 2026 + input: + people: + person: + age: 70 + social_security: 12_000 + rental_income: 2_400 + output: + # (14,400 / 12 - 20) x 12 = 14,160. + part_d_lis_countable_income: 14_160 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_deemed_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_deemed_eligible.yaml new file mode 100644 index 00000000000..9f76a8f12ab --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_deemed_eligible.yaml @@ -0,0 +1,47 @@ +# Full-benefit dual eligibles, SSI recipients and Medicare Savings Program +# enrollees are treated as eligible for the full subsidy (42 CFR +# 423.773(c)(1)). + +- name: Medicaid enrollee is deemed eligible. + period: 2026 + input: + people: + person: + medicaid_enrolled: true + ssi: 0 + msp_eligible: false + output: + part_d_lis_deemed_eligible: true + +- name: SSI recipient is deemed eligible. + period: 2026 + input: + people: + person: + medicaid_enrolled: false + ssi: 1_200 + msp_eligible: false + output: + part_d_lis_deemed_eligible: true + +- name: Medicare Savings Program eligible person is deemed eligible. + period: 2026 + input: + people: + person: + medicaid_enrolled: false + ssi: 0 + msp_eligible: true + output: + part_d_lis_deemed_eligible: true + +- name: None of the three is not deemed eligible. + period: 2026 + input: + people: + person: + medicaid_enrolled: false + ssi: 0 + msp_eligible: false + output: + part_d_lis_deemed_eligible: false diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_eligible.yaml new file mode 100644 index 00000000000..aa46e6d9793 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_eligible.yaml @@ -0,0 +1,82 @@ +# Eligible: a Medicare enrollee in the 50 states or DC who is deemed eligible +# or meets both the income and resource tests (42 U.S.C. 1395w-114(a)(3)). + +- name: Meets the income and resource tests. + period: 2026 + input: + people: + person: + medicare_enrolled: true + part_d_lis_deemed_eligible: false + part_d_lis_income_eligible: true + part_d_lis_resource_eligible: true + output: + part_d_lis_eligible: true + +- name: Income eligible but resources over the limit. + period: 2026 + input: + people: + person: + medicare_enrolled: true + part_d_lis_deemed_eligible: false + part_d_lis_income_eligible: true + part_d_lis_resource_eligible: false + output: + part_d_lis_eligible: false + +- name: Deemed eligible with income and resources over the limits. + period: 2026 + input: + people: + person: + medicare_enrolled: true + part_d_lis_deemed_eligible: true + part_d_lis_income_eligible: false + part_d_lis_resource_eligible: false + output: + part_d_lis_eligible: true + +- name: Not enrolled in Medicare. + period: 2026 + input: + people: + person: + medicare_enrolled: false + part_d_lis_deemed_eligible: true + part_d_lis_income_eligible: true + part_d_lis_resource_eligible: true + output: + part_d_lis_eligible: false + +- name: Puerto Rico residents are not eligible. + period: 2026 + input: + people: + person: + medicare_enrolled: true + part_d_lis_deemed_eligible: true + part_d_lis_income_eligible: true + part_d_lis_resource_eligible: true + households: + household: + members: [person] + state_code: PR + output: + part_d_lis_eligible: false + +- name: District of Columbia residents are eligible. + period: 2026 + input: + people: + person: + medicare_enrolled: true + part_d_lis_deemed_eligible: false + part_d_lis_income_eligible: true + part_d_lis_resource_eligible: true + households: + household: + members: [person] + state_code: DC + output: + part_d_lis_eligible: true diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_enrolled.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_enrolled.yaml new file mode 100644 index 00000000000..ed08dcadf32 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_enrolled.yaml @@ -0,0 +1,98 @@ +# The subsidy goes only to people in a Part D plan. Medicaid enrollees and +# SSI recipients are enrolled without an application; other eligible people +# are enrolled if they take up the subsidy. + +- name: Medicaid enrollee who would not apply is enrolled anyway. + period: 2026 + input: + people: + person: + part_d_lis_eligible: true + medicare_part_d_enrolled: true + medicaid_enrolled: true + ssi: 0 + takes_up_part_d_lis_if_eligible: false + output: + part_d_lis_enrolled: true + +- name: SSI recipient who would not apply is enrolled anyway. + period: 2026 + input: + people: + person: + part_d_lis_eligible: true + medicare_part_d_enrolled: true + medicaid_enrolled: false + ssi: 1_200 + takes_up_part_d_lis_if_eligible: false + output: + part_d_lis_enrolled: true + +- name: Other eligible person who does not take it up. + period: 2026 + input: + people: + person: + part_d_lis_eligible: true + medicare_part_d_enrolled: true + medicaid_enrolled: false + ssi: 0 + takes_up_part_d_lis_if_eligible: false + output: + part_d_lis_enrolled: false + +- name: Other eligible person who takes it up. + period: 2026 + input: + people: + person: + part_d_lis_eligible: true + medicare_part_d_enrolled: true + medicaid_enrolled: false + ssi: 0 + takes_up_part_d_lis_if_eligible: true + output: + part_d_lis_enrolled: true + +- name: Medicaid enrollee who opted out of Part D gets no subsidy. + period: 2026 + input: + people: + person: + part_d_lis_eligible: true + medicare_part_d_enrolled: false + medicaid_enrolled: true + ssi: 1_200 + takes_up_part_d_lis_if_eligible: true + output: + part_d_lis_enrolled: false + +- name: Medicaid enrollee who is not eligible (not in Medicare) is not enrolled. + period: 2026 + input: + people: + person: + part_d_lis_eligible: false + medicare_part_d_enrolled: true + medicaid_enrolled: true + ssi: 0 + output: + part_d_lis_enrolled: false + +- name: A Medicaid enrollee whose Part D input is false gets no subsidy. + period: 2026 + input: + people: + person: + age: 70 + part_d_lis_eligible: true + medicaid_enrolled: true + ssi: 0 + # CMS enrolls Medicaid enrollees in a plan unless they decline Part D + # or are qualifying covered retirees who don't choose one (42 CFR + # 423.34(d), (e)); the microdata mark those people with this input. + takes_up_medicare_part_d_if_eligible: false + output: + medicare_part_d_enrolled: false + part_d_lis_enrolled: false + part_d_lis: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_family_size.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_family_size.yaml new file mode 100644 index 00000000000..5a78aa56f7f --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_family_size.yaml @@ -0,0 +1,111 @@ +# Family size: the person, a spouse living with them, and relatives who +# depend on them for at least half of their support (42 CFR 423.772). + +- name: Single person living alone. + period: 2026 + input: + people: + person: + age: 70 + output: + part_d_lis_family_size: 1 + +- name: Married couple supporting a grandchild. + period: 2026 + input: + people: + head: + age: 70 + spouse: + age: 68 + grandchild: + age: 12 + is_tax_unit_dependent: true + marital_units: + couple: + members: [head, spouse] + grandchild_unit: + members: [grandchild] + tax_units: + tax_unit: + members: [head, spouse, grandchild] + output: + # The couple's family is 3. The grandchild is not a head or spouse, so + # its own family size is 1. + part_d_lis_family_size: [3, 3, 1] + +- name: Parent who is a dependent of an adult child. + period: 2026 + input: + people: + child: + age: 45 + parent: + age: 80 + is_tax_unit_dependent: true + marital_units: + child_unit: + members: [child] + parent_unit: + members: [parent] + tax_units: + tax_unit: + members: [child, parent] + output: + # The parent depends on the child, so the parent's family is just the + # parent; the child's family includes the dependent parent. + part_d_lis_family_size: [2, 1] + +- name: Spouses filing separately count the dependents on both returns. + period: 2026 + input: + people: + spouse1: + age: 70 + spouse2: + age: 68 + grandchild: + age: 10 + is_tax_unit_dependent: true + marital_units: + couple: + members: [spouse1, spouse2] + grandchild_unit: + members: [grandchild] + tax_units: + spouse1_return: + members: [spouse1] + spouse2_return: + members: [spouse2, grandchild] + output: + # Each spouse heads a return; only the second claims the grandchild. + part_d_lis_family_size: [3, 3, 1] + +- name: Each spouse files separately and claims one child. + period: 2026 + input: + people: + spouse1: + age: 70 + child1: + age: 15 + is_tax_unit_dependent: true + spouse2: + age: 68 + child2: + age: 12 + is_tax_unit_dependent: true + marital_units: + couple: + members: [spouse1, spouse2] + child1_unit: + members: [child1] + child2_unit: + members: [child2] + tax_units: + spouse1_return: + members: [spouse1, child1] + spouse2_return: + members: [spouse2, child2] + output: + part_d_lis_family_size: [4, 1, 4, 1] diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_income_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_income_eligible.yaml new file mode 100644 index 00000000000..44769a1e266 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_income_eligible.yaml @@ -0,0 +1,62 @@ +# Countable income must be below 150% of the poverty guideline for the +# family size (42 U.S.C. 1395w-114(a)(3)(A)(ii)). 2026 guidelines: $15,960 +# for one person in the contiguous states plus $5,680 per additional person; +# Alaska $19,950 plus $7,100. + +- name: Single person just below 150% of the guideline. + period: 2026 + input: + people: + person: + part_d_lis_countable_income: 23_939 + part_d_lis_family_size: 1 + output: + # 1.5 x 15,960 = 23,940. + part_d_lis_income_eligible: true + +- name: Single person exactly at 150% of the guideline is not below it. + period: 2026 + input: + people: + person: + part_d_lis_countable_income: 23_940 + part_d_lis_family_size: 1 + output: + part_d_lis_income_eligible: false + +- name: Family of three uses the three-person guideline. + period: 2026 + input: + people: + person: + part_d_lis_countable_income: 40_000 + part_d_lis_family_size: 3 + output: + # 1.5 x (15,960 + 2 x 5,680) = 1.5 x 27,320 = 40,980. + part_d_lis_income_eligible: true + +- name: Alaska uses its own guideline. + period: 2026 + input: + people: + person: + part_d_lis_countable_income: 29_000 + part_d_lis_family_size: 1 + households: + household: + members: [person] + state_code: AK + output: + # 1.5 x 19,950 = 29,925; the contiguous-state limit would be 23,940. + part_d_lis_income_eligible: true + +- name: 2023, income from 135% to 150% of the guideline qualified for the partial subsidy. + period: 2023 + input: + people: + person: + part_d_lis_countable_income: 21_000 + part_d_lis_family_size: 1 + output: + # 2023: 1.35 x 14,580 = 19,683 < 21,000 < 1.5 x 14,580 = 21,870. + part_d_lis_income_eligible: true diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_resource_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_resource_eligible.yaml new file mode 100644 index 00000000000..bf2e67838fb --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_resource_eligible.yaml @@ -0,0 +1,181 @@ +# Countable resources of the person and a spouse living with them must not +# exceed the limit plus $1,500 per person for burial funds (POMS HI +# 03030.025, 20 CFR 418.3425(j)). + +- name: Single person at the 2026 limit with the burial exclusion. + period: 2026 + input: + people: + person: + bank_account_assets: 18_090 + output: + # 16,590 + 1,500 = 18,090. + part_d_lis_resource_eligible: true + +- name: Single person one dollar over the 2026 limit. + period: 2026 + input: + people: + person: + bank_account_assets: 18_091 + output: + part_d_lis_resource_eligible: false + +- name: Married couple at the 2026 couple limit, resources combined. + period: 2026 + input: + people: + spouse1: + bank_account_assets: 20_000 + spouse2: + stock_assets: 10_120 + bond_assets: 6_000 + marital_units: + marital_unit: + members: [spouse1, spouse2] + output: + # 20,000 + 10,120 + 6,000 = 36,120 = 33,120 + 2 x 1,500. The statute's + # formula gives $33,120; CMS published $33,100 (see the couple limit's + # parameter file). + part_d_lis_resource_eligible: [true, true] + +- name: Married couple one dollar over the 2026 couple limit. + period: 2026 + input: + people: + spouse1: + bank_account_assets: 20_001 + spouse2: + stock_assets: 16_120 + marital_units: + marital_unit: + members: [spouse1, spouse2] + output: + part_d_lis_resource_eligible: [false, false] + +- name: 2023 uses the higher (partial subsidy) limit. + period: 2023 + input: + people: + person: + bank_account_assets: 16_660 + output: + # 15,160 + 1,500 = 16,660, CMS's published 2023 partial subsidy limit. + # The 2023 full subsidy limit was 9,090 + 1,500 = 10,590. + part_d_lis_resource_eligible: true + +- name: Single person at the projected 2027 limit. + period: 2027 + input: + people: + person: + bank_account_assets: 18_710 + output: + # 16,590 x 336.940 / 324.800 = 17,210.08, rounded to 17,210; plus 1,500. + part_d_lis_resource_eligible: true + +- name: Single person one dollar over the projected 2027 limit. + period: 2027 + input: + people: + person: + bank_account_assets: 18_711 + output: + part_d_lis_resource_eligible: false + +- name: Married couple at the projected 2027 couple limit. + period: 2027 + input: + people: + spouse1: + bank_account_assets: 37_360 + spouse2: + bank_account_assets: 0 + marital_units: + marital_unit: + members: [spouse1, spouse2] + output: + # 33,120 x 336.940 / 324.800 = 34,357.92, rounded to 34,360; plus 2 x + # 1,500 = 37,360. + part_d_lis_resource_eligible: [true, true] + +- name: Non-home real estate counts for the household head's couple only. + period: 2026 + input: + people: + head: + age: 70 + is_household_head: true + bank_account_assets: 20_000 + spouse: + age: 68 + bank_account_assets: 7_000 + mother: + age: 92 + bank_account_assets: 9_000 + marital_units: + couple: + members: [head, spouse] + mother_unit: + members: [mother] + households: + household: + members: [head, spouse, mother] + household_other_real_estate_equity: 10_000 + output: + # The couple: 20,000 + 7,000 + 10,000 = 37,000 > 33,120 + 3,000 = + # 36,120. The mother: 9,000 <= 16,590 + 1,500 = 18,090. + part_d_lis_resource_eligible: [false, false, true] + +- name: With two flagged heads, the older one holds the real estate. + period: 2026 + input: + people: + younger: + age: 70 + is_household_head: true + bank_account_assets: 9_000 + older: + age: 80 + is_household_head: true + bank_account_assets: 9_000 + marital_units: + younger_unit: + members: [younger] + older_unit: + members: [older] + households: + household: + members: [younger, older] + household_other_real_estate_equity: 10_000 + output: + # The younger: 9,000 <= 16,590 + 1,500 = 18,090. The older: 9,000 + + # 10,000 = 19,000 > 18,090. + part_d_lis_resource_eligible: [true, false] + +- name: Without a designated head, the oldest member holds the real estate. + period: 2026 + input: + people: + head: + age: 70 + bank_account_assets: 20_000 + spouse: + age: 68 + bank_account_assets: 7_000 + mother: + age: 92 + bank_account_assets: 9_000 + marital_units: + couple: + members: [head, spouse] + mother_unit: + members: [mother] + households: + household: + members: [head, spouse, mother] + household_other_real_estate_equity: 10_000 + output: + # The couple: 27,000 <= 36,120. The mother: 9,000 + 10,000 = 19,000 > + # 18,090. + part_d_lis_resource_eligible: [true, true, false] diff --git a/policyengine_us/tests/policy/baseline/household/healthcare_benefit_value.yaml b/policyengine_us/tests/policy/baseline/household/healthcare_benefit_value.yaml index 2b7980e1463..596087fbcfa 100644 --- a/policyengine_us/tests/policy/baseline/household/healthcare_benefit_value.yaml +++ b/policyengine_us/tests/policy/baseline/household/healthcare_benefit_value.yaml @@ -23,6 +23,16 @@ output: healthcare_benefit_value: 2_434.8 +- name: Case 2b, the Part D low-income subsidy is a healthcare benefit value. + period: 2026 + absolute_error_margin: 0.01 + input: + medicaid_cost: 0 + msp_cost: 2_434.8 + part_d_lis: 1_254 + output: + healthcare_benefit_value: 3_688.8 + - name: Case 3, enrolled CHIP value is included in healthcare benefit value. period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_us/tests/policy/baseline/household/household_health_benefits.yaml b/policyengine_us/tests/policy/baseline/household/household_health_benefits.yaml index c02d64382ee..2af124fd1bd 100644 --- a/policyengine_us/tests/policy/baseline/household/household_health_benefits.yaml +++ b/policyengine_us/tests/policy/baseline/household/household_health_benefits.yaml @@ -38,6 +38,30 @@ household_health_benefits: 4_234.8 household_benefits: 4_234.8 +- name: Case 3b, flag on includes the Part D low-income subsidy. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.simulation.include_health_benefits_in_net_income: true + medicaid_cost: 0 + msp_cost: 2_434.8 + part_d_lis: 1_254 + snap: 0 + output: + household_health_benefits: 3_688.8 + household_benefits: 3_688.8 + +- name: Case 3c, flag off leaves the Part D low-income subsidy out of benefits. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.simulation.include_health_benefits_in_net_income: false + part_d_lis: 1_254 + snap: 0 + output: + household_health_benefits: 0 + household_benefits: 0 + - name: Case 4, legacy wrappers do not double count canonical health values. period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_us/tests/policy/baseline/household/income/household/cbo_household_income.yaml b/policyengine_us/tests/policy/baseline/household/income/household/cbo_household_income.yaml index 642eb31834d..04743a4d563 100644 --- a/policyengine_us/tests/policy/baseline/household/income/household/cbo_household_income.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/household/cbo_household_income.yaml @@ -74,6 +74,8 @@ reduced_price_school_meals: 1 spm_unit_energy_subsidy: 9 general_assistance: 6 + part_d_lis: 11 + msp: 100 wic: 100 head_start: 100 early_head_start: 100 @@ -81,7 +83,9 @@ household_state_benefits: 100 household_health_benefits: 100 output: - cbo_household_means_tested_transfers: 55 + # The Medicare Part D low-income subsidy counts; Medicare Savings + # Programs do not. + cbo_household_means_tested_transfers: 66 - name: CBO after-tax income uses federal taxes only and keeps negative income tax period: 2025 @@ -110,3 +114,30 @@ cbo_household_means_tested_transfers: 11_000 cbo_household_federal_taxes: 3_252 cbo_household_income_after_transfers_and_taxes: 45_085 + +- name: CBO income after transfers adds the Part D low-income subsidy + period: 2026 + input: + age: 70 + medicare_enrolled: true + social_security: 20_000 + interest_income: 5_000 + bank_account_assets: 15_000 + state_code: TX + medicaid: 0 + chip: 0 + snap: 0 + housing_assistance: 0 + ssi: 0 + tanf: 0 + free_school_meals: 0 + reduced_price_school_meals: 0 + spm_unit_energy_subsidy: 0 + general_assistance: 0 + output: + # Eligible through the subsidy's own tests (countable income 19,760 < + # 23,940, resources 15,000 <= 18,090), valued at the 2026 Trustees + # average of $1,254 per subsidy enrollee, the only means-tested + # transfer here. + part_d_lis: 1_254 + cbo_household_means_tested_transfers: 1_254 diff --git a/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis.py b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis.py new file mode 100644 index 00000000000..b9cd12f3fbc --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis.py @@ -0,0 +1,31 @@ +from policyengine_us.model_api import * + + +class part_d_lis(Variable): + value_type = float + entity = Person + label = "Medicare Part D low-income subsidy" + documentation = ( + "Value of the Part D low-income subsidy (Extra Help): the Medicare " + "Trustees' average subsidy payment per subsidy enrollee, the same for " + "every enrollee, which is how the Congressional Budget Office values " + "it. Most of the subsidy pays cost sharing, which depends on each " + "enrollee's drug use, which the model does not have. Recipients " + "differ from CBO's: CBO draws them at random from the eligible " + "population until they match the administrative count, while the " + "model pays every eligible person in a Part D plan who is on " + "Medicaid or SSI or takes the subsidy up." + ) + unit = USD + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/42/1395w-114", + "https://www.cbo.gov/publication/61910", + "https://www.cbo.gov/system/files/2018-07/54234-workingpaper.pdf#page=46", + "https://www.cms.gov/oact/tr/2026#page=158", + ) + defined_for = "part_d_lis_enrolled" + + def formula(person, period, parameters): + p = parameters(period).calibration.gov.hhs.medicare.part_d + return person.filled_array(p.low_income_subsidy_per_recipient) diff --git a/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_countable_income.py b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_countable_income.py new file mode 100644 index 00000000000..d8c0e1faf92 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_countable_income.py @@ -0,0 +1,60 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._apply_ssi_exclusions import ( + _apply_ssi_exclusions, +) + + +class part_d_lis_countable_income(Variable): + value_type = float + entity = Person + label = "Medicare Part D low-income subsidy countable income" + documentation = ( + "Annual countable income for the Part D low-income subsidy: the " + "Supplemental Security Income definition of income, for the person " + "and a spouse living with them, without in-kind support and " + "maintenance or interest and dividends. Each unearned source counts " + "for each person at no less than zero, so a rental loss does not " + "offset other income. The $20 general, $65 earned and one-half " + "earned income exclusions apply once to the combined income. Not " + "modeled: the Social Security cost-of-living increase, which SSA " + "leaves out through the month after the new poverty guidelines are " + "published (20 CFR 418.3340(f)); the model counts the year's " + "benefits in full." + ) + unit = USD + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3_C", + "https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.772", + "https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3310", + "https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3325", + "https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3335", + "https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3340#p-418.3340(f)", + "https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3350", + "https://secure.ssa.gov/poms.nsf/lnx/0603020050", + "https://secure.ssa.gov/poms.nsf/lnx/0500830505", + ) + + def formula(person, period, parameters): + p = parameters(period) + excluded = p.gov.hhs.medicare.part_d.low_income_subsidy.income.excluded_sources + # SSI's unearned income sources without the ones the subsidy excludes, + # so a change to SSI's list carries through and an excluded source is + # never subtracted when SSI no longer counts it. + sources = [ + source + for source in p.gov.ssa.ssi.income.sources.unearned + if source not in excluded + ] + # SSI counts net rental income, and expenses above the rent do not + # offset other income (POMS SI 00830.505), so no source counts below + # zero, for either spouse. + unearned = person.filled_array(0.0) + for source in sources: + unearned = unearned + max_(person(source, period), 0) + earned = person("ssi_earned_income", period) + # A spouse's income counts whether or not the spouse applies or is + # eligible, and the exclusions apply once to the couple's income. + couple_earned = person.marital_unit.sum(earned) + couple_unearned = person.marital_unit.sum(unearned) + return _apply_ssi_exclusions(couple_earned, couple_unearned, parameters, period) diff --git a/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_deemed_eligible.py b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_deemed_eligible.py new file mode 100644 index 00000000000..2dfdc01bafd --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_deemed_eligible.py @@ -0,0 +1,24 @@ +from policyengine_us.model_api import * + + +class part_d_lis_deemed_eligible(Variable): + value_type = bool + entity = Person + label = "Deemed eligible for the Medicare Part D low-income subsidy" + documentation = ( + "Treated as eligible for the full subsidy without an income or " + "resource test: full-benefit dual eligibles (Medicaid enrollees), " + "Supplemental Security Income recipients, and people eligible for a " + "Medicare Savings Program (QMB, SLMB or QI)." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3_B_v", + "https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.773#p-423.773(c)", + ) + + def formula(person, period, parameters): + medicaid = person("medicaid_enrolled", period) + ssi = add(person, period, ["ssi"]) > 0 + msp = add(person, period, ["msp_eligible"]) > 0 + return medicaid | ssi | msp diff --git a/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_eligible.py b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_eligible.py new file mode 100644 index 00000000000..7e07fb76b40 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_eligible.py @@ -0,0 +1,30 @@ +from policyengine_us.model_api import * + + +class part_d_lis_eligible(Variable): + value_type = bool + entity = Person + label = "Eligible for the Medicare Part D low-income subsidy" + documentation = ( + "A Medicare enrollee living in the 50 states or the District of " + "Columbia who is deemed eligible or meets the income and resource " + "tests. Before 2024 people with income from 135% to 150% of the " + "poverty guideline, or resources between the lower and higher " + "limits, got a partial subsidy; this covers both." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3", + "https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.773", + "https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3101", + ) + defined_for = "medicare_enrolled" + + def formula(person, period, parameters): + p = parameters(period).gov.hhs.medicare.part_d.low_income_subsidy.eligibility + state_code = person.household("state_code_str", period) + in_state = ~np.isin(state_code, p.ineligible_jurisdictions) + deemed = person("part_d_lis_deemed_eligible", period) + income_eligible = person("part_d_lis_income_eligible", period) + resource_eligible = person("part_d_lis_resource_eligible", period) + return in_state & (deemed | (income_eligible & resource_eligible)) diff --git a/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_enrolled.py b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_enrolled.py new file mode 100644 index 00000000000..275b23a1661 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_enrolled.py @@ -0,0 +1,42 @@ +from policyengine_us.model_api import * + + +class part_d_lis_enrolled(Variable): + value_type = bool + entity = Person + label = "Enrolled in the Medicare Part D low-income subsidy" + documentation = ( + "Medicare pays the subsidy only for people enrolled in a Part D plan " + "(medicare_part_d_enrolled). Medicaid enrollees and Supplemental " + "Security Income recipients get the subsidy without applying; other " + "eligible people in a plan get it if they take it up. CMS enrolls " + "subsidy-eligible people who don't choose a plan, and any of them " + "can decline (42 CFR 423.34(d)(1) and (e)). Qualifying covered " + "retirees, whose employer retiree drug coverage qualifies for the " + "retiree drug subsidy, are the exception: CMS enrolls only the " + "full-benefit dual eligibles among them whose plan sponsor it " + "approved for that subsidy, and only if they confirm that they want " + "a plan (423.34(d)(3)). So microdata that set " + "takes_up_medicare_part_d_if_eligible should set it true for " + "Medicaid enrollees and SSI recipients other than those who decline " + "and covered retirees who don't choose a plan; a person whose Part D " + "input is false gets no subsidy. " + "Not modeled: people deemed eligible from July to December stay " + "deemed eligible through the following year (423.773(c)(2)(ii)); the " + "model tests each year on its own." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3_A", + "https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.773#p-423.773(c)(2)", + "https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3101", + "https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-B/section-423.34#p-423.34(d)", + ) + defined_for = "part_d_lis_eligible" + + def formula(person, period, parameters): + in_part_d = person("medicare_part_d_enrolled", period) + medicaid = person("medicaid_enrolled", period) + ssi = add(person, period, ["ssi"]) > 0 + takes_up = person("takes_up_part_d_lis_if_eligible", period) + return in_part_d & (medicaid | ssi | takes_up) diff --git a/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_family_size.py b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_family_size.py new file mode 100644 index 00000000000..c33a393542e --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_family_size.py @@ -0,0 +1,28 @@ +from policyengine_us.model_api import * + + +class part_d_lis_family_size(Variable): + value_type = int + entity = Person + label = "Medicare Part D low-income subsidy family size" + documentation = ( + "The person, a spouse living with them, and relatives living with " + "them who depend on the person or spouse for at least half of their " + "support, approximated by the dependents on the couple's tax " + "returns: a joint return's dependents count once, and when the " + "spouses file separately, the dependents on both returns count." + ) + definition_period = YEAR + reference = ( + "https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.772", + "https://secure.ssa.gov/poms.nsf/lnx/0603020055", + ) + + def formula(person, period, parameters): + couple_size = person.marital_unit.nb_persons() + # Each tax unit's dependents count once, through the unit's head: on a + # joint return the couple has one head, and spouses who file + # separately each head their own return. + head = person("is_tax_unit_head", period) + dependents = person.tax_unit("tax_unit_dependents", period) + return couple_size + person.marital_unit.sum(where(head, dependents, 0)) diff --git a/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_income_eligible.py b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_income_eligible.py new file mode 100644 index 00000000000..37e1475241a --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_income_eligible.py @@ -0,0 +1,23 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.hhs.tax_unit_fpg import fpg + + +class part_d_lis_income_eligible(Variable): + value_type = bool + entity = Person + label = "Income eligible for the Medicare Part D low-income subsidy" + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3_A_ii", + "https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.773#p-423.773(a)(1)", + "https://secure.ssa.gov/poms.nsf/lnx/0603020055", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.hhs.medicare.part_d.low_income_subsidy.eligibility + family_size = person("part_d_lis_family_size", period) + state_group = person.household("state_group_str", period) + income_limit = p.income_limit * fpg( + family_size, state_group, period, parameters + ) + return person("part_d_lis_countable_income", period) < income_limit diff --git a/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_resource_eligible.py b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_resource_eligible.py new file mode 100644 index 00000000000..16f82e95acc --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_resource_eligible.py @@ -0,0 +1,49 @@ +from policyengine_us.model_api import * + + +class part_d_lis_resource_eligible(Variable): + value_type = bool + entity = Person + label = "Resource eligible for the Medicare Part D low-income subsidy" + documentation = ( + "Countable resources of the person and a spouse living with them do " + "not exceed the individual or couple limit plus the burial funds " + "exclusion for each of them. Resources are the couple's financial " + "assets and, for the household head's couple, the household's " + "equity in real estate other than the home." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3_E", + "https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.773#p-423.773(d)(2)", + "https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3405", + "https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3410", + "https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3425", + "https://secure.ssa.gov/poms.nsf/lnx/0603030025", + ) + + def formula(person, period, parameters): + p = parameters( + period + ).gov.hhs.medicare.part_d.low_income_subsidy.eligibility.resources + own = add(person, period, p.sources) + # A household amount does not say whose it is. It counts in full for + # the marital unit of the household head (the oldest member when no + # head is designated), so it counts for one couple. + is_household_head = person("is_household_head", period) + has_household_head = person.household.any(is_household_head) + age = person("age", period) + holds_household_amount = where( + has_household_head, + person.get_rank(person.household, -age, condition=is_household_head) == 0, + person.get_rank(person.household, -age) == 0, + ) + household_amount = person.filled_array(0.0) + for source in p.household_sources: + household_amount = household_amount + person.household(source, period) + resources = person.marital_unit.sum( + own + holds_household_amount * household_amount + ) + couple_size = person.marital_unit.nb_persons() + limit = where(couple_size == 2, p.limit.couple, p.limit.individual) + return resources <= limit + p.burial_exclusion * couple_size diff --git a/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/takes_up_part_d_lis_if_eligible.py b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/takes_up_part_d_lis_if_eligible.py new file mode 100644 index 00000000000..3d4e8172bd7 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/takes_up_part_d_lis_if_eligible.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * + + +class takes_up_part_d_lis_if_eligible(Variable): + value_type = bool + entity = Person + label = ( + "Whether an eligible person enrolls in the Medicare Part D low-income subsidy" + ) + documentation = ( + "Whether an eligible person who is neither a Medicaid enrollee nor a " + "Supplemental Security Income recipient applies for the subsidy. " + "Medicare Savings Program enrollees are deemed eligible, but the " + "model has Medicare Savings Program eligibility, not enrollment, so " + "this flag covers them too. The subsidy also requires enrollment in " + "a Part D plan (medicare_part_d_enrolled), which CMS arranges for " + "subsidy-eligible people who don't choose one unless they decline " + "(42 CFR 423.34). The default, true, takes everyone up; microdata " + "can set a take-up rate." + ) + definition_period = YEAR + default_value = True diff --git a/policyengine_us/variables/household/healthcare_benefit_value.py b/policyengine_us/variables/household/healthcare_benefit_value.py index 0f68d98f3e9..bf7731ee3d5 100644 --- a/policyengine_us/variables/household/healthcare_benefit_value.py +++ b/policyengine_us/variables/household/healthcare_benefit_value.py @@ -11,11 +11,14 @@ class healthcare_benefit_value(Variable): "Annual canonical household resource value of health coverage. This " "uses annual health-value and government-cost proxy variables directly, " "including assigned_aca_ptc for ACA premium tax credits. CHIP is counted " - "through `chip`, which is gated on eligibility, take-up, and enrollment." + "through `chip`, which is gated on eligibility, take-up, and enrollment. " + "The Medicare Part D low-income subsidy counts through `part_d_lis`; " + "Medicare's own spending (`medicare_cost`) is not included." ) adds = [ "medicaid_cost", "msp_cost", + "part_d_lis", "chip", "assigned_aca_ptc", "assigned_ca_premium_subsidy", diff --git a/source_conflicts/candidates.yaml b/source_conflicts/candidates.yaml index 6b1dbcbedc4..23a95750581 100644 --- a/source_conflicts/candidates.yaml +++ b/source_conflicts/candidates.yaml @@ -11236,6 +11236,47 @@ candidates: state: unreviewed note: 'Main follows the form-line omission; the statute-first rule requires the statutory add-back proposed in the open PR. Code not yet on main: excess_business_loss, net_investment_income_excess_business_loss, gov.irs.investment.income.sources.' +- id: us:part-d-lis-couple-resource-limit-2015 + program: medicare-part-d-low-income-subsidy + years: + from: 2015 + type: calendar + question: Is the 2015 couple resource limit for the Part D low-income subsidy $24,260, the statute's formula applied + to the 2014 limit, or the $24,250 that CMS and SSA published; and does each later limit build on the formula's + amount or on the published one? + sources: + - kind: statute + citation: 42 U.S.C. 1395w-114(a)(3)(E)(i) + url: https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3_E_i + - kind: regulation + citation: 42 CFR 423.773(d)(2)(ii) + url: https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.773#p-423.773(d)(2)(ii) + - kind: guidance + citation: CMS, 2015 Medicare Part D Low-Income Subsidy (LIS) Income and Resource Standards, Attachment 4, PDF pp. 5-6 + url: https://www.cms.gov/files/document/2015-lis-income-and-resourcespdf#page=5 + - kind: manual + citation: SSA POMS HI 03030.025, resource limits for prior years + url: https://secure.ssa.gov/poms.nsf/lnx/0603030025 + readings: + statute: The 2014 limit of $23,860 increased by the September 2013 to September 2014 CPI-U change (238.031 / + 234.149) is $24,255.58, which rounds to $24,260. Each later year raises the previous year's limit as the formula + sets it, giving $24,620 in 2017 and $33,120 in 2026. + other: CMS's memo gives the 1.01658 factor and the rounding to $10 but prints $24,250, and POMS lists $24,250 for + 2015 and 2016. SSA raises its own published amount each later year, giving $24,600 in 2017 and $33,100 in 2026. + example: In 2026 a married couple with $36,110 of resources, $3,000 of them set aside for burial, has $33,110 of + countable resources, within the statutory limit of $33,120 but over SSA's $33,100. + pe_us: + follows: statute + code: + - policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/limit/couple.yaml + prs: + - https://github.com/PolicyEngine/policyengine-us/pull/10127 + state: ruled + note: Every other higher (partial subsidy, then full subsidy) limit SSA published from 2007 to 2026 equals the + formula applied to the previous published limit, and the memo's 1.01658 factor reproduces its other 2015 amounts + ($7,280, $10,930 and $12,140), so the $24,250 is the one cell the stated method doesn't produce. The model applies the formula as the + statute and 423.773(d)(2)(ii) state it, from the statute's 2006 amount of $20,000. The difference reaches only + couples whose countable resources fall within $10 to $20 of the limit. - id: us:recovery-rebate-joint-claimable-spouse program: recovery-rebate-credit years: