Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
)
1 change: 0 additions & 1 deletion policyengine_us/tools/default_uprating.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
128 changes: 128 additions & 0 deletions policyengine_us/tools/spm_thresholds.py
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
Loading