diff --git a/policyengine_us/tests/policy/baseline/household/income/spm_unit/test_spm_unit_spm_threshold.py b/policyengine_us/tests/policy/baseline/household/income/spm_unit/test_spm_unit_spm_threshold.py new file mode 100644 index 00000000000..1953760af11 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/income/spm_unit/test_spm_unit_spm_threshold.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import pytest + +from policyengine_us import CountryTaxBenefitSystem, Simulation + +SYSTEM = CountryTaxBenefitSystem() +CPI_U = SYSTEM.parameters.gov.bls.cpi.cpi_u +REFERENCE_THRESHOLDS_2024 = { + "renter": 39_430.0, + "owner_with_mortgage": 39_068.0, + "owner_without_mortgage": 32_586.0, +} + + +def _equivalence_scale(num_adults: int, num_children: int) -> float: + reference_raw_scale = 3**0.7 + + if num_adults <= 0 and num_children <= 0: + return 0.0 + if num_children > 0: + if num_adults <= 1: + raw = (1.0 + 0.8 + 0.5 * max(num_children - 1, 0)) ** 0.7 + else: + raw = (num_adults + 0.5 * num_children) ** 0.7 + else: + if num_adults <= 1: + raw = 1.0 + elif num_adults == 2: + raw = 1.41 + else: + raw = num_adults**0.7 + return raw / reference_raw_scale + + +def _future_base_threshold(tenure: str, year: int) -> float: + return REFERENCE_THRESHOLDS_2024[tenure] * float( + CPI_U(f"{year}-02-01") / CPI_U("2024-02-01") + ) + + +def _make_simulation(spm_unit_fields: dict, people: dict) -> Simulation: + return Simulation( + tax_benefit_system=SYSTEM, + situation={ + "people": people, + "households": { + "household": { + "members": list(people.keys()), + }, + }, + "tax_units": { + "tax_unit": { + "members": list(people.keys()), + }, + }, + "spm_units": { + "spm_unit": { + "members": list(people.keys()), + **spm_unit_fields, + }, + }, + "families": { + "family": { + "members": list(people.keys()), + }, + }, + "marital_units": { + "marital_unit": { + "members": list(people.keys()), + }, + }, + }, + ) + + +def test_spm_threshold_recomputes_when_child_ages_into_adulthood(): + baseline_threshold = 40_000.0 + sim = _make_simulation( + spm_unit_fields={ + "spm_unit_spm_threshold": {"2024": baseline_threshold}, + "spm_unit_tenure_type": {"2024": "RENTER", "2025": "RENTER"}, + }, + people={ + "adult": {"age": {"2024": 35, "2025": 36}}, + "child": {"age": {"2024": 17, "2025": 18}}, + }, + ) + + result = sim.calculate("spm_unit_spm_threshold", 2025)[0] + expected = baseline_threshold + expected *= _future_base_threshold("renter", 2025) / REFERENCE_THRESHOLDS_2024["renter"] + expected *= _equivalence_scale(2, 0) / _equivalence_scale(1, 1) + + assert result == pytest.approx(expected) + + +def test_spm_threshold_recomputes_when_tenure_changes(): + baseline_threshold = 20_000.0 + sim = _make_simulation( + spm_unit_fields={ + "spm_unit_spm_threshold": {"2024": baseline_threshold}, + "spm_unit_tenure_type": { + "2024": "RENTER", + "2025": "OWNER_WITHOUT_MORTGAGE", + }, + }, + people={ + "adult": {"age": {"2024": 35, "2025": 36}}, + }, + ) + + result = sim.calculate("spm_unit_spm_threshold", 2025)[0] + expected = baseline_threshold + expected *= ( + _future_base_threshold("owner_without_mortgage", 2025) + / REFERENCE_THRESHOLDS_2024["renter"] + ) + + assert result == pytest.approx(expected) + + +def test_manual_future_spm_threshold_input_still_wins(): + sim = _make_simulation( + spm_unit_fields={ + "spm_unit_spm_threshold": {"2024": 20_000.0, "2025": 12_345.0}, + "spm_unit_tenure_type": {"2024": "RENTER", "2025": "RENTER"}, + }, + people={ + "adult": {"age": {"2024": 35, "2025": 36}}, + }, + ) + + assert sim.calculate("spm_unit_spm_threshold", 2025)[0] == pytest.approx( + 12_345.0 + ) diff --git a/policyengine_us/tools/default_uprating.py b/policyengine_us/tools/default_uprating.py index a69922ba8fe..18250ddfebd 100644 --- a/policyengine_us/tools/default_uprating.py +++ b/policyengine_us/tools/default_uprating.py @@ -57,7 +57,6 @@ "casualty_loss", "partnership_s_corp_income", "taxable_interest_income", - "spm_unit_spm_threshold", "non_sch_d_capital_gains", "spm_unit_state_tax_reported", "spm_unit_capped_work_childcare_expenses", diff --git a/policyengine_us/tools/spm_thresholds.py b/policyengine_us/tools/spm_thresholds.py new file mode 100644 index 00000000000..7822d9c868e --- /dev/null +++ b/policyengine_us/tools/spm_thresholds.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from functools import lru_cache + +import numpy as np + +PUBLISHED_SPM_REFERENCE_THRESHOLDS = { + 2015: { + "renter": 25_155.0, + "owner_with_mortgage": 24_859.0, + "owner_without_mortgage": 20_639.0, + }, + 2016: { + "renter": 25_558.0, + "owner_with_mortgage": 25_248.0, + "owner_without_mortgage": 20_943.0, + }, + 2017: { + "renter": 26_213.0, + "owner_with_mortgage": 25_897.0, + "owner_without_mortgage": 21_527.0, + }, + 2018: { + "renter": 26_905.0, + "owner_with_mortgage": 26_565.0, + "owner_without_mortgage": 22_095.0, + }, + 2019: { + "renter": 27_515.0, + "owner_with_mortgage": 27_172.0, + "owner_without_mortgage": 22_600.0, + }, + 2020: { + "renter": 28_881.0, + "owner_with_mortgage": 28_533.0, + "owner_without_mortgage": 23_948.0, + }, + 2021: { + "renter": 31_453.0, + "owner_with_mortgage": 31_089.0, + "owner_without_mortgage": 26_022.0, + }, + 2022: { + "renter": 33_402.0, + "owner_with_mortgage": 32_949.0, + "owner_without_mortgage": 27_679.0, + }, + 2023: { + "renter": 36_606.0, + "owner_with_mortgage": 36_192.0, + "owner_without_mortgage": 30_347.0, + }, + 2024: { + "renter": 39_430.0, + "owner_with_mortgage": 39_068.0, + "owner_without_mortgage": 32_586.0, + }, +} + +LATEST_PUBLISHED_SPM_THRESHOLD_YEAR = max(PUBLISHED_SPM_REFERENCE_THRESHOLDS) +REFERENCE_RAW_SCALE = 3**0.7 + + +@lru_cache(maxsize=1) +def _get_cpi_u_parameter(): + from policyengine_us import CountryTaxBenefitSystem + + system = CountryTaxBenefitSystem() + return system.parameters.gov.bls.cpi.cpi_u + + +def spm_equivalence_scale(num_adults, num_children): + adults, children = np.broadcast_arrays( + np.asarray(num_adults, dtype=float), + np.asarray(num_children, dtype=float), + ) + + raw = np.zeros_like(adults, dtype=float) + has_people = (adults + children) > 0 + with_children = has_people & (children > 0) + + single_adult_with_children = with_children & (adults <= 1) + raw[single_adult_with_children] = ( + 1.0 + + 0.8 + + 0.5 * np.maximum(children[single_adult_with_children] - 1, 0) + ) ** 0.7 + + multi_adult_with_children = with_children & ~single_adult_with_children + raw[multi_adult_with_children] = ( + adults[multi_adult_with_children] + + 0.5 * children[multi_adult_with_children] + ) ** 0.7 + + no_children = has_people & ~with_children + one_adult = no_children & (adults <= 1) + two_adults = no_children & (adults == 2) + larger_adult_units = no_children & (adults > 2) + + raw[one_adult] = 1.0 + raw[two_adults] = 1.41 + raw[larger_adult_units] = adults[larger_adult_units] ** 0.7 + + return raw / REFERENCE_RAW_SCALE + + +def get_spm_reference_thresholds(year: int, cpi_u_parameter=None) -> dict[str, float]: + if year in PUBLISHED_SPM_REFERENCE_THRESHOLDS: + return PUBLISHED_SPM_REFERENCE_THRESHOLDS[year].copy() + + if year < min(PUBLISHED_SPM_REFERENCE_THRESHOLDS): + raise ValueError( + f"No published SPM reference thresholds for {year}. " + f"Earliest available year is {min(PUBLISHED_SPM_REFERENCE_THRESHOLDS)}." + ) + + cpi_u_parameter = cpi_u_parameter or _get_cpi_u_parameter() + factor = float( + cpi_u_parameter(f"{year}-02-01") + / cpi_u_parameter(f"{LATEST_PUBLISHED_SPM_THRESHOLD_YEAR}-02-01") + ) + latest_thresholds = PUBLISHED_SPM_REFERENCE_THRESHOLDS[ + LATEST_PUBLISHED_SPM_THRESHOLD_YEAR + ] + return { + tenure: value * factor + for tenure, value in latest_thresholds.items() + } diff --git a/policyengine_us/variables/household/income/spm_unit/spm_unit_spm_threshold.py b/policyengine_us/variables/household/income/spm_unit/spm_unit_spm_threshold.py index 9330cd1e7c7..b4ee53e8a97 100644 --- a/policyengine_us/variables/household/income/spm_unit/spm_unit_spm_threshold.py +++ b/policyengine_us/variables/household/income/spm_unit/spm_unit_spm_threshold.py @@ -1,4 +1,11 @@ from policyengine_us.model_api import * +from policyengine_us.tools.spm_thresholds import ( + get_spm_reference_thresholds, + spm_equivalence_scale, +) +from policyengine_us.variables.household.income.spm_unit.spm_unit_tenure_type import ( + SPMUnitTenureType, +) class spm_unit_spm_threshold(Variable): @@ -7,4 +14,58 @@ class spm_unit_spm_threshold(Variable): label = "SPM unit's SPM poverty threshold" definition_period = YEAR unit = USD - uprating = "gov.bls.cpi.cpi_u" + + def formula_2024(spm_unit, period, parameters): + prior_period = period.last_year + cpi_u = parameters.gov.bls.cpi.cpi_u + + prior_threshold = spm_unit("spm_unit_spm_threshold", prior_period) + prior_adults = spm_unit("spm_unit_count_adults", prior_period) + prior_children = spm_unit("spm_unit_count_children", prior_period) + prior_tenure = spm_unit("spm_unit_tenure_type", prior_period) + + current_adults = spm_unit("spm_unit_count_adults", period) + current_children = spm_unit("spm_unit_count_children", period) + current_tenure = spm_unit("spm_unit_tenure_type", period) + + prior_base = _reference_threshold_array( + prior_tenure, + prior_period.start.year, + cpi_u, + ) + current_base = _reference_threshold_array( + current_tenure, + period.start.year, + cpi_u, + ) + + prior_equiv_scale = spm_equivalence_scale(prior_adults, prior_children) + current_equiv_scale = spm_equivalence_scale( + current_adults, + current_children, + ) + + denominator = prior_base * prior_equiv_scale + geoadj = np.divide( + prior_threshold, + denominator, + out=np.ones_like(prior_threshold, dtype=float), + where=denominator > 0, + ) + return current_base * current_equiv_scale * geoadj + + +def _reference_threshold_array(tenure, year: int, cpi_u_parameter): + thresholds = get_spm_reference_thresholds(year, cpi_u_parameter) + values = np.full(len(tenure), thresholds["renter"], dtype=float) + values = np.where( + tenure == SPMUnitTenureType.OWNER_WITH_MORTGAGE, + thresholds["owner_with_mortgage"], + values, + ) + values = np.where( + tenure == SPMUnitTenureType.OWNER_WITHOUT_MORTGAGE, + thresholds["owner_without_mortgage"], + values, + ) + return values