Skip to content

Commit 414ed91

Browse files
committed
Fix census sentinel values and poverty variable at block group level
Two data handling issues fixed: 1. Census API sentinel values (-666666666 = "data not available") were passed through as valid numbers, corrupting min/median statistics. Now filtered to None in fetch_census_data. 2. The poverty variable (B17001_002E) is not published at block group level in any ACS vintage. Replaced with compound variable support: poverty is now computed as C17002_002E + C17002_003E (population below 100% of federal poverty level), which IS available at block group level. normalize_variable_names returns a tuple of (codes, compounds) and api.py post-processes the sums.
1 parent f397dd7 commit 414ed91

4 files changed

Lines changed: 98 additions & 1016 deletions

File tree

socialmapper/_census.py

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ def validate_fips_code(fips_code: str, expected_length: int, code_type: str = "F
111111
return fips_code
112112

113113

114-
# Variable name mappings
114+
# Variable name mappings (single Census API code per friendly name)
115115
VARIABLE_MAPPING = {
116116
'population': 'B01003_001E',
117117
'total_population': 'B01003_001E',
@@ -127,8 +127,6 @@ def validate_fips_code(fips_code: str, expected_length: int, code_type: str = "F
127127
'black_population': 'B02001_003E',
128128
'asian_population': 'B02001_005E',
129129
'hispanic_population': 'B03002_012E',
130-
'poverty': 'B17001_002E',
131-
'poverty_population': 'B17001_002E',
132130
'bachelors_degree': 'B15003_022E',
133131
'high_school': 'B15003_017E',
134132
'households_with_vehicle': 'B08201_001E', # Total households (subtract no_vehicle)
@@ -137,15 +135,25 @@ def validate_fips_code(fips_code: str, expected_length: int, code_type: str = "F
137135
'median_rent': 'B25064_001E',
138136
}
139137

138+
# Variables computed as sums of multiple Census API codes.
139+
# The B17001 poverty table is NOT available at block group level;
140+
# the C17002 ratio-of-income-to-poverty table IS. Summing the
141+
# "under 0.50" and "0.50 to 0.99" buckets gives total population
142+
# below 100 % of the federal poverty level.
143+
COMPOUND_VARIABLES: dict[str, list[str]] = {
144+
'poverty': ['C17002_002E', 'C17002_003E'],
145+
'poverty_population': ['C17002_002E', 'C17002_003E'],
146+
}
147+
140148

141-
def normalize_variable_names(variables: list[str]) -> list[str]:
149+
def normalize_variable_names(variables: list[str]) -> tuple[list[str], dict[str, list[str]]]:
142150
"""
143151
Convert human-readable variable names to census codes.
144152
145153
Maps common demographic variable names to their corresponding
146154
Census Bureau API variable codes (e.g., 'population' to
147-
'B01003_001E'). If a variable is already a census code,
148-
it is returned unchanged.
155+
'B01003_001E'). Compound variables (like 'poverty') are expanded
156+
into their component codes.
149157
150158
Parameters
151159
----------
@@ -156,39 +164,56 @@ def normalize_variable_names(variables: list[str]) -> list[str]:
156164
157165
Returns
158166
-------
159-
list of str
160-
List of census variable codes corresponding to the input variables.
161-
Unknown variables are kept as-is with a warning logged.
167+
tuple of (list[str], dict[str, list[str]])
168+
A 2-tuple:
169+
- List of census variable codes to fetch from the API.
170+
- Dict mapping compound friendly names to their component codes.
171+
Empty if no compound variables were requested.
162172
163173
Examples
164174
--------
165175
>>> normalize_variable_names(['population', 'median_income'])
166-
['B01003_001E', 'B19013_001E']
176+
(['B01003_001E', 'B19013_001E'], {})
167177
168-
>>> normalize_variable_names(['B01003_001E', 'housing_units'])
169-
['B01003_001E', 'B25001_001E']
178+
>>> normalize_variable_names(['population', 'poverty'])
179+
(['B01003_001E', 'C17002_002E', 'C17002_003E'], {'poverty': ['C17002_002E', 'C17002_003E']})
170180
171181
Notes
172182
-----
173183
Variable names are case-insensitive and spaces are converted to
174184
underscores during the mapping process.
175185
"""
176186
normalized = []
187+
compounds: dict[str, list[str]] = {}
177188

178189
for var in variables:
179190
# Check if already a census code (has underscore and starts with letter)
180191
if '_' in var and var[0].isalpha() and var[0].isupper():
181192
normalized.append(var)
193+
continue
194+
195+
key = var.lower().replace(' ', '_')
196+
197+
# Check compound variables first (e.g., poverty = sum of two codes)
198+
if key in COMPOUND_VARIABLES:
199+
components = COMPOUND_VARIABLES[key]
200+
compounds[key] = components
201+
normalized.extend(components)
202+
elif key in VARIABLE_MAPPING:
203+
normalized.append(VARIABLE_MAPPING[key])
182204
else:
183-
# Try to map from human-readable name
184-
mapped = VARIABLE_MAPPING.get(var.lower().replace(' ', '_'))
185-
if mapped:
186-
normalized.append(mapped)
187-
else:
188-
logger.warning(f"Unknown variable '{var}', keeping as-is")
189-
normalized.append(var)
205+
logger.warning(f"Unknown variable '{var}', keeping as-is")
206+
normalized.append(var)
207+
208+
# Deduplicate while preserving order
209+
seen: set[str] = set()
210+
deduped = []
211+
for code in normalized:
212+
if code not in seen:
213+
seen.add(code)
214+
deduped.append(code)
190215

191-
return normalized
216+
return deduped, compounds
192217

193218

194219
def fetch_block_groups_for_area(geometry: Polygon) -> list[dict[str, Any]]:
@@ -666,7 +691,12 @@ def fetch_census_data(
666691
for j, header in enumerate(headers):
667692
if header in variables:
668693
try:
669-
geoid_data[header] = float(row[j]) if row[j] else None
694+
val = float(row[j]) if row[j] else None
695+
# Census API uses large negative sentinels
696+
# for unavailable data (-666666666, etc.)
697+
if val is not None and val <= -666666666:
698+
val = None
699+
geoid_data[header] = val
670700
except (ValueError, TypeError):
671701
geoid_data[header] = row[j]
672702

socialmapper/api.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,8 @@ def get_census_data(
258258
"Census variables must be a non-empty list of variable names"
259259
)
260260

261-
# Normalize variable names
262-
var_codes = normalize_variable_names(variables)
261+
# Normalize variable names (may expand compound variables)
262+
var_codes, compounds = normalize_variable_names(variables)
263263

264264
# Determine location type
265265
if isinstance(location, dict):
@@ -279,6 +279,19 @@ def get_census_data(
279279
# Fetch census data
280280
data = fetch_census_data(geoids, var_codes, year)
281281

282+
# Post-process: compute compound variables (e.g. poverty = sum of two codes)
283+
if compounds:
284+
for geoid_data in data.values():
285+
for friendly_name, components in compounds.items():
286+
values = [geoid_data.get(c) for c in components]
287+
if all(v is not None for v in values):
288+
geoid_data[friendly_name] = sum(values)
289+
else:
290+
geoid_data[friendly_name] = None
291+
# Remove component codes from output
292+
for c in components:
293+
geoid_data.pop(c, None)
294+
282295
# Return consistent structure - always {geoid: {variable: value}}
283296
return CensusDataResult(
284297
data=data,

0 commit comments

Comments
 (0)