spm-calculator 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 PolicyEngine
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.4
2
+ Name: spm-calculator
3
+ Version: 0.1.0
4
+ Summary: Calculate SPM (Supplemental Poverty Measure) thresholds for any US geography and year
5
+ Author-email: PolicyEngine <hello@policyengine.org>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/PolicyEngine/spm-calculator
8
+ Project-URL: Repository, https://github.com/PolicyEngine/spm-calculator
9
+ Project-URL: Issues, https://github.com/PolicyEngine/spm-calculator/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Scientific/Engineering
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: pandas>=2.0
22
+ Requires-Dist: numpy>=1.24
23
+ Requires-Dist: requests>=2.28
24
+ Requires-Dist: census>=0.8
25
+ Requires-Dist: us>=3.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0; extra == "dev"
28
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
29
+ Requires-Dist: black>=23.0; extra == "dev"
30
+ Requires-Dist: ruff>=0.1; extra == "dev"
31
+ Provides-Extra: app
32
+ Requires-Dist: streamlit>=1.30; extra == "app"
33
+ Dynamic: license-file
34
+
35
+ # spm-calculator
36
+
37
+ Calculate [Supplemental Poverty Measure (SPM)](https://www.census.gov/topics/income-poverty/supplemental-poverty-measure.html) thresholds for any US geography and year.
38
+
39
+ [![Try the Calculator](https://img.shields.io/badge/Try-Calculator-teal)](https://policyengine.github.io/spm-calculator)
40
+ [![Documentation](https://img.shields.io/badge/docs-online-green)](https://policyengine.github.io/spm-calculator)
41
+
42
+ ## Interactive Calculator
43
+
44
+ **[Try the SPM Threshold Calculator](https://policyengine.github.io/spm-calculator)** - A static web app that walks you through calculating your SPM threshold based on your household characteristics.
45
+
46
+ The calculator runs entirely in your browser with no server required - all data is pre-computed and bundled.
47
+
48
+ ### Run Locally
49
+
50
+ **Static React App (recommended):**
51
+ ```bash
52
+ cd web
53
+ npm install
54
+ npm run dev
55
+ ```
56
+
57
+ **Streamlit App (alternative):**
58
+ ```bash
59
+ pip install spm-calculator[app]
60
+ streamlit run app/streamlit_app.py
61
+ ```
62
+
63
+ ## Overview
64
+
65
+ The SPM threshold is calculated as:
66
+
67
+ ```
68
+ threshold = base_threshold[tenure] × equivalence_scale × geoadj
69
+ ```
70
+
71
+ Where:
72
+ - **base_threshold** varies by housing tenure (renter, owner with mortgage, owner without mortgage), calculated from 5-year rolling Consumer Expenditure Survey data
73
+ - **equivalence_scale** adjusts for family composition using the SPM three-parameter scale
74
+ - **geoadj** adjusts for local housing costs based on ACS median rents
75
+
76
+ ## Installation
77
+
78
+ ```bash
79
+ pip install spm-calculator
80
+ ```
81
+
82
+ ## Quick Start
83
+
84
+ ```python
85
+ from spm_calculator import SPMCalculator
86
+
87
+ # Initialize calculator for a specific year
88
+ calc = SPMCalculator(year=2024)
89
+
90
+ # Get base thresholds by tenure (national, before geographic adjustment)
91
+ base = calc.get_base_thresholds()
92
+ # {'renter': 39430, 'owner_with_mortgage': 39068, 'owner_without_mortgage': 32586}
93
+
94
+ # Get GEOADJ for a specific geography
95
+ geoadj = calc.get_geoadj("congressional_district", "0612") # CA-12
96
+ # 1.24 (24% above national average due to high housing costs)
97
+
98
+ # Calculate threshold for a specific family in a specific location
99
+ threshold = calc.calculate_threshold(
100
+ num_adults=2,
101
+ num_children=2,
102
+ tenure="renter",
103
+ geography_type="congressional_district",
104
+ geography_id="0612"
105
+ )
106
+ # ~$48,893 (base $39,430 × equiv_scale 1.0 × geoadj 1.24)
107
+ ```
108
+
109
+ ## Supported Geographies
110
+
111
+ - `nation` - National average
112
+ - `state` - 50 states + DC
113
+ - `county` - ~3,200 counties
114
+ - `metro_area` - Metropolitan statistical areas
115
+ - `congressional_district` - 435 congressional districts
116
+ - `puma` - Public Use Microdata Areas
117
+ - `tract` - Census tracts (limited availability)
118
+
119
+ ## Data Sources
120
+
121
+ - **Base thresholds**: [BLS Consumer Expenditure Survey](https://www.bls.gov/cex/) - 5-year rolling FCSUti (Food, Clothing, Shelter, Utilities, telephone, internet)
122
+ - **Geographic adjustment**: [ACS 5-Year Estimates](https://www.census.gov/programs-surveys/acs) - Table B25031 (Median Gross Rent by Bedrooms)
123
+ - **Methodology**: [Census SPM Technical Documentation](https://www2.census.gov/programs-surveys/supplemental-poverty-measure/datasets/spm/spm_techdoc.pdf)
124
+
125
+ ## Methodology
126
+
127
+ ### Base Threshold Calculation
128
+
129
+ Following BLS methodology (updated September 2021):
130
+ 1. Download 5 years of CE Survey PUMD (Public Use Microdata)
131
+ 2. Filter to consumer units with children
132
+ 3. Calculate FCSUti expenditures
133
+ 4. Convert to reference family (2 adults, 2 children) using equivalence scale
134
+ 5. Calculate 83% of median (47th-53rd percentile average) by tenure type
135
+
136
+ ### Geographic Adjustment (GEOADJ)
137
+
138
+ Following Census methodology:
139
+ ```
140
+ GEOADJ = (local_median_rent / national_median_rent) × 0.492 + 0.508
141
+ ```
142
+
143
+ Where 0.492 is the housing portion of the SPM threshold for renters.
144
+
145
+ ### Equivalence Scale
146
+
147
+ The SPM uses a three-parameter equivalence scale:
148
+ - First adult: 1.0
149
+ - Additional adults: 0.5 each
150
+ - Children: 0.3 each
151
+ - Normalized to reference family (2A2C = 1.0)
152
+
153
+ ## Validation
154
+
155
+ Base thresholds are validated against [BLS published values](https://www.bls.gov/pir/spm/spm_thresholds_2024.htm):
156
+
157
+ | Tenure | 2024 BLS | Calculator |
158
+ |--------|----------|------------|
159
+ | Renter | $39,430 | $39,430 |
160
+ | Owner w/ mortgage | $39,068 | $39,068 |
161
+ | Owner w/o mortgage | $32,586 | $32,586 |
162
+
163
+ ## License
164
+
165
+ MIT License - see [LICENSE](LICENSE) for details.
166
+
167
+ ## Contributing
168
+
169
+ Contributions welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
@@ -0,0 +1,135 @@
1
+ # spm-calculator
2
+
3
+ Calculate [Supplemental Poverty Measure (SPM)](https://www.census.gov/topics/income-poverty/supplemental-poverty-measure.html) thresholds for any US geography and year.
4
+
5
+ [![Try the Calculator](https://img.shields.io/badge/Try-Calculator-teal)](https://policyengine.github.io/spm-calculator)
6
+ [![Documentation](https://img.shields.io/badge/docs-online-green)](https://policyengine.github.io/spm-calculator)
7
+
8
+ ## Interactive Calculator
9
+
10
+ **[Try the SPM Threshold Calculator](https://policyengine.github.io/spm-calculator)** - A static web app that walks you through calculating your SPM threshold based on your household characteristics.
11
+
12
+ The calculator runs entirely in your browser with no server required - all data is pre-computed and bundled.
13
+
14
+ ### Run Locally
15
+
16
+ **Static React App (recommended):**
17
+ ```bash
18
+ cd web
19
+ npm install
20
+ npm run dev
21
+ ```
22
+
23
+ **Streamlit App (alternative):**
24
+ ```bash
25
+ pip install spm-calculator[app]
26
+ streamlit run app/streamlit_app.py
27
+ ```
28
+
29
+ ## Overview
30
+
31
+ The SPM threshold is calculated as:
32
+
33
+ ```
34
+ threshold = base_threshold[tenure] × equivalence_scale × geoadj
35
+ ```
36
+
37
+ Where:
38
+ - **base_threshold** varies by housing tenure (renter, owner with mortgage, owner without mortgage), calculated from 5-year rolling Consumer Expenditure Survey data
39
+ - **equivalence_scale** adjusts for family composition using the SPM three-parameter scale
40
+ - **geoadj** adjusts for local housing costs based on ACS median rents
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ pip install spm-calculator
46
+ ```
47
+
48
+ ## Quick Start
49
+
50
+ ```python
51
+ from spm_calculator import SPMCalculator
52
+
53
+ # Initialize calculator for a specific year
54
+ calc = SPMCalculator(year=2024)
55
+
56
+ # Get base thresholds by tenure (national, before geographic adjustment)
57
+ base = calc.get_base_thresholds()
58
+ # {'renter': 39430, 'owner_with_mortgage': 39068, 'owner_without_mortgage': 32586}
59
+
60
+ # Get GEOADJ for a specific geography
61
+ geoadj = calc.get_geoadj("congressional_district", "0612") # CA-12
62
+ # 1.24 (24% above national average due to high housing costs)
63
+
64
+ # Calculate threshold for a specific family in a specific location
65
+ threshold = calc.calculate_threshold(
66
+ num_adults=2,
67
+ num_children=2,
68
+ tenure="renter",
69
+ geography_type="congressional_district",
70
+ geography_id="0612"
71
+ )
72
+ # ~$48,893 (base $39,430 × equiv_scale 1.0 × geoadj 1.24)
73
+ ```
74
+
75
+ ## Supported Geographies
76
+
77
+ - `nation` - National average
78
+ - `state` - 50 states + DC
79
+ - `county` - ~3,200 counties
80
+ - `metro_area` - Metropolitan statistical areas
81
+ - `congressional_district` - 435 congressional districts
82
+ - `puma` - Public Use Microdata Areas
83
+ - `tract` - Census tracts (limited availability)
84
+
85
+ ## Data Sources
86
+
87
+ - **Base thresholds**: [BLS Consumer Expenditure Survey](https://www.bls.gov/cex/) - 5-year rolling FCSUti (Food, Clothing, Shelter, Utilities, telephone, internet)
88
+ - **Geographic adjustment**: [ACS 5-Year Estimates](https://www.census.gov/programs-surveys/acs) - Table B25031 (Median Gross Rent by Bedrooms)
89
+ - **Methodology**: [Census SPM Technical Documentation](https://www2.census.gov/programs-surveys/supplemental-poverty-measure/datasets/spm/spm_techdoc.pdf)
90
+
91
+ ## Methodology
92
+
93
+ ### Base Threshold Calculation
94
+
95
+ Following BLS methodology (updated September 2021):
96
+ 1. Download 5 years of CE Survey PUMD (Public Use Microdata)
97
+ 2. Filter to consumer units with children
98
+ 3. Calculate FCSUti expenditures
99
+ 4. Convert to reference family (2 adults, 2 children) using equivalence scale
100
+ 5. Calculate 83% of median (47th-53rd percentile average) by tenure type
101
+
102
+ ### Geographic Adjustment (GEOADJ)
103
+
104
+ Following Census methodology:
105
+ ```
106
+ GEOADJ = (local_median_rent / national_median_rent) × 0.492 + 0.508
107
+ ```
108
+
109
+ Where 0.492 is the housing portion of the SPM threshold for renters.
110
+
111
+ ### Equivalence Scale
112
+
113
+ The SPM uses a three-parameter equivalence scale:
114
+ - First adult: 1.0
115
+ - Additional adults: 0.5 each
116
+ - Children: 0.3 each
117
+ - Normalized to reference family (2A2C = 1.0)
118
+
119
+ ## Validation
120
+
121
+ Base thresholds are validated against [BLS published values](https://www.bls.gov/pir/spm/spm_thresholds_2024.htm):
122
+
123
+ | Tenure | 2024 BLS | Calculator |
124
+ |--------|----------|------------|
125
+ | Renter | $39,430 | $39,430 |
126
+ | Owner w/ mortgage | $39,068 | $39,068 |
127
+ | Owner w/o mortgage | $32,586 | $32,586 |
128
+
129
+ ## License
130
+
131
+ MIT License - see [LICENSE](LICENSE) for details.
132
+
133
+ ## Contributing
134
+
135
+ Contributions welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
@@ -0,0 +1,60 @@
1
+ [build-system]
2
+ requires = ["setuptools>=45", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "spm-calculator"
7
+ version = "0.1.0"
8
+ description = "Calculate SPM (Supplemental Poverty Measure) thresholds for any US geography and year"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [
12
+ {name = "PolicyEngine", email = "hello@policyengine.org"}
13
+ ]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Science/Research",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Scientific/Engineering",
23
+ ]
24
+ requires-python = ">=3.10"
25
+ dependencies = [
26
+ "pandas>=2.0",
27
+ "numpy>=1.24",
28
+ "requests>=2.28",
29
+ "census>=0.8",
30
+ "us>=3.0",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ dev = [
35
+ "pytest>=7.0",
36
+ "pytest-cov>=4.0",
37
+ "black>=23.0",
38
+ "ruff>=0.1",
39
+ ]
40
+ app = [
41
+ "streamlit>=1.30",
42
+ ]
43
+
44
+ [project.urls]
45
+ Homepage = "https://github.com/PolicyEngine/spm-calculator"
46
+ Repository = "https://github.com/PolicyEngine/spm-calculator"
47
+ Issues = "https://github.com/PolicyEngine/spm-calculator/issues"
48
+
49
+ [tool.setuptools]
50
+ packages = ["spm_calculator"]
51
+
52
+ [tool.black]
53
+ line-length = 79
54
+
55
+ [tool.ruff]
56
+ line-length = 79
57
+ select = ["E", "F", "I"]
58
+
59
+ [tool.pytest.ini_options]
60
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,52 @@
1
+ """
2
+ SPM Calculator - Calculate Supplemental Poverty Measure thresholds.
3
+
4
+ This package calculates SPM thresholds for any US geography and year using:
5
+ - Base thresholds from BLS Consumer Expenditure Survey (by tenure type)
6
+ - Geographic adjustments (GEOADJ) from ACS median rents
7
+ - SPM three-parameter equivalence scale for family composition
8
+
9
+ Example:
10
+ >>> from spm_calculator import SPMCalculator
11
+ >>> calc = SPMCalculator(year=2024)
12
+ >>> threshold = calc.calculate_threshold(
13
+ ... num_adults=2,
14
+ ... num_children=2,
15
+ ... tenure="renter",
16
+ ... geography_type="congressional_district",
17
+ ... geography_id="0612"
18
+ ... )
19
+ """
20
+
21
+ from .calculator import SPMCalculator
22
+ from .ce_threshold import calculate_base_thresholds, get_published_thresholds
23
+ from .geoadj import get_geoadj, create_geoadj_lookup
24
+ from .equivalence_scale import spm_equivalence_scale
25
+ from .fcsuti_cpi import get_fcsuti_cpi, get_fcsuti_inflation_factor
26
+ from .forecast import (
27
+ forecast_thresholds,
28
+ get_thresholds,
29
+ get_threshold_with_metadata,
30
+ get_available_years,
31
+ get_latest_published_year,
32
+ HISTORICAL_THRESHOLDS,
33
+ )
34
+
35
+ __version__ = "0.1.0"
36
+
37
+ __all__ = [
38
+ "SPMCalculator",
39
+ "calculate_base_thresholds",
40
+ "get_published_thresholds",
41
+ "get_geoadj",
42
+ "create_geoadj_lookup",
43
+ "spm_equivalence_scale",
44
+ "get_fcsuti_cpi",
45
+ "get_fcsuti_inflation_factor",
46
+ "forecast_thresholds",
47
+ "get_thresholds",
48
+ "get_threshold_with_metadata",
49
+ "get_available_years",
50
+ "get_latest_published_year",
51
+ "HISTORICAL_THRESHOLDS",
52
+ ]
@@ -0,0 +1,248 @@
1
+ """
2
+ Main SPMCalculator class for calculating SPM thresholds.
3
+
4
+ The full SPM threshold calculation is:
5
+ threshold = base_threshold[tenure] × equivalence_scale × geoadj
6
+
7
+ Where:
8
+ - base_threshold varies by housing tenure (from CE Survey)
9
+ - equivalence_scale adjusts for family composition
10
+ - geoadj adjusts for local housing costs
11
+ """
12
+
13
+ from typing import Union, Optional, Sequence
14
+
15
+ import numpy as np
16
+
17
+ from .ce_threshold import get_published_thresholds, calculate_base_thresholds
18
+ from .equivalence_scale import spm_equivalence_scale
19
+ from .geoadj import get_geoadj, create_geoadj_lookup, SUPPORTED_GEOGRAPHIES
20
+
21
+
22
+ VALID_TENURE_TYPES = [
23
+ "renter",
24
+ "owner_with_mortgage",
25
+ "owner_without_mortgage",
26
+ ]
27
+
28
+
29
+ class SPMCalculator:
30
+ """
31
+ Calculator for Supplemental Poverty Measure thresholds.
32
+
33
+ Calculates SPM thresholds for any US geography and year using:
34
+ - Base thresholds from BLS Consumer Expenditure Survey
35
+ - Geographic adjustments from ACS median rents
36
+ - SPM three-parameter equivalence scale
37
+
38
+ Example:
39
+ >>> calc = SPMCalculator(year=2024)
40
+ >>> threshold = calc.calculate_threshold(
41
+ ... num_adults=2,
42
+ ... num_children=2,
43
+ ... tenure="renter",
44
+ ... geography_type="congressional_district",
45
+ ... geography_id="0612"
46
+ ... )
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ year: int,
52
+ use_published_thresholds: bool = True,
53
+ ):
54
+ """
55
+ Initialize SPMCalculator for a specific year.
56
+
57
+ Args:
58
+ year: Target year for threshold calculation
59
+ use_published_thresholds: If True, use published BLS thresholds
60
+ when available. If False, calculate from CE Survey data.
61
+ """
62
+ self.year = year
63
+ self.use_published_thresholds = use_published_thresholds
64
+ self._base_thresholds: Optional[dict[str, float]] = None
65
+
66
+ def get_base_thresholds(self) -> dict[str, float]:
67
+ """
68
+ Get base SPM thresholds by tenure type.
69
+
70
+ Returns thresholds for the reference family (2 adults, 2 children)
71
+ before geographic adjustment.
72
+
73
+ Returns:
74
+ Dict with keys 'renter', 'owner_with_mortgage',
75
+ 'owner_without_mortgage' and threshold values in dollars.
76
+ """
77
+ if self._base_thresholds is not None:
78
+ return self._base_thresholds.copy()
79
+
80
+ if self.use_published_thresholds:
81
+ try:
82
+ self._base_thresholds = get_published_thresholds(self.year)
83
+ except ValueError:
84
+ # Published not available, forecast from latest available
85
+ # Use simple CPI-U projection from 2024 for now
86
+ # TODO: Implement proper CE-based forecasting
87
+ latest_thresholds = get_published_thresholds(2024)
88
+ years_ahead = self.year - 2024
89
+ # Assume ~3% annual inflation
90
+ inflation_factor = 1.03**years_ahead
91
+ self._base_thresholds = {
92
+ k: v * inflation_factor
93
+ for k, v in latest_thresholds.items()
94
+ }
95
+ else:
96
+ self._base_thresholds = calculate_base_thresholds(
97
+ target_year=self.year,
98
+ use_published_fallback=False,
99
+ )
100
+
101
+ return self._base_thresholds.copy()
102
+
103
+ def get_geoadj(
104
+ self,
105
+ geography_type: str,
106
+ geography_id: str,
107
+ ) -> float:
108
+ """
109
+ Get geographic adjustment factor for a specific location.
110
+
111
+ Args:
112
+ geography_type: Type of geography (nation, state, county,
113
+ congressional_district, etc.)
114
+ geography_id: Geography identifier (FIPS code, etc.)
115
+
116
+ Returns:
117
+ GEOADJ value (typically 0.84 to 1.27)
118
+ """
119
+ # Use year - 1 for ACS data (lag)
120
+ acs_year = min(self.year - 1, 2022) # TODO: Update as data available
121
+ return get_geoadj(geography_type, geography_id, acs_year)
122
+
123
+ def calculate_threshold(
124
+ self,
125
+ num_adults: int,
126
+ num_children: int,
127
+ tenure: str,
128
+ geography_type: str,
129
+ geography_id: str,
130
+ ) -> float:
131
+ """
132
+ Calculate SPM threshold for a specific SPM unit and location.
133
+
134
+ Args:
135
+ num_adults: Number of adults (18+) in the SPM unit
136
+ num_children: Number of children (under 18) in the SPM unit
137
+ tenure: Housing tenure type ('renter', 'owner_with_mortgage',
138
+ 'owner_without_mortgage')
139
+ geography_type: Type of geography (nation, state, county, etc.)
140
+ geography_id: Geography identifier
141
+
142
+ Returns:
143
+ SPM threshold in dollars
144
+
145
+ Raises:
146
+ ValueError: If tenure type invalid or geography not found
147
+ """
148
+ # Validate inputs
149
+ if tenure not in VALID_TENURE_TYPES:
150
+ raise ValueError(
151
+ f"Invalid tenure type: {tenure}. "
152
+ f"Must be one of: {VALID_TENURE_TYPES}"
153
+ )
154
+
155
+ if num_adults < 0 or num_children < 0:
156
+ raise ValueError("Number of persons cannot be negative")
157
+
158
+ # Zero persons = zero threshold
159
+ if num_adults == 0 and num_children == 0:
160
+ return 0.0
161
+
162
+ # Get components
163
+ base = self.get_base_thresholds()[tenure]
164
+ equiv_scale = spm_equivalence_scale(num_adults, num_children)
165
+ geoadj = self.get_geoadj(geography_type, geography_id)
166
+
167
+ return base * equiv_scale * geoadj
168
+
169
+ def calculate_thresholds(
170
+ self,
171
+ num_adults: Union[int, np.ndarray, Sequence[int]],
172
+ num_children: Union[int, np.ndarray, Sequence[int]],
173
+ tenure: Union[str, Sequence[str]],
174
+ geography_type: str,
175
+ geography_ids: Union[str, Sequence[str]],
176
+ ) -> np.ndarray:
177
+ """
178
+ Calculate SPM thresholds for multiple SPM units (vectorized).
179
+
180
+ Args:
181
+ num_adults: Number of adults for each unit
182
+ num_children: Number of children for each unit
183
+ tenure: Tenure type(s) - single value or per-unit
184
+ geography_type: Type of geography (same for all)
185
+ geography_ids: Geography ID(s) - single value or per-unit
186
+
187
+ Returns:
188
+ Array of SPM thresholds
189
+ """
190
+ # Convert to arrays
191
+ num_adults = np.atleast_1d(num_adults)
192
+ num_children = np.atleast_1d(num_children)
193
+ n = len(num_adults)
194
+
195
+ # Handle tenure
196
+ if isinstance(tenure, str):
197
+ tenure = [tenure] * n
198
+ tenure = list(tenure)
199
+
200
+ # Handle geography_ids
201
+ if isinstance(geography_ids, str):
202
+ geography_ids = [geography_ids] * n
203
+ geography_ids = list(geography_ids)
204
+
205
+ # Validate lengths
206
+ if not (
207
+ len(num_adults)
208
+ == len(num_children)
209
+ == len(tenure)
210
+ == len(geography_ids)
211
+ ):
212
+ raise ValueError("All input arrays must have same length")
213
+
214
+ # Validate tenure types
215
+ for t in tenure:
216
+ if t not in VALID_TENURE_TYPES:
217
+ raise ValueError(
218
+ f"Invalid tenure type: {t}. "
219
+ f"Must be one of: {VALID_TENURE_TYPES}"
220
+ )
221
+
222
+ # Get base thresholds
223
+ base_thresholds = self.get_base_thresholds()
224
+
225
+ # Calculate equivalence scales (vectorized)
226
+ equiv_scales = spm_equivalence_scale(num_adults, num_children)
227
+
228
+ # Get GEOADJ values
229
+ # Cache unique geographies
230
+ unique_geos = set(geography_ids)
231
+ geo_to_geoadj = {}
232
+ for geo_id in unique_geos:
233
+ geo_to_geoadj[geo_id] = self.get_geoadj(geography_type, geo_id)
234
+
235
+ geoadj_values = np.array([geo_to_geoadj[g] for g in geography_ids])
236
+
237
+ # Get base values by tenure
238
+ base_values = np.array([base_thresholds[t] for t in tenure])
239
+
240
+ # Calculate thresholds
241
+ thresholds = base_values * equiv_scales * geoadj_values
242
+
243
+ return thresholds
244
+
245
+ @property
246
+ def supported_geographies(self) -> list[str]:
247
+ """List of supported geography types."""
248
+ return list(SUPPORTED_GEOGRAPHIES.keys())