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
1 change: 1 addition & 0 deletions changelog.d/543.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
US household calculations without `county_fips` now measure SPM poverty nationally instead of raising `SPM_GEOGRAPHY_REQUIRED`, and report it as `provenance["spm_geography_source"] == "national_fallback"`. Households with `county_fips` keep their county's SPM estimation area, and a geography chosen with `spm=` is still used as given.
5 changes: 3 additions & 2 deletions docs/countries.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ Override in any output with `income_variable=`.

US: Populace row scoping uses `state_fips` and `congressional_district_geoid`.
`state_code` remains the human-readable state input for custom households.
US resource and poverty calculations also require observed `county_fips` or an
explicit national or SPM-area choice; see [Households](households.md#spm-geography-and-measurement-selection).
US SPM thresholds use a household's observed `county_fips` to find its SPM
estimation area; a household calculation without one is measured nationally and
says so in its provenance. See [Households](households.md#spm-geography-and-measurement-selection).

UK: constituency code and local authority code on every household where available.

Expand Down
54 changes: 37 additions & 17 deletions docs/households.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,32 +27,47 @@ result = pe.us.calculate_household(
| `people` | List of person dicts. Keys are any person-level variable on the model. |
| `tax_unit` | Tax-unit inputs (e.g. `filing_status`). |
| `spm_unit` | SPM-unit inputs. |
| `household` | Household inputs, including `state_code` and observed five-digit `county_fips` for the default SPM geography selection. |
| `household` | Household inputs, including `state_code` and, for area-adjusted SPM measurement, the observed five-digit `county_fips`. |
| `family` | Family-level inputs. |
| `marital_unit` | Marital-unit inputs. |

All adults default to one shared tax unit and household. For separate tax units (e.g. two adult roommates), construct the `Simulation` directly and set the entity-membership arrays.

### SPM geography and measurement selection

US household results include SPM resources and poverty by default. Provide the
household's county FIPS, as above, or explicitly select national measurement:
US household results include SPM resources and poverty by default. With the
household's county FIPS, as above, SPM thresholds use that county's Census SPM
estimation area. A household that gives only its state is measured nationally,
with no geographic adjustment, and the result says so. Missing values (`None`,
`""`, NaN) and a `county` or `county_str` of `"UNKNOWN"` count as no county:

```python
result = pe.us.calculate_household(
people=[{"age": 40, "employment_income": 50_000}],
tax_unit={"filing_status": "SINGLE"},
household={"state_code": "CA"},
year=2026,
spm={"geography_kind": "national"},
)
result.provenance["spm_config"]["geography_kind"] # "national"
result.provenance["spm_geography_source"] # "national_fallback"
receipt = result.to_dict()["provenance"]["spm"]
result.write("household-result.json") # Includes the JSON-compatible receipt.
```

National measurement applies to everything that uses the SPM measurement: the
thresholds and poverty status, and, for a unit allocated housing assistance, the
capped SPM housing subsidy and the SPM resources built on it.

`spm_geography_source` is `"national_fallback"` when national measurement
replaced the default county selection because the household named no county,
`"default"` when the bundle default applied as is, and `"selection"` when you
chose the geography with `spm`. The same national result, recorded as your
selection, comes from `spm={"geography_kind": "national"}`.

County FIPS assigns a household to the selected year's Census SPM estimation
area; it does not select a separately estimated county rent factor. National
measurement is a conscious analytical choice and is recorded in provenance.
measurement, whether chosen or used because no county was given, is recorded in
provenance.
A fixed SPM area can instead be selected with
`spm={"geography_kind": "metro", "geography_id": area_id}`, using an area ID
available in the pinned artifact for the requested year.
Expand All @@ -64,7 +79,7 @@ set of keys is:
|---|---|
| `forecast_content_sha256` | Optional assertion of the bundle's independently pinned artifact content hash. A different hash is rejected. |
| `scenario` | Scenario within that artifact: `ce_trend` by default or the `zero_real` sensitivity. |
| `geography_kind` | `county` by default; `national` or `metro` require an explicit choice. |
| `geography_kind` | `county` by default. If you leave it out and the household has no `county_fips`, the calculation falls back to `national`. `national` or `metro` can be chosen explicitly. |
| `geography_id` | Required only for a fixed `metro` SPM area. |
| `county_vintage` | County assignment vintage, `"2020"` by default. |
| `as_of` | Optional information-date cutoff accepted by the pinned artifact. |
Expand All @@ -75,14 +90,21 @@ national inputs from forecast components and research geography; an unsupported
year fails instead of being extrapolated by the wrapper. Scenario forecasts
are conditional research estimates, not agency forecasts or uncertainty bounds.

State alone is insufficient for SPM and raises `SPM_GEOGRAPHY_REQUIRED`.
Unknown counties or selected areas raise `SPM_GEOGRAPHY_UNAVAILABLE`; a measured
unit with no classified adult raises `SPM_COMPOSITION_REQUIRED`. These are
calculator `SPMInputError` exceptions with `code` and `to_dict()` attributes.
Geography is checked when an SPM-dependent formula runs, and only for the units
whose result depends on the measurement. SPM measurement itself — thresholds,
the geographic factor and SPM poverty — always requires this choice. The
ordinary resource outputs — household net income, benefits, income decile and
A state alone does not identify a Census SPM estimation area, so a household
without `county_fips` is measured nationally unless you choose a geography. A
geography you choose is used as given: `spm={"geography_kind": "county"}` for a
household without `county_fips` raises `SPM_GEOGRAPHY_REQUIRED`. So does a
household that names its county only as `county` or `county_str`, because county
measurement reads `county_fips`; it keeps the county selection rather than being
measured nationally. Unknown counties or selected areas raise
`SPM_GEOGRAPHY_UNAVAILABLE`; a measured unit with no classified adult raises
`SPM_COMPOSITION_REQUIRED`. These are calculator `SPMInputError` exceptions with
`code` and `to_dict()` attributes.

Under a county selection, geography is checked when an SPM-dependent formula
runs, and only for the units whose result depends on the measurement. SPM
measurement itself — thresholds, the geographic factor and SPM poverty — always
needs the county. The ordinary resource outputs — household net income, benefits, income decile and
equivalized net income, and each person's marginal tax rate — take the
household's housing assistance amount rather than the capped SPM subsidy, so
they succeed on state alone and record an empty measurement receipt whether or
Expand All @@ -93,9 +115,7 @@ without a geography the cap and everything downstream of it —
`spm_unit_benefits`, `spm_unit_net_income` and in turn
`spm_unit_oecd_equiv_net_income` and `spm_unit_income_decile` — raise
`SPM_GEOGRAPHY_REQUIRED`, while a unit allocated none has a capped subsidy of
zero by construction and consults no measurement. A genuinely independent
tax-only country-model calculation can use state alone; the wrapper's default
outputs include SPM poverty and therefore always require the choice.
zero by construction and consults no measurement.

Supply observed inputs such as age, tenure, county and the source-backed
`is_spm_independent_minor_role`. Computed SPM thresholds, geographic factors,
Expand Down
7 changes: 4 additions & 3 deletions examples/household_impact_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,10 @@ def us_example() -> None:
print(f" Income tax: ${single.tax_unit.income_tax:,.0f}")
print(f" Payroll tax: ${single.tax_unit.employee_payroll_tax:,.0f}")

# Married couple with two kids, Texas, lower income. Explicit national
# measurement is recorded in the result; state alone does not select an
# SPM area. Use an observed county_fips for local SPM measurement instead.
# Married couple with two kids, Texas, lower income. A state alone does not
# select an SPM area, so this household would be measured nationally even
# without the spm argument; choosing it explicitly records it as a
# selection. Use an observed county_fips for local SPM measurement instead.
family = pe.us.calculate_household(
people=[
{"age": 35, "employment_income": 40_000},
Expand Down
11 changes: 7 additions & 4 deletions src/policyengine/core/spm.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,15 @@ def _selection_schema(schema: dict[str, Any]) -> None:


class SPMSelection(BaseModel):
"""Select from the bundle's pinned artifact; national geography is explicit.
"""Select from the bundle's pinned artifact.

County mode reads the household's observed ``county_fips``. A state alone
does not identify an SPM area. These settings contain no provider or path.
Serialization preserves omitted options so they still inherit bundle defaults
after a round trip. A resolved selection explicitly contains all six fields.
does not identify an SPM area, so national measurement is either selected
explicitly or, for a household calculation that chose no geography and
names no county, a fallback recorded as ``spm_geography_source``. These
settings contain no provider or path. Serialization preserves omitted
options so they still inherit bundle defaults after a round trip. A resolved
selection explicitly contains all six fields.
"""

model_config = ConfigDict(
Expand Down
103 changes: 83 additions & 20 deletions src/policyengine/tax_benefit_models/us/household.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@

from __future__ import annotations

import math
import numbers
from collections.abc import Mapping
from typing import Any, Optional

Expand All @@ -52,10 +54,61 @@
from policyengine.utils.household_validation import validate_household_input

from .model import us_latest
from .spm import SPMSelection, calculation_provenance, resolve_spm_selection
from .spm import (
SPMSelection,
calculation_provenance,
resolve_household_spm_selection,
)

_GROUP_ENTITIES = ("marital_unit", "family", "spm_unit", "tax_unit", "household")

# Household inputs that name a county. SPM county measurement reads only
# ``county_fips``, but a household that names its county another way still
# asked for a county measurement, so it keeps the county selection and gets the
# error that asks for ``county_fips`` instead of a national result.
_COUNTY_INPUTS = ("county_fips", "county", "county_str")


def _is_absent(name: str, value: Any) -> bool:
"""Whether a county input's value means that no county was given.

Missing values (``None``, empty text, NaN or the text ``"nan"``,
``pd.NA``) are absent, as the country model's county check also treats
them, and so is ``"UNKNOWN"``, the ``county`` enum's default, for
``county`` and ``county_str`` only. Anything else, including a malformed
code such as ``6037``, names a county and so reaches the county
selection's typed error. This decides only the SPM selection: the country
model itself rejects some of these as inputs (``pd.NA`` and bytes fail to
serialize whatever the geography).
"""
if value is None:
return True
if isinstance(value, bytes):
value = value.decode(errors="replace")
if isinstance(value, str):
if value == "" or value.lower() == "nan":
return True
return name != "county_fips" and value == "UNKNOWN"
if isinstance(value, numbers.Number) and not isinstance(value, bool):
try:
return math.isnan(value)
except TypeError:
return False
import pandas as pd

return value is pd.NA


def _names_county(
household: Mapping[str, Any], axes: Optional[list[list[dict[str, Any]]]]
) -> bool:
if any(
name in household and not _is_absent(name, household[name])
for name in _COUNTY_INPUTS
):
return True
return any(axis["name"] in _COUNTY_INPUTS for group in axes or [] for axis in group)


def _raise_unexpected_kwargs(unexpected: Mapping[str, Any]) -> None:
from difflib import get_close_matches
Expand Down Expand Up @@ -188,31 +241,37 @@ def calculate_household(
values default to ``year``. When axes are present, result values
are lists ordered by the axis grid instead of scalars.
spm: SPMSelection or mapping selecting a scenario and geography from
the bundle's independently pinned artifact. Geography is demanded
only by the results that actually use the measurement. SPM
measurement itself — thresholds, the geographic factor and SPM
poverty — always requires household county_fips or an explicit
metro/national selection, and so do the default outputs, which
include SPM poverty. The ordinary resource outputs take the
household's housing assistance amount rather than the capped SPM
subsidy, so they compute on state alone whether or not the unit is
allocated assistance. The country's cap
(``spm_unit_capped_housing_subsidy``) is what consults the
canonical housing portion, and only for units allocated
assistance, so for an assisted unit without a geography the cap
and everything downstream of it — ``spm_unit_benefits``,
``spm_unit_net_income`` and in turn
``spm_unit_oecd_equiv_net_income`` and
``spm_unit_income_decile`` — raise ``SPM_GEOGRAPHY_REQUIRED``;
a unit allocated none has a capped subsidy of zero by
construction.
the bundle's independently pinned artifact. When it chooses no
``geography_kind``, a household with ``county_fips`` is measured
in its county's Census SPM estimation area, and a household that
names no county (no county input, only missing values, or
``county``/``county_str`` of ``"UNKNOWN"``) is measured
nationally, with no geographic
adjustment, in its thresholds and in the capped SPM housing
subsidy; ``provenance["spm_geography_source"]`` is then
``"national_fallback"`` (otherwise ``"default"``, or
``"selection"`` when you chose the geography). A household that
names its county only as ``county`` or ``county_str`` keeps county
measurement and raises ``SPM_GEOGRAPHY_REQUIRED``, asking for
``county_fips``. A ``geography_kind`` you choose is used as given,
so an explicit county selection without ``county_fips`` also
raises ``SPM_GEOGRAPHY_REQUIRED``. Under that selection only the
results that use the measurement need the county: SPM thresholds
and poverty, and, for a unit allocated housing assistance, the
capped SPM subsidy (``spm_unit_capped_housing_subsidy``) and the
SPM resources built on it (``spm_unit_benefits``,
``spm_unit_net_income``, ``spm_unit_oecd_equiv_net_income``,
``spm_unit_income_decile``). Ordinary resource outputs use the
actual housing assistance amount and never need one.
Formula-owned SPM amounts and measurement counts cannot be
supplied as inputs/axes.

Returns:
:class:`HouseholdResult` with dot-accessible per-entity
variables. Singleton entities (``tax_unit``, ``household``, ...)
return :class:`EntityResult`; ``person`` returns a list of them.
``provenance`` holds the resolved ``spm_config``, the
``spm_geography_source`` and the SPM calculation receipt (``spm``).

Raises:
ValueError: if any input dict uses an unknown variable name,
Expand Down Expand Up @@ -272,7 +331,10 @@ def calculate_household(
}
)
axes_active = normalized_axes is not None
spm_config = resolve_spm_selection(spm)
spm_config, spm_geography_source = resolve_household_spm_selection(
spm,
household_names_county=_names_county(entities["household"], normalized_axes),
)

simulation = Simulation(
situation=_build_situation(
Expand Down Expand Up @@ -330,6 +392,7 @@ def calculate_household(
)
result["provenance"] = {
"spm_config": dict(simulation.spm_config),
"spm_geography_source": spm_geography_source,
"spm": calculation_provenance(simulation),
}
return result
40 changes: 40 additions & 0 deletions src/policyengine/tax_benefit_models/us/spm.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,18 @@
__all__ = [
"SPMSelection",
"SPMProvenance",
"SPM_GEOGRAPHY_SOURCES",
"resolve_spm_selection",
"resolve_household_spm_selection",
"calculation_provenance",
]

# How a household calculation's SPM geography was chosen, reported as
# ``provenance["spm_geography_source"]``: the caller's own ``geography_kind``,
# the bundle default as given, or national measurement in place of the default
# county selection because the household names no county.
SPM_GEOGRAPHY_SOURCES = ("selection", "default", "national_fallback")


def resolve_spm_selection(selection=None) -> dict:
"""Resolve public options against an independently pinned bundle artifact."""
Expand Down Expand Up @@ -43,6 +51,38 @@ def resolve_spm_selection(selection=None) -> dict:
return SPMSelection.model_validate(values).model_dump()


def resolve_household_spm_selection(
selection=None, *, household_names_county: bool
) -> tuple[dict, str]:
"""Resolve one household calculation's SPM selection and how it was chosen.

A ``geography_kind`` the caller chose is honoured as given, so an explicit
county selection for a household with no county still raises
``SPM_GEOGRAPHY_REQUIRED``. Otherwise the bundle default applies, except
that the default county selection becomes national measurement when the
household names no county. A state alone does not identify a Census SPM
estimation area, and national measurement is the one geography that needs
no area. The substitution is reported in the returned source, never made
silently.

Every other setting, such as ``scenario``, is kept. Population simulations
do not use this function: their data must supply observed counties.

Returns the resolved configuration and one of ``SPM_GEOGRAPHY_SOURCES``.
"""
chosen = SPMSelection.model_validate({} if selection is None else selection)
# Resolve before deciding: this also rejects a selection that asserts a
# different artifact hash, whichever geography is finally used.
config = resolve_spm_selection(chosen)
if "geography_kind" in chosen.model_fields_set:
return config, "selection"
if config["geography_kind"] != "county" or household_names_county:
return config, "default"
# ``model_dump`` keeps only the settings the caller chose.
national = {**chosen.model_dump(), "geography_kind": "national"}
return resolve_spm_selection(national), "national_fallback"


def calculation_provenance(simulation) -> dict:
return SPMProvenance.model_validate(simulation.spm_provenance()).model_dump(
mode="json"
Expand Down
Loading
Loading