diff --git a/policyengine_us/parameters/gov/census/index.yaml b/policyengine_us/parameters/gov/census/index.yaml new file mode 100644 index 00000000000..2852e302ad0 --- /dev/null +++ b/policyengine_us/parameters/gov/census/index.yaml @@ -0,0 +1,4 @@ +metadata: + propagate_metadata_to_children: true + economy: false + household: false diff --git a/policyengine_us/parameters/gov/census/spm/index.yaml b/policyengine_us/parameters/gov/census/spm/index.yaml new file mode 100644 index 00000000000..2852e302ad0 --- /dev/null +++ b/policyengine_us/parameters/gov/census/spm/index.yaml @@ -0,0 +1,4 @@ +metadata: + propagate_metadata_to_children: true + economy: false + household: false diff --git a/policyengine_us/parameters/gov/census/spm/work_expense/index.yaml b/policyengine_us/parameters/gov/census/spm/work_expense/index.yaml new file mode 100644 index 00000000000..2852e302ad0 --- /dev/null +++ b/policyengine_us/parameters/gov/census/spm/work_expense/index.yaml @@ -0,0 +1,4 @@ +metadata: + propagate_metadata_to_children: true + economy: false + household: false diff --git a/policyengine_us/parameters/gov/census/spm/work_expense/weekly_amount.yaml b/policyengine_us/parameters/gov/census/spm/work_expense/weekly_amount.yaml new file mode 100644 index 00000000000..e54dbe89dc1 --- /dev/null +++ b/policyengine_us/parameters/gov/census/spm/work_expense/weekly_amount.yaml @@ -0,0 +1,31 @@ +description: Weekly work-expense deduction amount used in Census Supplemental Poverty Measure calculations. +values: + 2009-01-01: 28.0500 + 2010-01-01: 25.5000 + 2011-01-01: 27.1575 + 2012-01-01: 33.0225 + 2013-01-01: 39.3975 + 2014-01-01: 39.2530 + 2015-01-01: 40.0945 + 2016-01-01: 38.4710 + 2017-01-01: 36.3460 + 2018-01-01: 37.1025 + 2019-01-01: 39.7205 + 2020-01-01: 39.6100 + 2021-01-01: 38.0630 + 2022-01-01: 31.0080 + 2023-01-01: 33.4050 + 2024-01-01: 34.9945 + +metadata: + unit: currency-USD + period: year + uprating: gov.bls.cpi.cpi_u + label: Census SPM weekly work-expense deduction amount + reference: + - title: Supplemental Poverty Measure (SPM) Technical Documentation + href: https://www2.census.gov/programs-surveys/supplemental-poverty-measure/technical-documentation/spm_techdoc.pdf + - title: Poverty in the United States 2023 + href: https://www2.census.gov/library/publications/2024/demo/p60-283.pdf + - title: Poverty in the United States 2024 + href: https://www2.census.gov/library/publications/2025/demo/p60-287.pdf diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/income_adjusted_part_b_premium.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/income_adjusted_part_b_premium.yaml index 22c5dedfdbc..edb20958203 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/income_adjusted_part_b_premium.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/income_adjusted_part_b_premium.yaml @@ -15,7 +15,7 @@ period: 2025 input: age: 65 - filing_status: JOINT + tax_unit_married: true adjusted_gross_income: 2023: 266_001 # IRMAA uses income from 2 years prior tax_exempt_interest_income: @@ -54,7 +54,7 @@ period: 2025 input: age: 65 - filing_status: JOINT + tax_unit_married: true adjusted_gross_income: 2023: 200_000 # IRMAA uses income from 2 years prior tax_exempt_interest_income: @@ -67,7 +67,7 @@ period: 2025 input: age: 65 - filing_status: JOINT + tax_unit_married: true adjusted_gross_income: 2023: 1_000_000 # IRMAA uses income from 2 years prior tax_exempt_interest_income: diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/msp_part_b_premium_coverage.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/msp_part_b_premium_coverage.yaml new file mode 100644 index 00000000000..0c450945ee9 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/msp_part_b_premium_coverage.yaml @@ -0,0 +1,29 @@ +- name: MSP part B coverage pays the standard premium for income- and asset-eligible enrollees + period: 2025 + input: + medicare_enrolled: true + msp_income_eligible: true + msp_asset_eligible: true + base_part_b_premium: 2_220 + output: + msp_part_b_premium_coverage: 2_220 + +- name: MSP part B coverage is zero for ineligible enrollees + period: 2025 + input: + medicare_enrolled: true + msp_income_eligible: false + msp_asset_eligible: true + base_part_b_premium: 2_220 + output: + msp_part_b_premium_coverage: 0 + +- name: MSP part B coverage is zero when not enrolled + period: 2025 + input: + medicare_enrolled: false + msp_income_eligible: true + msp_asset_eligible: true + base_part_b_premium: 2_220 + output: + msp_part_b_premium_coverage: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_part_b_msp_offset.py b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_part_b_msp_offset.py new file mode 100644 index 00000000000..b08e43e99e7 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_part_b_msp_offset.py @@ -0,0 +1,88 @@ +import pytest + +from policyengine_us import CountryTaxBenefitSystem, Simulation + + +SYSTEM = CountryTaxBenefitSystem() +PERIOD = "2025" + + +def make_simulation( + *, + medicare_enrolled: bool, + gross_part_b_premium: float, + base_part_b_premium: float, + msp_income_eligible: bool, + msp_asset_eligible: bool, +) -> Simulation: + return Simulation( + tax_benefit_system=SYSTEM, + situation={ + "people": { + "person": { + "age": {PERIOD: 65}, + "medicare_enrolled": {PERIOD: medicare_enrolled}, + "income_adjusted_part_b_premium": {PERIOD: gross_part_b_premium}, + "base_part_b_premium": {PERIOD: base_part_b_premium}, + "msp_income_eligible": {f"{PERIOD}-01": msp_income_eligible}, + "msp_asset_eligible": {f"{PERIOD}-01": msp_asset_eligible}, + } + }, + "households": {"household": {"members": ["person"]}}, + "tax_units": {"tax_unit": {"members": ["person"]}}, + "spm_units": {"spm_unit": {"members": ["person"]}}, + "families": {"family": {"members": ["person"]}}, + "marital_units": {"marital_unit": {"members": ["person"]}}, + }, + ) + + +def test_msp_part_b_premium_coverage_pays_standard_premium(): + sim = make_simulation( + medicare_enrolled=True, + gross_part_b_premium=4_440, + base_part_b_premium=2_220, + msp_income_eligible=True, + msp_asset_eligible=True, + ) + + assert sim.calculate("msp_part_b_premium_coverage", PERIOD)[0] == pytest.approx( + 2_220 + ) + + +def test_medicare_part_b_premiums_preserve_only_irmaa_above_msp_support(): + sim = make_simulation( + medicare_enrolled=True, + gross_part_b_premium=4_440, + base_part_b_premium=2_220, + msp_income_eligible=True, + msp_asset_eligible=True, + ) + + assert sim.calculate("medicare_part_b_premiums", PERIOD)[0] == pytest.approx(2_220) + + +def test_medicare_part_b_premiums_are_zero_when_msp_covers_standard_premium(): + sim = make_simulation( + medicare_enrolled=True, + gross_part_b_premium=2_220, + base_part_b_premium=2_220, + msp_income_eligible=True, + msp_asset_eligible=True, + ) + + assert sim.calculate("medicare_part_b_premiums", PERIOD)[0] == pytest.approx(0) + + +def test_medicare_part_b_premiums_are_zero_when_not_enrolled(): + sim = make_simulation( + medicare_enrolled=False, + gross_part_b_premium=2_220, + base_part_b_premium=2_220, + msp_income_eligible=True, + msp_asset_eligible=True, + ) + + assert sim.calculate("msp_part_b_premium_coverage", PERIOD)[0] == pytest.approx(0) + assert sim.calculate("medicare_part_b_premiums", PERIOD)[0] == pytest.approx(0) diff --git a/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_unit_capped_work_childcare_expenses.yaml b/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_unit_capped_work_childcare_expenses.yaml new file mode 100644 index 00000000000..d9bbafe41ea --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_unit_capped_work_childcare_expenses.yaml @@ -0,0 +1,107 @@ +- name: Combined work and childcare expenses are capped at the lower earner + period: 2024 + absolute_error_margin: 0.001 + input: + people: + head: + age: 35 + weeks_worked: 10 + employment_income: 40_000 + is_household_head: true + is_tax_unit_head: true + spouse: + age: 33 + weeks_worked: 20 + employment_income: 2_000 + is_tax_unit_spouse: true + child: + age: 8 + tax_units: + tax_unit: + members: [head, spouse, child] + spm_units: + spm_unit: + members: [head, spouse, child] + spm_unit_pre_subsidy_childcare_expenses: 2_000 + output: + spm_unit_capped_work_childcare_expenses: 2_000 + +- name: Work expenses are preserved when they already exceed the earnings cap + period: 2024 + absolute_error_margin: 0.001 + input: + people: + head: + age: 41 + weeks_worked: 52 + employment_income: 1_500 + is_household_head: true + is_tax_unit_head: true + child: + age: 5 + tax_units: + tax_unit: + members: [head, child] + spm_units: + spm_unit: + members: [head, child] + spm_unit_pre_subsidy_childcare_expenses: 3_000 + output: + spm_unit_capped_work_childcare_expenses: 1_819.714 + +- name: Childcare is capped by the remaining lower-earner earnings after work expenses + period: 2024 + absolute_error_margin: 0.001 + input: + people: + head: + age: 46 + weeks_worked: 52 + employment_income: 9_098.57 + is_household_head: true + is_tax_unit_head: true + spouse: + age: 46 + weeks_worked: 52 + employment_income: 30_000 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + spm_units: + spm_unit: + members: [head, spouse] + spm_unit_pre_subsidy_childcare_expenses: 21_580 + output: + spm_unit_capped_work_childcare_expenses: 9_098.57 + +- name: Unmarried partner counts toward the reference person's remaining childcare cap + period: 2024 + absolute_error_margin: 0.001 + input: + people: + head: + age: 35 + weeks_worked: 10 + employment_income: 40_000 + is_household_head: true + is_tax_unit_head: true + partner: + age: 33 + weeks_worked: 20 + employment_income: 2_000 + is_unmarried_partner_of_household_head: true + is_tax_unit_head: true + child: + age: 8 + tax_units: + reference_unit: + members: [head, child] + partner_unit: + members: [partner] + spm_units: + spm_unit: + members: [head, partner, child] + spm_unit_pre_subsidy_childcare_expenses: 2_000 + output: + spm_unit_capped_work_childcare_expenses: 2_000 diff --git a/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_unit_head_spouse_earned_cap.yaml b/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_unit_head_spouse_earned_cap.yaml new file mode 100644 index 00000000000..966a3e49b63 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_unit_head_spouse_earned_cap.yaml @@ -0,0 +1,163 @@ +- name: Household head and spouse determine the SPM childcare earnings cap + period: 2024 + absolute_error_margin: 0.001 + input: + people: + reference_person: + age: 35 + employment_income: 40_000 + is_household_head: true + is_tax_unit_spouse: true + spouse: + age: 45 + employment_income: 8_000 + is_tax_unit_head: true + child: + age: 8 + tax_units: + tax_unit: + members: [reference_person, spouse, child] + spm_units: + spm_unit: + members: [reference_person, spouse, child] + output: + spm_unit_head_spouse_earned_cap: 8_000 + +- name: Household head and unmarried partner determine the SPM childcare earnings cap + period: 2024 + absolute_error_margin: 0.001 + input: + people: + reference_person: + age: 35 + employment_income: 40_000 + is_household_head: true + is_tax_unit_head: true + partner: + age: 33 + employment_income: 2_500 + is_unmarried_partner_of_household_head: true + is_tax_unit_head: true + child: + age: 8 + tax_units: + reference_unit: + members: [reference_person, child] + partner_unit: + members: [partner] + spm_units: + spm_unit: + members: [reference_person, partner, child] + output: + spm_unit_head_spouse_earned_cap: 2_500 + +- name: Farm income counts toward the Census SPM childcare earnings cap + period: 2024 + absolute_error_margin: 0.001 + input: + people: + reference_person: + age: 35 + employment_income: 40_000 + is_household_head: true + is_tax_unit_head: true + spouse: + age: 33 + farm_income: 3_000 + is_tax_unit_spouse: true + child: + age: 8 + tax_units: + tax_unit: + members: [reference_person, spouse, child] + spm_units: + spm_unit: + members: [reference_person, spouse, child] + output: + spm_unit_head_spouse_earned_cap: 3_000 + +- name: Extra adults in the SPM unit do not expand the reference person's childcare cap + period: 2024 + absolute_error_margin: 0.001 + input: + people: + reference_person: + age: 35 + employment_income: 40_000 + is_household_head: true + is_tax_unit_head: true + partner: + age: 33 + employment_income: 5_000 + is_unmarried_partner_of_household_head: true + is_tax_unit_head: true + unrelated_adult: + age: 55 + employment_income: 1_000 + is_tax_unit_head: true + child: + age: 8 + tax_units: + reference_unit: + members: [reference_person, child] + partner_unit: + members: [partner] + unrelated_unit: + members: [unrelated_adult] + spm_units: + spm_unit: + members: [reference_person, partner, unrelated_adult, child] + output: + spm_unit_head_spouse_earned_cap: 5_000 + +- name: Spouse takes precedence over unmarried partner in the reference person's childcare cap + period: 2024 + absolute_error_margin: 0.001 + input: + people: + reference_person: + age: 35 + employment_income: 40_000 + is_household_head: true + is_tax_unit_head: true + spouse: + age: 33 + employment_income: 6_000 + is_tax_unit_spouse: true + partner: + age: 31 + employment_income: 2_500 + is_unmarried_partner_of_household_head: true + is_tax_unit_head: true + tax_units: + reference_unit: + members: [reference_person, spouse] + partner_unit: + members: [partner] + spm_units: + spm_unit: + members: [reference_person, spouse, partner] + output: + spm_unit_head_spouse_earned_cap: 6_000 + +- name: The childcare cap falls back to tax-unit head/spouse when no reference person is provided + period: 2024 + absolute_error_margin: 0.001 + input: + people: + head: + age: 35 + employment_income: 40_000 + is_tax_unit_head: true + spouse: + age: 33 + employment_income: 4_000 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + spm_units: + spm_unit: + members: [head, spouse] + output: + spm_unit_head_spouse_earned_cap: 4_000 diff --git a/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_unit_work_expenses.yaml b/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_unit_work_expenses.yaml new file mode 100644 index 00000000000..d3c7c74d672 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/expense/childcare/spm_unit_work_expenses.yaml @@ -0,0 +1,41 @@ +- name: Work expenses apply only to adult earners in the SPM unit + period: 2024 + absolute_error_margin: 0.001 + input: + people: + adult_worker: + age: 40 + weeks_worked: 10 + employment_income: 20_000 + adult_nonworker: + age: 38 + weeks_worked: 30 + employment_income: 0 + child: + age: 12 + weeks_worked: 52 + employment_income: 500 + spm_units: + spm_unit: + members: [adult_worker, adult_nonworker, child] + output: + spm_unit_work_expenses: 349.945 + +- name: Farm-income-only adults still incur Census SPM work expenses + period: 2024 + absolute_error_margin: 0.001 + input: + people: + adult_farmer: + age: 40 + weeks_worked: 10 + farm_income: 20_000 + adult_nonworker: + age: 38 + weeks_worked: 30 + farm_income: 0 + spm_units: + spm_unit: + members: [adult_farmer, adult_nonworker] + output: + spm_unit_work_expenses: 349.945 diff --git a/policyengine_us/tests/policy/baseline/household/expense/health/medicare_part_b_premiums.yaml b/policyengine_us/tests/policy/baseline/household/expense/health/medicare_part_b_premiums.yaml new file mode 100644 index 00000000000..ea3748cee5a --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/expense/health/medicare_part_b_premiums.yaml @@ -0,0 +1,35 @@ +- name: Medicare Part B premiums equal modeled premium when enrolled + period: 2025 + input: + medicare_enrolled: true + income_adjusted_part_b_premium: 4_440 + msp_part_b_premium_coverage: 0 + output: + medicare_part_b_premiums: 4_440 + +- name: Medicare Part B premiums are fully offset when MSP covers the standard premium + period: 2025 + input: + medicare_enrolled: true + income_adjusted_part_b_premium: 2_220 + msp_part_b_premium_coverage: 2_220 + output: + medicare_part_b_premiums: 0 + +- name: Medicare Part B premiums preserve IRMAA above the MSP-covered standard premium + period: 2025 + input: + medicare_enrolled: true + income_adjusted_part_b_premium: 4_440 + msp_part_b_premium_coverage: 2_220 + output: + medicare_part_b_premiums: 2_220 + +- name: Medicare Part B premiums are zero when not enrolled + period: 2025 + input: + medicare_enrolled: false + income_adjusted_part_b_premium: 2_220 + msp_part_b_premium_coverage: 0 + output: + medicare_part_b_premiums: 0 diff --git a/policyengine_us/tests/policy/baseline/household/income/person/weeks_worked.yaml b/policyengine_us/tests/policy/baseline/household/income/person/weeks_worked.yaml new file mode 100644 index 00000000000..ea615f3cea6 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/income/person/weeks_worked.yaml @@ -0,0 +1,16 @@ +- name: Weeks worked is an input variable + period: 2024 + input: + weeks_worked: 40 + output: + weeks_worked: 40 + +- name: Weeks worked carries forward when future data is missing + period: 2025 + input: + people: + person: + weeks_worked: + 2024: 37 + output: + weeks_worked: 37 diff --git a/policyengine_us/tools/default_uprating.py b/policyengine_us/tools/default_uprating.py index a69922ba8fe..bbc91088c77 100644 --- a/policyengine_us/tools/default_uprating.py +++ b/policyengine_us/tools/default_uprating.py @@ -60,7 +60,6 @@ "spm_unit_spm_threshold", "non_sch_d_capital_gains", "spm_unit_state_tax_reported", - "spm_unit_capped_work_childcare_expenses", "farm_income", "taxable_403b_distributions", "qualified_tuition_expenses", @@ -103,7 +102,7 @@ "strike_benefits", "other_medical_expenses", "over_the_counter_health_expenses", - "medicare_part_b_premiums", + "medicare_part_b_premiums_reported", "health_insurance_premiums_without_medicare_part_b", ] diff --git a/policyengine_us/variables/gov/hhs/medicare/eligibility/part_b/income_adjusted_part_b_premium.py b/policyengine_us/variables/gov/hhs/medicare/eligibility/part_b/income_adjusted_part_b_premium.py index 0a857e02654..006e0d9adaa 100644 --- a/policyengine_us/variables/gov/hhs/medicare/eligibility/part_b/income_adjusted_part_b_premium.py +++ b/policyengine_us/variables/gov/hhs/medicare/eligibility/part_b/income_adjusted_part_b_premium.py @@ -13,7 +13,8 @@ class income_adjusted_part_b_premium(Variable): def formula(person, period, parameters): tax_unit = person.tax_unit - filing_status = tax_unit("filing_status", period) + is_joint = tax_unit("tax_unit_married", period) + is_separated = tax_unit.any(person("is_separated", period)) # Medicare Part B IRMAA is based on MAGI from 2 years prior # MAGI = AGI + tax-exempt interest prior_period = period.offset(-2, "year") @@ -22,28 +23,18 @@ def formula(person, period, parameters): magi = agi + tax_exempt_interest base = person("base_part_b_premium", period) - # Build boolean masks for each status - status = filing_status.possible_values - statuses = [ - status.SINGLE, - status.JOINT, - status.HEAD_OF_HOUSEHOLD, - status.SURVIVING_SPOUSE, - status.SEPARATE, - ] - in_status = [filing_status == s for s in statuses] - p = parameters(period).gov.hhs.medicare.part_b.irmaa irmaa_amount = select( - in_status, [ - p.single.calc(magi), + is_joint, + is_separated, + ], + [ p.joint.calc(magi), - p.head_of_household.calc(magi), - p.surviving_spouse.calc(magi), p.separate.calc(magi), ], + default=p.single.calc(magi), ) # IRMAA amounts are monthly, multiply by MONTHS_IN_YEAR to get annual diff --git a/policyengine_us/variables/gov/hhs/medicare/savings_programs/msp_part_b_premium_coverage.py b/policyengine_us/variables/gov/hhs/medicare/savings_programs/msp_part_b_premium_coverage.py new file mode 100644 index 00000000000..b0a717a4b4e --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/savings_programs/msp_part_b_premium_coverage.py @@ -0,0 +1,33 @@ +from policyengine_us.model_api import * + + +class msp_part_b_premium_coverage(Variable): + value_type = float + entity = Person + unit = USD + label = "Medicare Part B premium amount covered by MSP" + definition_period = YEAR + reference = ( + "https://www.medicare.gov/basics/costs/help/medicare-savings-programs", + ) + documentation = """ + Annual standard Part B premium amount paid on the enrollee's behalf through a + Medicare Savings Program-like pathway. + + This uses the MSP income and asset rules directly and intentionally avoids the + modeled Medicaid exclusion used in QI eligibility because that path reaches the + medically needy Medicaid formula, which depends on medical_out_of_pocket_expenses + and would create a cycle in SPM MOOP calculations. + + The coverage amount is capped at the standard Part B premium. Any IRMAA amount + above the standard premium remains the enrollee's responsibility. + """ + + def formula(person, period, parameters): + first_month = period.first_month + enrolled = person("medicare_enrolled", period) + income_eligible = person("msp_income_eligible", first_month) + asset_eligible = person("msp_asset_eligible", first_month) + covered_standard_premium = person("base_part_b_premium", period) + eligible_for_coverage = enrolled & income_eligible & asset_eligible + return where(eligible_for_coverage, covered_standard_premium, 0) diff --git a/policyengine_us/variables/household/demographic/person/is_unmarried_partner_of_household_head.py b/policyengine_us/variables/household/demographic/person/is_unmarried_partner_of_household_head.py new file mode 100644 index 00000000000..e8d0f44cebc --- /dev/null +++ b/policyengine_us/variables/household/demographic/person/is_unmarried_partner_of_household_head.py @@ -0,0 +1,8 @@ +from policyengine_us.model_api import * + + +class is_unmarried_partner_of_household_head(Variable): + value_type = bool + entity = Person + label = "is unmarried partner of household head" + definition_period = YEAR diff --git a/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py b/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py index a1dfaf5d349..619b7f76673 100644 --- a/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py +++ b/policyengine_us/variables/household/expense/childcare/spm_unit_capped_work_childcare_expenses.py @@ -7,4 +7,14 @@ class spm_unit_capped_work_childcare_expenses(Variable): label = "SPM unit work and childcare expenses" definition_period = YEAR unit = USD - uprating = "gov.bls.cpi.cpi_u" + + def formula_2024(spm_unit, period, parameters): + work_expenses = spm_unit("spm_unit_work_expenses", period) + childcare_expenses = spm_unit( + "spm_unit_pre_subsidy_childcare_expenses", period + ) + earned_cap = spm_unit("spm_unit_head_spouse_earned_cap", period) + remaining_childcare_cap = np.maximum(earned_cap - work_expenses, 0) + return work_expenses + min_( + np.maximum(childcare_expenses, 0), remaining_childcare_cap + ) diff --git a/policyengine_us/variables/household/expense/childcare/spm_unit_head_spouse_earned_cap.py b/policyengine_us/variables/household/expense/childcare/spm_unit_head_spouse_earned_cap.py new file mode 100644 index 00000000000..3117292a487 --- /dev/null +++ b/policyengine_us/variables/household/expense/childcare/spm_unit_head_spouse_earned_cap.py @@ -0,0 +1,42 @@ +from policyengine_us.model_api import * + + +class spm_unit_head_spouse_earned_cap(Variable): + value_type = float + entity = SPMUnit + label = "SPM unit lower-earner cap for the reference person and spouse/partner" + definition_period = YEAR + unit = USD + + def formula(spm_unit, period, parameters): + person = spm_unit.members + is_reference_person = person("is_household_head", period) + has_reference_person = spm_unit.any(is_reference_person) + is_head_or_spouse = person("is_tax_unit_head_or_spouse", period) + is_reference_person_spouse = ( + person.tax_unit.any(is_reference_person) + & is_head_or_spouse + & ~is_reference_person + ) + is_reference_person_partner = person( + "is_unmarried_partner_of_household_head", period + ) + has_spouse = spm_unit.any(is_reference_person_spouse) + eligible_reference_person_or_partner = ( + is_reference_person + | is_reference_person_spouse + | (~has_spouse & is_reference_person_partner) + ) + eligible_people = where( + has_reference_person, + eligible_reference_person_or_partner, + is_head_or_spouse, + ) + earned_income = person("spm_work_childcare_earnings", period) + eligible_earnings = eligible_people * np.maximum(earned_income, 0) + + count_head_or_spouse = spm_unit.sum(eligible_people) + total_earned = spm_unit.sum(eligible_earnings) + max_earned = spm_unit.max(eligible_earnings) + + return where(count_head_or_spouse > 1, total_earned - max_earned, total_earned) diff --git a/policyengine_us/variables/household/expense/childcare/spm_unit_work_expenses.py b/policyengine_us/variables/household/expense/childcare/spm_unit_work_expenses.py new file mode 100644 index 00000000000..6ed82434f9c --- /dev/null +++ b/policyengine_us/variables/household/expense/childcare/spm_unit_work_expenses.py @@ -0,0 +1,21 @@ +from policyengine_us.model_api import * + + +class spm_unit_work_expenses(Variable): + value_type = float + entity = SPMUnit + label = "SPM unit work expenses" + definition_period = YEAR + unit = USD + + def formula(spm_unit, period, parameters): + person = spm_unit.members + weeks_worked = person("weeks_worked", period) + is_adult = person("is_adult", period) + earned_income = person("spm_work_childcare_earnings", period) + weekly_amount = parameters(period).gov.census.spm.work_expense.weekly_amount + + eligible_weeks = is_adult * (earned_income > 0) * np.clip( + weeks_worked, 0, 52 + ) + return spm_unit.sum(eligible_weeks) * weekly_amount diff --git a/policyengine_us/variables/household/expense/childcare/spm_work_childcare_earnings.py b/policyengine_us/variables/household/expense/childcare/spm_work_childcare_earnings.py new file mode 100644 index 00000000000..382d3cb015d --- /dev/null +++ b/policyengine_us/variables/household/expense/childcare/spm_work_childcare_earnings.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * + + +class spm_work_childcare_earnings(Variable): + value_type = float + entity = Person + label = "earnings relevant to Census SPM work and childcare expense caps" + definition_period = YEAR + unit = USD + + adds = [ + "employment_income", + "self_employment_income", + "farm_income", + ] diff --git a/policyengine_us/variables/household/expense/health/medicare_part_b_premiums.py b/policyengine_us/variables/household/expense/health/medicare_part_b_premiums.py index 90bfde15ad8..59a023b2ebd 100644 --- a/policyengine_us/variables/household/expense/health/medicare_part_b_premiums.py +++ b/policyengine_us/variables/household/expense/health/medicare_part_b_premiums.py @@ -7,4 +7,9 @@ class medicare_part_b_premiums(Variable): label = "Medicare Part B premiums" definition_period = YEAR unit = USD - uprating = "calibration.gov.hhs.cms.moop_per_capita" + + def formula(person, period, parameters): + enrolled = person("medicare_enrolled", period) + gross_premium = person("income_adjusted_part_b_premium", period) + msp_coverage = person("msp_part_b_premium_coverage", period) + return max_(where(enrolled, gross_premium, 0) - msp_coverage, 0) diff --git a/policyengine_us/variables/household/expense/health/medicare_part_b_premiums_reported.py b/policyengine_us/variables/household/expense/health/medicare_part_b_premiums_reported.py new file mode 100644 index 00000000000..500970c641d --- /dev/null +++ b/policyengine_us/variables/household/expense/health/medicare_part_b_premiums_reported.py @@ -0,0 +1,10 @@ +from policyengine_us.model_api import * + + +class medicare_part_b_premiums_reported(Variable): + value_type = float + entity = Person + label = "Medicare Part B premiums (reported)" + definition_period = YEAR + unit = USD + uprating = "calibration.gov.hhs.cms.moop_per_capita" diff --git a/policyengine_us/variables/input/weeks_worked.py b/policyengine_us/variables/input/weeks_worked.py new file mode 100644 index 00000000000..14d40b279bf --- /dev/null +++ b/policyengine_us/variables/input/weeks_worked.py @@ -0,0 +1,12 @@ +from policyengine_us.model_api import * + + +class weeks_worked(Variable): + value_type = int + entity = Person + label = "Weeks worked during the year" + definition_period = YEAR + documentation = "Number of weeks worked during the year." + + def formula_2025(person, period, parameters): + return person("weeks_worked", period.last_year)