Skip to content

Commit 0f7b48c

Browse files
authored
Add SPM geographic adjustment input (#8246)
1 parent 9c75c17 commit 0f7b48c

4 files changed

Lines changed: 179 additions & 31 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add an SPM unit geographic adjustment input and use it to calculate SPM thresholds.

policyengine_us/tests/policy/baseline/household/income/spm_unit/test_spm_unit_spm_threshold.py

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
2. Post-published years uprate via PolicyEngine's ``gov.bls.cpi.cpi_u``
77
parameter.
88
3. Composition and tenure changes between periods flow through to the
9-
threshold while preserving the unit-specific geographic adjustment
10-
implied by the prior-year stored threshold.
9+
threshold while applying the unit-specific geographic adjustment.
1110
4. The Betson three-parameter equivalence scale is applied (a 2A2C
1211
reference family at the renter national base equals 39430 in 2024).
1312
"""
@@ -170,3 +169,130 @@ def test_formula_preserves_implied_geographic_adjustment():
170169
got = float(sim.calculate("spm_unit_spm_threshold", YEAR)[0])
171170
expected = current_base * equiv * GEOADJ
172171
assert got == pytest.approx(expected, rel=1e-5)
172+
173+
174+
def test_formula_uses_current_geographic_adjustment_input():
175+
"""A dataset can provide the geographic adjustment directly without
176+
materializing an SPM threshold input."""
177+
YEAR = 2023
178+
GEOADJ = 1.25
179+
180+
cpi_u = _cpi_u()
181+
base = _reference_threshold_array(
182+
np.array([SPMUnitTenureType.RENTER]),
183+
YEAR,
184+
cpi_u,
185+
)[0]
186+
equiv = spm_equivalence_scale(2, 2)
187+
188+
situation = {
189+
"people": {
190+
"a1": {"age": {YEAR: 40}},
191+
"a2": {"age": {YEAR: 40}},
192+
"k1": {"age": {YEAR: 5}},
193+
"k2": {"age": {YEAR: 3}},
194+
},
195+
"spm_units": {
196+
"spm_unit": {
197+
"members": ["a1", "a2", "k1", "k2"],
198+
"spm_unit_geographic_adjustment": {
199+
YEAR: GEOADJ,
200+
},
201+
"spm_unit_tenure_type": {
202+
YEAR: "RENTER",
203+
},
204+
}
205+
},
206+
}
207+
208+
sim = Simulation(situation=situation)
209+
got = float(sim.calculate("spm_unit_spm_threshold", YEAR)[0])
210+
expected = base * equiv * GEOADJ
211+
assert got == pytest.approx(expected, rel=1e-5)
212+
213+
214+
def test_geographic_adjustment_defaults_to_one_without_prior_threshold():
215+
"""With no direct or implied geographic adjustment, the threshold uses
216+
the national reference threshold."""
217+
YEAR = 2024
218+
219+
cpi_u = _cpi_u()
220+
base = _reference_threshold_array(
221+
np.array([SPMUnitTenureType.RENTER]),
222+
YEAR,
223+
cpi_u,
224+
)[0]
225+
equiv = spm_equivalence_scale(2, 2)
226+
227+
situation = {
228+
"people": {
229+
"a1": {"age": {YEAR: 40}},
230+
"a2": {"age": {YEAR: 40}},
231+
"k1": {"age": {YEAR: 5}},
232+
"k2": {"age": {YEAR: 3}},
233+
},
234+
"spm_units": {
235+
"spm_unit": {
236+
"members": ["a1", "a2", "k1", "k2"],
237+
"spm_unit_tenure_type": {
238+
YEAR: "RENTER",
239+
},
240+
}
241+
},
242+
}
243+
244+
sim = Simulation(situation=situation)
245+
got = float(sim.calculate("spm_unit_spm_threshold", YEAR)[0])
246+
expected = base * equiv
247+
assert got == pytest.approx(expected, rel=1e-5)
248+
249+
250+
def test_current_geographic_adjustment_input_overrides_prior_implied_value():
251+
"""Current-period geographic adjustment inputs should take precedence
252+
over a prior-year threshold used for backward compatibility."""
253+
YEAR = 2026
254+
PRIOR = 2025
255+
PRIOR_GEOADJ = 1.5
256+
CURRENT_GEOADJ = 1.1
257+
258+
cpi_u = _cpi_u()
259+
prior_base = _reference_threshold_array(
260+
np.array([SPMUnitTenureType.RENTER]),
261+
PRIOR,
262+
cpi_u,
263+
)[0]
264+
current_base = _reference_threshold_array(
265+
np.array([SPMUnitTenureType.RENTER]),
266+
YEAR,
267+
cpi_u,
268+
)[0]
269+
equiv = spm_equivalence_scale(2, 2)
270+
271+
situation = {
272+
"people": {
273+
"a1": {"age": {PRIOR: 40, YEAR: 41}},
274+
"a2": {"age": {PRIOR: 40, YEAR: 41}},
275+
"k1": {"age": {PRIOR: 5, YEAR: 6}},
276+
"k2": {"age": {PRIOR: 3, YEAR: 4}},
277+
},
278+
"spm_units": {
279+
"spm_unit": {
280+
"members": ["a1", "a2", "k1", "k2"],
281+
"spm_unit_spm_threshold": {
282+
PRIOR: float(prior_base * equiv * PRIOR_GEOADJ),
283+
},
284+
"spm_unit_geographic_adjustment": {
285+
YEAR: CURRENT_GEOADJ,
286+
},
287+
"spm_unit_tenure_type": {
288+
PRIOR: "RENTER",
289+
YEAR: "RENTER",
290+
},
291+
}
292+
},
293+
}
294+
295+
sim = Simulation(situation=situation)
296+
got = float(sim.calculate("spm_unit_spm_threshold", YEAR)[0])
297+
expected = current_base * equiv * CURRENT_GEOADJ
298+
assert got == pytest.approx(expected, rel=1e-5)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import numpy as np
2+
3+
from policyengine_us.model_api import *
4+
from policyengine_us.variables.household.income.spm_unit.spm_unit_spm_threshold import (
5+
_reference_threshold_array,
6+
)
7+
from spm_calculator.equivalence_scale import spm_equivalence_scale
8+
9+
10+
class spm_unit_geographic_adjustment(Variable):
11+
value_type = float
12+
entity = SPMUnit
13+
label = "SPM unit geographic adjustment"
14+
documentation = "Geographic adjustment applied to the SPM reference threshold."
15+
definition_period = YEAR
16+
default_value = 1.0
17+
18+
def formula_2016(spm_unit, period, parameters):
19+
"""Infer the location adjustment from prior-year threshold inputs.
20+
21+
Datasets can provide this directly. For older datasets that only
22+
contain stored SPM thresholds, this preserves the implied adjustment.
23+
"""
24+
prior_period = period.last_year
25+
cpi_u = parameters.gov.bls.cpi.cpi_u
26+
27+
prior_threshold = spm_unit("spm_unit_spm_threshold", prior_period)
28+
prior_adults = spm_unit("spm_unit_count_adults", prior_period)
29+
prior_children = spm_unit("spm_unit_count_children", prior_period)
30+
prior_tenure = spm_unit("spm_unit_tenure_type", prior_period)
31+
32+
prior_base = _reference_threshold_array(
33+
prior_tenure,
34+
prior_period.start.year,
35+
cpi_u,
36+
)
37+
prior_equiv_scale = spm_equivalence_scale(
38+
prior_adults,
39+
prior_children,
40+
)
41+
denominator = prior_base * prior_equiv_scale
42+
return np.divide(
43+
prior_threshold,
44+
denominator,
45+
out=np.ones_like(prior_threshold, dtype=float),
46+
where=(prior_threshold > 0) & (denominator > 0),
47+
)

policyengine_us/variables/household/income/spm_unit/spm_unit_spm_threshold.py

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -56,54 +56,28 @@ class spm_unit_spm_threshold(Variable):
5656
definition_period = YEAR
5757
unit = USD
5858

59-
def formula_2024(spm_unit, period, parameters):
59+
def formula_2015(spm_unit, period, parameters):
6060
"""Rebuild the SPM threshold from current composition, current
61-
tenure, and the unit-specific geographic adjustment implied by
62-
the prior-year stored threshold.
63-
64-
The implied geographic adjustment is
65-
``prior_threshold / (prior_base * prior_equiv_scale)``. Carrying
66-
it forward this way preserves the location-specific SPM
67-
adjustment baked into the input data while letting composition
68-
and tenure changes flow through.
61+
tenure, and the unit-specific geographic adjustment.
6962
7063
Base reference thresholds and the Betson three-parameter
7164
equivalence scale come from ``spm-calculator``.
7265
"""
73-
prior_period = period.last_year
7466
cpi_u = parameters.gov.bls.cpi.cpi_u
7567

76-
prior_threshold = spm_unit("spm_unit_spm_threshold", prior_period)
77-
prior_adults = spm_unit("spm_unit_count_adults", prior_period)
78-
prior_children = spm_unit("spm_unit_count_children", prior_period)
79-
prior_tenure = spm_unit("spm_unit_tenure_type", prior_period)
80-
8168
current_adults = spm_unit("spm_unit_count_adults", period)
8269
current_children = spm_unit("spm_unit_count_children", period)
8370
current_tenure = spm_unit("spm_unit_tenure_type", period)
84-
85-
prior_base = _reference_threshold_array(
86-
prior_tenure,
87-
prior_period.start.year,
88-
cpi_u,
89-
)
71+
geoadj = spm_unit("spm_unit_geographic_adjustment", period)
9072
current_base = _reference_threshold_array(
9173
current_tenure,
9274
period.start.year,
9375
cpi_u,
9476
)
9577

96-
prior_equiv_scale = spm_equivalence_scale(prior_adults, prior_children)
9778
current_equiv_scale = spm_equivalence_scale(
9879
current_adults,
9980
current_children,
10081
)
10182

102-
denominator = prior_base * prior_equiv_scale
103-
geoadj = np.divide(
104-
prior_threshold,
105-
denominator,
106-
out=np.ones_like(prior_threshold, dtype=float),
107-
where=denominator > 0,
108-
)
10983
return current_base * current_equiv_scale * geoadj

0 commit comments

Comments
 (0)