microunit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- microunit/__init__.py +61 -0
- microunit/core.py +189 -0
- microunit/data/dependent_gross_income_limit.yaml +88 -0
- microunit/diagnostics.py +82 -0
- microunit/py.typed +1 -0
- microunit/registry.py +51 -0
- microunit/rule_helpers.py +155 -0
- microunit/tax_unit_construction.py +891 -0
- microunit/units/__init__.py +26 -0
- microunit/units/_helpers.py +22 -0
- microunit/units/medicaid.py +44 -0
- microunit/units/passthrough.py +27 -0
- microunit/units/programs.py +56 -0
- microunit/units/snap.py +53 -0
- microunit/units/spm.py +57 -0
- microunit/units/tax.py +88 -0
- microunit-0.1.0.dist-info/METADATA +183 -0
- microunit-0.1.0.dist-info/RECORD +20 -0
- microunit-0.1.0.dist-info/WHEEL +4 -0
- microunit-0.1.0.dist-info/licenses/LICENSE +21 -0
microunit/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Microdata unit assignment primitives."""
|
|
2
|
+
|
|
3
|
+
from microunit.core import EgoUnitMembership, UnitPartition
|
|
4
|
+
from microunit.diagnostics import PartitionMatchReport, partition_match_report
|
|
5
|
+
from microunit.registry import UnitKind, UnitScheme, get_scheme, list_schemes
|
|
6
|
+
from microunit.rule_helpers import (
|
|
7
|
+
REFERENCE_PERSON_CODES,
|
|
8
|
+
REFERENCE_QUALIFYING_CHILD_CODES,
|
|
9
|
+
REFERENCE_QUALIFYING_RELATIVE_CODES,
|
|
10
|
+
REFERENCE_SPOUSE_CODES,
|
|
11
|
+
CPSRelationshipCode,
|
|
12
|
+
dependent_gross_income_limit,
|
|
13
|
+
qualifying_child_age_test,
|
|
14
|
+
reference_relationship_allows_qualifying_child,
|
|
15
|
+
reference_relationship_allows_qualifying_relative,
|
|
16
|
+
related_to_head_or_spouse,
|
|
17
|
+
)
|
|
18
|
+
from microunit.tax_unit_construction import (
|
|
19
|
+
CENSUS_DOCUMENTED_MODE,
|
|
20
|
+
DEPENDENT,
|
|
21
|
+
HEAD,
|
|
22
|
+
POLICYENGINE_MODE,
|
|
23
|
+
SPOUSE,
|
|
24
|
+
SUPPORTED_TAX_UNIT_CONSTRUCTION_MODES,
|
|
25
|
+
construct_tax_units,
|
|
26
|
+
estimate_dependent_gross_income,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"__version__",
|
|
33
|
+
# Core containers
|
|
34
|
+
"EgoUnitMembership",
|
|
35
|
+
"PartitionMatchReport",
|
|
36
|
+
"UnitKind",
|
|
37
|
+
"UnitPartition",
|
|
38
|
+
"UnitScheme",
|
|
39
|
+
"get_scheme",
|
|
40
|
+
"list_schemes",
|
|
41
|
+
"partition_match_report",
|
|
42
|
+
# Rules-based tax-unit construction engine
|
|
43
|
+
"construct_tax_units",
|
|
44
|
+
"estimate_dependent_gross_income",
|
|
45
|
+
"HEAD",
|
|
46
|
+
"SPOUSE",
|
|
47
|
+
"DEPENDENT",
|
|
48
|
+
"POLICYENGINE_MODE",
|
|
49
|
+
"CENSUS_DOCUMENTED_MODE",
|
|
50
|
+
"SUPPORTED_TAX_UNIT_CONSTRUCTION_MODES",
|
|
51
|
+
"CPSRelationshipCode",
|
|
52
|
+
"REFERENCE_PERSON_CODES",
|
|
53
|
+
"REFERENCE_SPOUSE_CODES",
|
|
54
|
+
"REFERENCE_QUALIFYING_CHILD_CODES",
|
|
55
|
+
"REFERENCE_QUALIFYING_RELATIVE_CODES",
|
|
56
|
+
"dependent_gross_income_limit",
|
|
57
|
+
"qualifying_child_age_test",
|
|
58
|
+
"reference_relationship_allows_qualifying_child",
|
|
59
|
+
"reference_relationship_allows_qualifying_relative",
|
|
60
|
+
"related_to_head_or_spouse",
|
|
61
|
+
]
|
microunit/core.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Core unit assignment containers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable, Iterable, Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _series(values: pd.Series | Iterable[Hashable], name: str) -> pd.Series:
|
|
12
|
+
if isinstance(values, pd.Series):
|
|
13
|
+
return values.rename(name)
|
|
14
|
+
return pd.Series(list(values), name=name)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class UnitPartition:
|
|
19
|
+
"""A policy-unit assignment with exactly one unit per person."""
|
|
20
|
+
|
|
21
|
+
unit_type: str
|
|
22
|
+
person_id: pd.Series
|
|
23
|
+
unit_id: pd.Series
|
|
24
|
+
role: pd.Series | None = None
|
|
25
|
+
source: str | None = None
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
person_id = _series(self.person_id, "person_id")
|
|
29
|
+
unit_id = _series(self.unit_id, "unit_id")
|
|
30
|
+
|
|
31
|
+
if len(person_id) != len(unit_id):
|
|
32
|
+
raise ValueError("person_id and unit_id must have the same length")
|
|
33
|
+
if person_id.isna().any():
|
|
34
|
+
raise ValueError("person_id cannot contain missing values")
|
|
35
|
+
if unit_id.isna().any():
|
|
36
|
+
raise ValueError("unit_id cannot contain missing values")
|
|
37
|
+
if person_id.duplicated().any():
|
|
38
|
+
duplicates = person_id[person_id.duplicated()].unique().tolist()
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"person_id must be unique, found duplicates: {duplicates}"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
object.__setattr__(self, "person_id", person_id.reset_index(drop=True))
|
|
44
|
+
object.__setattr__(self, "unit_id", unit_id.reset_index(drop=True))
|
|
45
|
+
|
|
46
|
+
if self.role is not None:
|
|
47
|
+
role = _series(self.role, "role")
|
|
48
|
+
if len(role) != len(person_id):
|
|
49
|
+
raise ValueError("role must have the same length as person_id")
|
|
50
|
+
object.__setattr__(self, "role", role.reset_index(drop=True))
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_frame(
|
|
54
|
+
cls,
|
|
55
|
+
frame: pd.DataFrame,
|
|
56
|
+
unit_type: str,
|
|
57
|
+
person_col: str = "person_id",
|
|
58
|
+
unit_col: str = "unit_id",
|
|
59
|
+
role_col: str | None = None,
|
|
60
|
+
source: str | None = None,
|
|
61
|
+
) -> UnitPartition:
|
|
62
|
+
"""Build a partition from columns in a person-level frame."""
|
|
63
|
+
|
|
64
|
+
role = frame[role_col] if role_col is not None else None
|
|
65
|
+
return cls(
|
|
66
|
+
unit_type=unit_type,
|
|
67
|
+
person_id=frame[person_col],
|
|
68
|
+
unit_id=frame[unit_col],
|
|
69
|
+
role=role,
|
|
70
|
+
source=source,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def n_persons(self) -> int:
|
|
75
|
+
return len(self.person_id)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def n_units(self) -> int:
|
|
79
|
+
return int(self.unit_id.nunique())
|
|
80
|
+
|
|
81
|
+
def to_frame(self) -> pd.DataFrame:
|
|
82
|
+
"""Return person-level unit assignments."""
|
|
83
|
+
|
|
84
|
+
frame = pd.DataFrame(
|
|
85
|
+
{
|
|
86
|
+
"person_id": self.person_id,
|
|
87
|
+
"unit_id": self.unit_id,
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
if self.role is not None:
|
|
91
|
+
frame["role"] = self.role
|
|
92
|
+
return frame
|
|
93
|
+
|
|
94
|
+
def members(self) -> dict[Hashable, tuple[Hashable, ...]]:
|
|
95
|
+
"""Return unit members keyed by unit ID."""
|
|
96
|
+
|
|
97
|
+
frame = self.to_frame()
|
|
98
|
+
grouped = frame.groupby("unit_id", sort=False)["person_id"]
|
|
99
|
+
return {unit_id: tuple(group.tolist()) for unit_id, group in grouped}
|
|
100
|
+
|
|
101
|
+
def unit_sizes(self) -> pd.Series:
|
|
102
|
+
"""Return the number of people in each unit."""
|
|
103
|
+
|
|
104
|
+
return self.unit_id.value_counts(sort=False)
|
|
105
|
+
|
|
106
|
+
def relabel(self, prefix: str = "unit_") -> UnitPartition:
|
|
107
|
+
"""Return a copy with dense, stable unit IDs in encounter order."""
|
|
108
|
+
|
|
109
|
+
codes = pd.factorize(self.unit_id, sort=False)[0]
|
|
110
|
+
unit_id = pd.Series([f"{prefix}{code + 1}" for code in codes])
|
|
111
|
+
return UnitPartition(
|
|
112
|
+
unit_type=self.unit_type,
|
|
113
|
+
person_id=self.person_id,
|
|
114
|
+
unit_id=unit_id,
|
|
115
|
+
role=self.role,
|
|
116
|
+
source=self.source,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class EgoUnitMembership:
|
|
122
|
+
"""A possibly-overlapping unit assignment for each focal person."""
|
|
123
|
+
|
|
124
|
+
unit_type: str
|
|
125
|
+
focal_person_id: pd.Series
|
|
126
|
+
member_person_id: pd.Series
|
|
127
|
+
role: pd.Series | None = None
|
|
128
|
+
source: str | None = None
|
|
129
|
+
|
|
130
|
+
def __post_init__(self) -> None:
|
|
131
|
+
focal = _series(self.focal_person_id, "focal_person_id")
|
|
132
|
+
member = _series(self.member_person_id, "member_person_id")
|
|
133
|
+
|
|
134
|
+
if len(focal) != len(member):
|
|
135
|
+
raise ValueError("focal_person_id and member_person_id must align")
|
|
136
|
+
if focal.isna().any() or member.isna().any():
|
|
137
|
+
raise ValueError("ego unit memberships cannot contain missing IDs")
|
|
138
|
+
|
|
139
|
+
pairs = pd.DataFrame({"focal": focal, "member": member})
|
|
140
|
+
if pairs.duplicated().any():
|
|
141
|
+
raise ValueError("ego unit memberships cannot contain duplicate pairs")
|
|
142
|
+
|
|
143
|
+
object.__setattr__(self, "focal_person_id", focal.reset_index(drop=True))
|
|
144
|
+
object.__setattr__(self, "member_person_id", member.reset_index(drop=True))
|
|
145
|
+
|
|
146
|
+
if self.role is not None:
|
|
147
|
+
role = _series(self.role, "role")
|
|
148
|
+
if len(role) != len(focal):
|
|
149
|
+
raise ValueError("role must have the same length as memberships")
|
|
150
|
+
object.__setattr__(self, "role", role.reset_index(drop=True))
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def from_mapping(
|
|
154
|
+
cls,
|
|
155
|
+
unit_type: str,
|
|
156
|
+
memberships: Mapping[Hashable, Iterable[Hashable]],
|
|
157
|
+
source: str | None = None,
|
|
158
|
+
) -> EgoUnitMembership:
|
|
159
|
+
"""Build overlapping units from focal-person membership sets."""
|
|
160
|
+
|
|
161
|
+
focal_ids: list[Hashable] = []
|
|
162
|
+
member_ids: list[Hashable] = []
|
|
163
|
+
for focal, members in memberships.items():
|
|
164
|
+
for member in members:
|
|
165
|
+
focal_ids.append(focal)
|
|
166
|
+
member_ids.append(member)
|
|
167
|
+
return cls(
|
|
168
|
+
unit_type, pd.Series(focal_ids), pd.Series(member_ids), source=source
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def to_frame(self) -> pd.DataFrame:
|
|
172
|
+
"""Return membership rows keyed by focal person and member person."""
|
|
173
|
+
|
|
174
|
+
frame = pd.DataFrame(
|
|
175
|
+
{
|
|
176
|
+
"focal_person_id": self.focal_person_id,
|
|
177
|
+
"member_person_id": self.member_person_id,
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
if self.role is not None:
|
|
181
|
+
frame["role"] = self.role
|
|
182
|
+
return frame
|
|
183
|
+
|
|
184
|
+
def members_for(self, focal_person_id: Hashable) -> tuple[Hashable, ...]:
|
|
185
|
+
frame = self.to_frame()
|
|
186
|
+
members = frame.loc[
|
|
187
|
+
frame["focal_person_id"] == focal_person_id, "member_person_id"
|
|
188
|
+
]
|
|
189
|
+
return tuple(members.tolist())
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
description: >-
|
|
2
|
+
Personal and dependent exemption amount under IRC 151(d). TCJA set the
|
|
3
|
+
deduction to $0 from 2018 (made permanent by OBBB), but the underlying
|
|
4
|
+
amount continues to be inflation-adjusted and published in annual Rev. Proc.
|
|
5
|
+
for other provisions that reference it, such as the qualifying relative
|
|
6
|
+
gross income test under IRC 152(d)(1)(B). The deduction suspension is
|
|
7
|
+
represented separately in gov.irs.income.exemption.suspended.
|
|
8
|
+
metadata:
|
|
9
|
+
unit: currency-USD
|
|
10
|
+
uprating: gov.irs.uprating
|
|
11
|
+
period: year
|
|
12
|
+
reference:
|
|
13
|
+
- title: 26 U.S. Code § 151(d)(1) - Exemption amount
|
|
14
|
+
href: https://www.law.cornell.edu/uscode/text/26/151#d_1
|
|
15
|
+
- title: IRS Notice 2018-70 - Guidance on qualifying relative exemption amount
|
|
16
|
+
href: https://www.irs.gov/pub/irs-drop/n-18-70.pdf
|
|
17
|
+
values:
|
|
18
|
+
2013-01-01:
|
|
19
|
+
value: 3_900
|
|
20
|
+
reference:
|
|
21
|
+
- title: Rev. Proc. 2013-15
|
|
22
|
+
href: https://www.irs.gov/pub/irs-drop/rp-13-15.pdf
|
|
23
|
+
2014-01-01:
|
|
24
|
+
value: 3_950
|
|
25
|
+
reference:
|
|
26
|
+
- title: Rev. Proc. 2013-35
|
|
27
|
+
href: https://www.irs.gov/pub/irs-drop/rp-13-35.pdf
|
|
28
|
+
2015-01-01:
|
|
29
|
+
value: 4_000
|
|
30
|
+
reference:
|
|
31
|
+
- title: Rev. Proc. 2014-61
|
|
32
|
+
href: https://www.irs.gov/pub/irs-drop/rp-14-61.pdf
|
|
33
|
+
2016-01-01:
|
|
34
|
+
value: 4_050
|
|
35
|
+
reference:
|
|
36
|
+
- title: Rev. Proc. 2015-53
|
|
37
|
+
href: https://www.irs.gov/pub/irs-drop/rp-15-53.pdf
|
|
38
|
+
2017-01-01:
|
|
39
|
+
value: 4_050
|
|
40
|
+
reference:
|
|
41
|
+
- title: Rev. Proc. 2016-55
|
|
42
|
+
href: https://www.irs.gov/pub/irs-drop/rp-16-55.pdf
|
|
43
|
+
2018-01-01:
|
|
44
|
+
value: 4_150
|
|
45
|
+
reference:
|
|
46
|
+
- title: Rev. Proc. 2017-58
|
|
47
|
+
href: https://www.irs.gov/pub/irs-drop/rp-17-58.pdf
|
|
48
|
+
2019-01-01:
|
|
49
|
+
value: 4_200
|
|
50
|
+
reference:
|
|
51
|
+
- title: Rev. Proc. 2018-57
|
|
52
|
+
href: https://www.irs.gov/pub/irs-drop/rp-18-57.pdf
|
|
53
|
+
2020-01-01:
|
|
54
|
+
value: 4_300
|
|
55
|
+
reference:
|
|
56
|
+
- title: Rev. Proc. 2019-44
|
|
57
|
+
href: https://www.irs.gov/pub/irs-drop/rp-19-44.pdf
|
|
58
|
+
2021-01-01:
|
|
59
|
+
value: 4_300
|
|
60
|
+
reference:
|
|
61
|
+
- title: Rev. Proc. 2020-45
|
|
62
|
+
href: https://www.irs.gov/pub/irs-drop/rp-20-45.pdf
|
|
63
|
+
2022-01-01:
|
|
64
|
+
value: 4_400
|
|
65
|
+
reference:
|
|
66
|
+
- title: Rev. Proc. 2021-45
|
|
67
|
+
href: https://www.irs.gov/pub/irs-drop/rp-21-45.pdf
|
|
68
|
+
2023-01-01:
|
|
69
|
+
value: 4_700
|
|
70
|
+
reference:
|
|
71
|
+
- title: Rev. Proc. 2022-38
|
|
72
|
+
href: https://www.irs.gov/pub/irs-drop/rp-22-38.pdf
|
|
73
|
+
2024-01-01:
|
|
74
|
+
value: 5_050
|
|
75
|
+
reference:
|
|
76
|
+
- title: Rev. Proc. 2023-34
|
|
77
|
+
href: https://www.irs.gov/pub/irs-drop/rp-23-34.pdf
|
|
78
|
+
2025-01-01:
|
|
79
|
+
value: 5_200
|
|
80
|
+
reference:
|
|
81
|
+
- title: Rev. Proc. 2024-40
|
|
82
|
+
href: https://www.irs.gov/pub/irs-drop/rp-24-40.pdf
|
|
83
|
+
2026-01-01:
|
|
84
|
+
value: 5_300
|
|
85
|
+
reference:
|
|
86
|
+
- title: Rev. Proc. 2025-32
|
|
87
|
+
href: https://www.irs.gov/pub/irs-drop/rp-25-32.pdf
|
|
88
|
+
|
microunit/diagnostics.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Diagnostics for comparing unit assignments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from microunit.core import UnitPartition
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class PartitionMatchReport:
|
|
15
|
+
"""Household-level comparison between two partitions."""
|
|
16
|
+
|
|
17
|
+
group_count: int
|
|
18
|
+
matched_group_count: int
|
|
19
|
+
person_count: int
|
|
20
|
+
persons_in_matched_groups: int
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def group_match_rate(self) -> float:
|
|
24
|
+
if self.group_count == 0:
|
|
25
|
+
return 0.0
|
|
26
|
+
return self.matched_group_count / self.group_count
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def person_match_rate(self) -> float:
|
|
30
|
+
if self.person_count == 0:
|
|
31
|
+
return 0.0
|
|
32
|
+
return self.persons_in_matched_groups / self.person_count
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _signature(
|
|
36
|
+
person_id: pd.Series, unit_id: pd.Series
|
|
37
|
+
) -> frozenset[frozenset[Hashable]]:
|
|
38
|
+
frame = pd.DataFrame({"person_id": person_id, "unit_id": unit_id})
|
|
39
|
+
return frozenset(
|
|
40
|
+
frozenset(group["person_id"].tolist())
|
|
41
|
+
for _, group in frame.groupby("unit_id", sort=False)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def partition_match_report(
|
|
46
|
+
reference: UnitPartition,
|
|
47
|
+
candidate: UnitPartition,
|
|
48
|
+
group_id: pd.Series,
|
|
49
|
+
) -> PartitionMatchReport:
|
|
50
|
+
"""Compare two partitions within household-like groups.
|
|
51
|
+
|
|
52
|
+
Unit IDs are arbitrary labels, so this compares each group's partition of
|
|
53
|
+
people rather than literal unit ID values.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
group_id = group_id.rename("group_id")
|
|
57
|
+
if len(group_id) != reference.n_persons:
|
|
58
|
+
raise ValueError("group_id must have the same length as the reference")
|
|
59
|
+
|
|
60
|
+
ref = reference.to_frame().rename(columns={"unit_id": "reference_unit_id"})
|
|
61
|
+
cand = candidate.to_frame().rename(columns={"unit_id": "candidate_unit_id"})
|
|
62
|
+
frame = ref.merge(cand, on="person_id", how="inner", validate="one_to_one")
|
|
63
|
+
if len(frame) != reference.n_persons:
|
|
64
|
+
raise ValueError("reference and candidate must contain the same person IDs")
|
|
65
|
+
|
|
66
|
+
frame["group_id"] = group_id.reset_index(drop=True)
|
|
67
|
+
|
|
68
|
+
matched_groups = 0
|
|
69
|
+
persons_in_matched_groups = 0
|
|
70
|
+
for _, group in frame.groupby("group_id", sort=False):
|
|
71
|
+
ref_sig = _signature(group["person_id"], group["reference_unit_id"])
|
|
72
|
+
cand_sig = _signature(group["person_id"], group["candidate_unit_id"])
|
|
73
|
+
if ref_sig == cand_sig:
|
|
74
|
+
matched_groups += 1
|
|
75
|
+
persons_in_matched_groups += len(group)
|
|
76
|
+
|
|
77
|
+
return PartitionMatchReport(
|
|
78
|
+
group_count=int(frame["group_id"].nunique()),
|
|
79
|
+
matched_group_count=matched_groups,
|
|
80
|
+
person_count=len(frame),
|
|
81
|
+
persons_in_matched_groups=persons_in_matched_groups,
|
|
82
|
+
)
|
microunit/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
microunit/registry.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Metadata for known policy-unit schemes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
UnitKind = Literal["partition", "ego"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class UnitScheme:
|
|
13
|
+
name: str
|
|
14
|
+
kind: UnitKind
|
|
15
|
+
description: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_SCHEMES: dict[str, UnitScheme] = {
|
|
19
|
+
"spm": UnitScheme(
|
|
20
|
+
name="spm",
|
|
21
|
+
kind="partition",
|
|
22
|
+
description="Supplemental Poverty Measure resource unit.",
|
|
23
|
+
),
|
|
24
|
+
"tax": UnitScheme(
|
|
25
|
+
name="tax",
|
|
26
|
+
kind="partition",
|
|
27
|
+
description="Federal income tax filing/dependency unit.",
|
|
28
|
+
),
|
|
29
|
+
"snap": UnitScheme(
|
|
30
|
+
name="snap",
|
|
31
|
+
kind="partition",
|
|
32
|
+
description="SNAP household assignment within a physical household.",
|
|
33
|
+
),
|
|
34
|
+
"medicaid_magi": UnitScheme(
|
|
35
|
+
name="medicaid_magi",
|
|
36
|
+
kind="ego",
|
|
37
|
+
description="Focal-person Medicaid MAGI household.",
|
|
38
|
+
),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_scheme(name: str) -> UnitScheme:
|
|
43
|
+
try:
|
|
44
|
+
return _SCHEMES[name]
|
|
45
|
+
except KeyError as exc:
|
|
46
|
+
known = ", ".join(sorted(_SCHEMES))
|
|
47
|
+
raise KeyError(f"Unknown unit scheme {name!r}. Known schemes: {known}") from exc
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def list_schemes() -> tuple[UnitScheme, ...]:
|
|
51
|
+
return tuple(_SCHEMES[name] for name in sorted(_SCHEMES))
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Rules-based helpers for tax-unit construction.
|
|
2
|
+
|
|
3
|
+
These helpers encode the federal dependency and filing rules used to assign
|
|
4
|
+
people into tax units: the qualifying-child age test, the
|
|
5
|
+
relationship-to-reference-person tests for qualifying children and qualifying
|
|
6
|
+
relatives, and the qualifying-relative gross income limit (the personal- and
|
|
7
|
+
dependent-exemption amount under IRC 151(d), used by the IRC 152(d)(1)(B)
|
|
8
|
+
gross income test).
|
|
9
|
+
|
|
10
|
+
The CPS relationship codes mirror the Census ``A_EXPRRP`` recode used in the
|
|
11
|
+
ASEC. Consumers that start from a different relationship coding (for example
|
|
12
|
+
ACS ``RELSHIPP``) are expected to map onto these codes before calling
|
|
13
|
+
:func:`microunit.construct_tax_units`.
|
|
14
|
+
|
|
15
|
+
The gross income limit is read from package data
|
|
16
|
+
(``data/dependent_gross_income_limit.yaml``) so the package is self-contained
|
|
17
|
+
and does not depend on ``policyengine-us`` being installed.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from enum import IntEnum
|
|
23
|
+
from functools import cache
|
|
24
|
+
from importlib import resources
|
|
25
|
+
|
|
26
|
+
import yaml
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CPSRelationshipCode(IntEnum):
|
|
30
|
+
"""CPS ASEC relationship-to-reference-person recode (``A_EXPRRP``)."""
|
|
31
|
+
|
|
32
|
+
REFERENCE_PERSON_WITH_RELATIVES = 1
|
|
33
|
+
REFERENCE_PERSON_WITHOUT_RELATIVES = 2
|
|
34
|
+
HUSBAND = 3
|
|
35
|
+
WIFE = 4
|
|
36
|
+
OWN_CHILD = 5
|
|
37
|
+
GRANDCHILD = 7
|
|
38
|
+
PARENT = 8
|
|
39
|
+
SIBLING = 9
|
|
40
|
+
OTHER_RELATIVE = 10
|
|
41
|
+
FOSTER_CHILD = 11
|
|
42
|
+
NONRELATIVE_WITH_RELATIVES = 12
|
|
43
|
+
PARTNER_OR_ROOMMATE = 13
|
|
44
|
+
NONRELATIVE_WITHOUT_RELATIVES = 14
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
REFERENCE_PERSON_CODES = frozenset(
|
|
48
|
+
{
|
|
49
|
+
CPSRelationshipCode.REFERENCE_PERSON_WITH_RELATIVES,
|
|
50
|
+
CPSRelationshipCode.REFERENCE_PERSON_WITHOUT_RELATIVES,
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
REFERENCE_SPOUSE_CODES = frozenset(
|
|
55
|
+
{
|
|
56
|
+
CPSRelationshipCode.HUSBAND,
|
|
57
|
+
CPSRelationshipCode.WIFE,
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
REFERENCE_QUALIFYING_CHILD_CODES = frozenset(
|
|
62
|
+
{
|
|
63
|
+
CPSRelationshipCode.OWN_CHILD,
|
|
64
|
+
CPSRelationshipCode.GRANDCHILD,
|
|
65
|
+
CPSRelationshipCode.SIBLING,
|
|
66
|
+
CPSRelationshipCode.FOSTER_CHILD,
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
REFERENCE_QUALIFYING_RELATIVE_CODES = frozenset(
|
|
71
|
+
{
|
|
72
|
+
CPSRelationshipCode.OWN_CHILD,
|
|
73
|
+
CPSRelationshipCode.GRANDCHILD,
|
|
74
|
+
CPSRelationshipCode.PARENT,
|
|
75
|
+
CPSRelationshipCode.SIBLING,
|
|
76
|
+
CPSRelationshipCode.OTHER_RELATIVE,
|
|
77
|
+
CPSRelationshipCode.FOSTER_CHILD,
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def qualifying_child_age_test(
|
|
83
|
+
age: int | float,
|
|
84
|
+
is_full_time_student: bool = False,
|
|
85
|
+
is_permanently_disabled: bool = False,
|
|
86
|
+
non_student_age_limit: int = 19,
|
|
87
|
+
student_age_limit: int = 24,
|
|
88
|
+
) -> bool:
|
|
89
|
+
if is_permanently_disabled:
|
|
90
|
+
return True
|
|
91
|
+
age_limit = student_age_limit if is_full_time_student else non_student_age_limit
|
|
92
|
+
return float(age) < age_limit
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _relationship_from_code(relationship_code: int | None):
|
|
96
|
+
if relationship_code is None:
|
|
97
|
+
return None
|
|
98
|
+
try:
|
|
99
|
+
return CPSRelationshipCode(int(relationship_code))
|
|
100
|
+
except ValueError:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def reference_relationship_allows_qualifying_child(
|
|
105
|
+
relationship_code: int | None,
|
|
106
|
+
) -> bool:
|
|
107
|
+
relationship = _relationship_from_code(relationship_code)
|
|
108
|
+
return relationship in REFERENCE_QUALIFYING_CHILD_CODES
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def reference_relationship_allows_qualifying_relative(
|
|
112
|
+
relationship_code: int | None,
|
|
113
|
+
) -> bool:
|
|
114
|
+
relationship = _relationship_from_code(relationship_code)
|
|
115
|
+
return relationship in REFERENCE_QUALIFYING_RELATIVE_CODES
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def related_to_head_or_spouse(relationship_code: int | None) -> bool:
|
|
119
|
+
relationship = _relationship_from_code(relationship_code)
|
|
120
|
+
return relationship in (
|
|
121
|
+
REFERENCE_PERSON_CODES
|
|
122
|
+
| REFERENCE_SPOUSE_CODES
|
|
123
|
+
| REFERENCE_QUALIFYING_RELATIVE_CODES
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@cache
|
|
128
|
+
def _gross_income_limit_values() -> dict:
|
|
129
|
+
parameter_path = (
|
|
130
|
+
resources.files("microunit") / "data" / "dependent_gross_income_limit.yaml"
|
|
131
|
+
)
|
|
132
|
+
with parameter_path.open("r", encoding="utf-8") as f:
|
|
133
|
+
return yaml.safe_load(f)["values"]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@cache
|
|
137
|
+
def dependent_gross_income_limit(year: int) -> float:
|
|
138
|
+
values = _gross_income_limit_values()
|
|
139
|
+
|
|
140
|
+
def _period_year(period) -> int:
|
|
141
|
+
if hasattr(period, "year"):
|
|
142
|
+
return int(period.year)
|
|
143
|
+
return int(str(period)[:4])
|
|
144
|
+
|
|
145
|
+
applicable_years = sorted(
|
|
146
|
+
_period_year(period) for period in values if _period_year(period) <= year
|
|
147
|
+
)
|
|
148
|
+
if not applicable_years:
|
|
149
|
+
raise ValueError(f"No dependent gross income limit configured for {year}.")
|
|
150
|
+
|
|
151
|
+
selected_year = applicable_years[-1]
|
|
152
|
+
for period, entry in values.items():
|
|
153
|
+
if _period_year(period) == selected_year:
|
|
154
|
+
return float(entry["value"])
|
|
155
|
+
raise ValueError(f"No dependent gross income limit configured for {year}.")
|