diff --git a/changelog.d/core-uprating-undefined-index.fixed.md b/changelog.d/core-uprating-undefined-index.fixed.md new file mode 100644 index 00000000000..9791444033c --- /dev/null +++ b/changelog.d/core-uprating-undefined-index.fixed.md @@ -0,0 +1 @@ +Inputs to uprated variables supplied only for a year before 2015 no longer raise a TypeError when a later year reads them; they carry over unchanged to 2015 and are uprated from there. diff --git a/policyengine_us/tests/core/test_pre_first_modeled_year_uprated_inputs.py b/policyengine_us/tests/core/test_pre_first_modeled_year_uprated_inputs.py new file mode 100644 index 00000000000..1e9d75421c1 --- /dev/null +++ b/policyengine_us/tests/core/test_pre_first_modeled_year_uprated_inputs.py @@ -0,0 +1,164 @@ +"""Uprated inputs supplied only for a year before FIRST_MODELED_YEAR. + +Parameters are backdated to FIRST_MODELED_YEAR and no further, so most +uprating indices have no value for an earlier year. policyengine-core used to +divide by that missing value (``TypeError: unsupported operand type(s) for /: +'float' and 'NoneType'``) whenever a later year read the input. Core now holds +the index flat where it has no value, so an early input carries over +unchanged to FIRST_MODELED_YEAR and is uprated from there, exactly as if it +had been supplied for FIRST_MODELED_YEAR. +""" + +from collections import defaultdict + +import numpy as np +import pytest +from policyengine_core.parameters import get_parameter + +from policyengine_us import Simulation +from policyengine_us.system import system +from policyengine_us.tools.parameters import FIRST_MODELED_YEAR + +EARLY_YEAR = FIRST_MODELED_YEAR - 2 +LATER_YEAR = FIRST_MODELED_YEAR + 5 +VALUE = 1_000.0 +GROUP_ENTITIES = { + "tax_unit": "tax_units", + "spm_unit": "spm_units", + "family": "families", + "marital_unit": "marital_units", + "household": "households", +} + + +def _index_undefined_before_first_modeled_year(variable) -> bool: + if variable.uprating is None: + return False + index = get_parameter(system.parameters, variable.uprating) + # The earliest input instant the test supplies. + return index(f"{EARLY_YEAR}-01-01") is None + + +AFFECTED = sorted( + name + for name, variable in system.variables.items() + if _index_undefined_before_first_modeled_year(variable) +) + + +def _periods(variable): + """The early input period, FIRST_MODELED_YEAR's first period and a later + period, in the variable's own definition period.""" + if variable.definition_period == "month": + return ( + f"{FIRST_MODELED_YEAR - 1}-12", + f"{FIRST_MODELED_YEAR}-01", + f"{LATER_YEAR}-01", + ) + return EARLY_YEAR, FIRST_MODELED_YEAR, LATER_YEAR + + +def _situation(names, defined_for, input_at_first_modeled_year: bool): + """One person in every group entity, living where the variables are + defined, with each variable input once.""" + if defined_for is None: + state = "TX" + elif defined_for == "in_nyc": + state = "NY" + else: + state = defined_for + situation = { + "people": {"p": {"age": {FIRST_MODELED_YEAR: 40}}}, + **{plural: {"g": {"members": ["p"]}} for plural in GROUP_ENTITIES.values()}, + } + # Geography has formulas (state_code from state_fips, in_nyc from the + # county), so it is not carried forward: set it for every year read, or + # the state masks fall back to the default state in LATER_YEAR. + geography_years = (FIRST_MODELED_YEAR, LATER_YEAR) + situation["households"]["g"]["state_code"] = { + year: state for year in geography_years + } + if defined_for == "in_nyc": + situation["households"]["g"]["in_nyc"] = { + year: True for year in geography_years + } + for name in names: + variable = system.variables[name] + early, floor, _ = _periods(variable) + input_period = floor if input_at_first_modeled_year else early + entity = variable.entity.key + target = ( + situation["people"]["p"] + if entity == "person" + else situation[GROUP_ENTITIES[entity]]["g"] + ) + target[name] = {input_period: VALUE} + return situation + + +def _groups(): + groups = defaultdict(list) + for name in AFFECTED: + groups[system.variables[name].defined_for].append(name) + return sorted(groups.items(), key=lambda item: str(item[0])) + + +def test_affected_set_is_the_floor_dated_indices(): + # Every dollar input on a floor-dated series (the default AGI-per-capita + # series and the IRS SOI and CMS per-capita series); CPI-U reaches back + # to 1913 and is unaffected. + assert len(AFFECTED) > 300 + assert "tax_exempt_interest_income" in AFFECTED + + +GROUPS = _groups() + + +@pytest.mark.parametrize( + "defined_for,names", GROUPS, ids=[str(defined_for) for defined_for, _ in GROUPS] +) +def test_early_input_behaves_like_first_modeled_year_input(defined_for, names): + """For every affected variable, an input supplied only for a year before + FIRST_MODELED_YEAR is carried unchanged to FIRST_MODELED_YEAR, and at a + later year equals the same input supplied for FIRST_MODELED_YEAR + (differential).""" + early_sim = Simulation(situation=_situation(names, defined_for, False)) + floor_sim = Simulation(situation=_situation(names, defined_for, True)) + for name in names: + _, floor_period, later_period = _periods(system.variables[name]) + at_floor = early_sim.calculate(name, floor_period) + at_later = early_sim.calculate(name, later_period) + from_floor = floor_sim.calculate(name, later_period) + assert at_floor == pytest.approx(VALUE), name + assert np.isfinite(at_later).all(), name + # Nonzero: the variable is defined for this household in LATER_YEAR, + # so the comparison below is not two masked defaults. + assert (from_floor > 0).all(), name + assert at_later == pytest.approx(from_floor, rel=1e-6), name + + +def test_issue_household_with_2013_tax_exempt_interest(): + """The reported household: a 66-year-old in Texas with 2015 wages and + tax-exempt interest supplied only for 2013.""" + + def household(tax_exempt_interest_income): + return Simulation( + situation={ + "people": { + "p": { + "age": {2015: 66}, + "employment_income": {2015: 30_000}, + "tax_exempt_interest_income": tax_exempt_interest_income, + } + }, + "households": {"h": {"members": ["p"], "state_code": {2015: "TX"}}}, + } + ) + + early = household({2013: 5_000}) + same_in_2015 = household({2015: 5_000}) + assert early.calculate("tax_exempt_interest_income", 2015) == pytest.approx(5_000) + for variable in ("income_tax", "household_net_income"): + assert early.calculate(variable, 2015) == pytest.approx( + same_in_2015.calculate(variable, 2015) + ), variable diff --git a/pyproject.toml b/pyproject.toml index 129c4954c22..c29898910a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ dependencies = [ # pandas 3 copy-on-write enables the shallow-copy fast path in dataset # extension and is available on every supported Python version. "pandas>=3.0", - "policyengine-core>=3.30.1", + "policyengine-core>=3.32.8", "spm-calculator>=1.0.0,<=1.0.0.post1", "tables>=3.9", "tqdm>=4.67.1", diff --git a/uv.lock b/uv.lock index d5d1918cf23..844e33f2ce2 100644 --- a/uv.lock +++ b/uv.lock @@ -1703,7 +1703,7 @@ wheels = [ [[package]] name = "policyengine-core" -version = "3.30.2" +version = "3.32.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dpath" }, @@ -1723,9 +1723,9 @@ dependencies = [ { name = "standard-imghdr" }, { name = "wheel" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/6b/f3e845e64401f329d2dd43ed4ea4f6663810b0e22879e13e3cec997caeab/policyengine_core-3.30.2.tar.gz", hash = "sha256:1272a08c432e3c26eb0cc6eabdabed1cf1bf16a837c006d006816ad8e665a33d", size = 501156, upload-time = "2026-07-20T12:20:58.184Z" } +sdist = { url = "https://files.pythonhosted.org/packages/12/df/2fe428d690f02cb109e91d22b8af31ca6dcb047920d7177a2b28487a8ea1/policyengine_core-3.32.8.tar.gz", hash = "sha256:925a8990985ec22885f4d6c4ed129cd13dcc2eab02cf9a130939ca563394fcc3", size = 426369, upload-time = "2026-09-27T19:10:50.428Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/6e/f8b6af58c701a05d4afd2956747364618bc4ffdf40f37b28d450b2223a27/policyengine_core-3.30.2-py3-none-any.whl", hash = "sha256:ba187b4cf459c337c4bfe02b1f6b2491d45240ea1bd41a1148a1ec9ca8c51fe9", size = 245046, upload-time = "2026-07-20T12:20:56.553Z" }, + { url = "https://files.pythonhosted.org/packages/1a/45/bbba25be7ddc52ad9bb6f2b8ea044eb3c670f4168c67044328f631ccb025/policyengine_core-3.32.8-py3-none-any.whl", hash = "sha256:84e470d1f03d531d1d5ee77c173c59444bbbdea50132faf9150a82aa00d54397", size = 246996, upload-time = "2026-09-27T19:10:48.871Z" }, ] [[package]] @@ -1760,7 +1760,7 @@ requires-dist = [ { name = "jupyter-book", marker = "extra == 'dev'", specifier = ">=1.0.4.post1" }, { name = "microdf-python", specifier = ">=1.0.0" }, { name = "pandas", specifier = ">=3.0" }, - { name = "policyengine-core", specifier = ">=3.30.1" }, + { name = "policyengine-core", specifier = ">=3.32.8" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, { name = "setuptools", marker = "extra == 'dev'", specifier = ">=80.9.0" }, { name = "spm-calculator", specifier = ">=1.0.0,<=1.0.0.post1" },