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 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
+
@@ -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}.")