diff --git a/doc/devlog/2024-05-03.ipynb b/doc/devlog/2024-05-03.ipynb index fe8635ec..2e6d8199 100644 --- a/doc/devlog/2024-05-03.ipynb +++ b/doc/devlog/2024-05-03.ipynb @@ -8,11 +8,13 @@ "\n", "_Author: Tyler Coles_\n", "\n", - "Testing our us_census functions for loading canonical sets of IDs for Census granularities from state to block group for the years 2000, 2010, and 2020.\n", + "Testing our us_census functions for loading canonical sets of IDs for Census granularities from state to block group for all supported TIGER years (2000 and 2009-2023).\n", "\n", "Since this is our source of truth for these delineations, we want to make sure we're getting complete data. One thing we can test is that at each level of granularity (above block group) each node should contain at least one child node. That is every state should contain a county, every county a tract, and every tract a block group. Otherwise we know something is missing.\n", "\n", - "(This may seem like a trivial test, but in fact it discovered that my original assumptions about how TIGER provides the data were invalid and has already saved us from bugs!)" + "(This may seem like a trivial test, but in fact it discovered that my original assumptions about how TIGER provides the data were invalid and has already saved us from bugs!)\n", + "\n", + "WARNING: this will take a very long time if you don't have the TIGER files cached." ] }, { @@ -22,13 +24,14 @@ "outputs": [], "source": [ "import epymorph.geography.us_census as c\n", + "import epymorph.geography.us_tiger as t\n", "\n", "\n", "class Fail(Exception):\n", " pass\n", "\n", "\n", - "def test_year(year: c.CensusYear) -> None:\n", + "def test_year(year: int) -> None:\n", " # 1. test that we have 52 states\n", " states = c.get_us_states(year).geoid\n", "\n", @@ -80,46 +83,28 @@ "name": "stdout", "output_type": "stream", "text": [ - "Census year 2020 passed!\n" - ] - } - ], - "source": [ - "test_year(2020)" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Census year 2010 passed!\n" - ] - } - ], - "source": [ - "test_year(2010)" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Census year 2000 passed!\n" + "Census year 2000 passed!\n", + "Census year 2009 passed!\n", + "Census year 2010 passed!\n", + "Census year 2011 passed!\n", + "Census year 2012 passed!\n", + "Census year 2013 passed!\n", + "Census year 2014 passed!\n", + "Census year 2015 passed!\n", + "Census year 2016 passed!\n", + "Census year 2017 passed!\n", + "Census year 2018 passed!\n", + "Census year 2019 passed!\n", + "Census year 2020 passed!\n", + "Census year 2021 passed!\n", + "Census year 2022 passed!\n", + "Census year 2023 passed!\n" ] } ], "source": [ - "test_year(2000)" + "for year in t.TIGER_YEARS:\n", + " test_year(year)" ] } ], diff --git a/epymorph/geo/adrio/census/adrio_census.py b/epymorph/geo/adrio/census/adrio_census.py index 0ef9e926..a8ecc196 100644 --- a/epymorph/geo/adrio/census/adrio_census.py +++ b/epymorph/geo/adrio/census/adrio_census.py @@ -12,7 +12,8 @@ from epymorph.data_shape import Shapes from epymorph.data_type import CentroidDType -from epymorph.error import DataResourceException, GeoValidationException +from epymorph.error import (DataResourceException, GeographyError, + GeoValidationException) from epymorph.geo.adrio.adrio import ADRIO, ADRIOMaker from epymorph.geo.spec import TimePeriod, Year from epymorph.geography.us_census import (BLOCK_GROUP, COUNTY, STATE, TRACT, @@ -22,7 +23,7 @@ StateScopeAll, TractScope) from epymorph.geography.us_tiger import (get_block_groups_geo, get_counties_geo, get_states_geo, - get_tracts_geo) + get_tracts_geo, is_tiger_year) from epymorph.simulation import AttributeDef, geo_attrib @@ -197,18 +198,22 @@ def fetch_acs5(self, variables: list[str], scope: CensusScope, year: int) -> Dat def fetch_sf(self, scope: CensusScope) -> GeoDataFrame: """Utility function to fetch shape files from Census for specified regions.""" # call appropriate pygris function based on granularity and sort result + scope_year = scope.year + if not is_tiger_year(scope_year): + raise GeographyError(f"Unsupported year: {scope_year}") + match scope: case StateScopeAll() | StateScope(): - df = get_states_geo(year=scope.year) + df = get_states_geo(year=scope_year) case CountyScope(): - df = get_counties_geo(year=scope.year) + df = get_counties_geo(year=scope_year) case TractScope(): - df = get_tracts_geo(year=scope.year) + df = get_tracts_geo(year=scope_year) case BlockGroupScope(): - df = get_block_groups_geo(year=scope.year) + df = get_block_groups_geo(year=scope_year) case _: raise DataResourceException("Unsupported query.") diff --git a/epymorph/geography/us_census.py b/epymorph/geography/us_census.py index b0bc21fe..40e641e5 100644 --- a/epymorph/geography/us_census.py +++ b/epymorph/geography/us_census.py @@ -250,9 +250,6 @@ def get_census_granularity(name: CensusGranularityName) -> CensusGranularity: # Census data loading and caching -CensusYear = Literal[2000, 2010, 2020] -"""A supported Census delineation year.""" - DEFAULT_YEAR = 2020 _GEOGRAPHY_CACHE_PATH = Path("geography") @@ -288,8 +285,11 @@ class StatesInfo(NamedTuple): """The US postal code for the state.""" -def get_us_states(year: CensusYear) -> StatesInfo: +def get_us_states(year: int) -> StatesInfo: """Loads US States information (assumed to be invariant for all supported years).""" + if not us_tiger.is_tiger_year(year): + raise GeographyError(f"Unsupported year: {year}") + def _get_us_states() -> StatesInfo: df = us_tiger.get_states_info(year) df.sort_values("GEOID", inplace=True) @@ -309,8 +309,11 @@ class CountiesInfo(NamedTuple): """The typical name of the county (does not include state).""" -def get_us_counties(year: CensusYear) -> CountiesInfo: +def get_us_counties(year: int) -> CountiesInfo: """Loads US Counties information for the given year.""" + if not us_tiger.is_tiger_year(year): + raise GeographyError(f"Unsupported year: {year}") + def _get_us_counties() -> CountiesInfo: df = us_tiger.get_counties_info(year) df.sort_values("GEOID", inplace=True) @@ -327,8 +330,11 @@ class TractsInfo(NamedTuple): """The GEOID (aka FIPS code) of the tract.""" -def get_us_tracts(year: CensusYear) -> TractsInfo: +def get_us_tracts(year: int) -> TractsInfo: """Loads US Census Tracts information for the given year.""" + if not us_tiger.is_tiger_year(year): + raise GeographyError(f"Unsupported year: {year}") + def _get_us_tracts() -> TractsInfo: df = us_tiger.get_tracts_info(year) df.sort_values("GEOID", inplace=True) @@ -344,8 +350,11 @@ class BlockGroupsInfo(NamedTuple): """The GEOID (aka FIPS code) of the block group.""" -def get_us_block_groups(year: CensusYear) -> BlockGroupsInfo: +def get_us_block_groups(year: int) -> BlockGroupsInfo: """Loads US Census Block Group information for the given year.""" + if not us_tiger.is_tiger_year(year): + raise GeographyError(f"Unsupported year: {year}") + def _get_us_cbgs() -> BlockGroupsInfo: df = us_tiger.get_block_groups_info(year) df.sort_values("GEOID", inplace=True) @@ -362,7 +371,7 @@ def _get_us_cbgs() -> BlockGroupsInfo: P = ParamSpec('P') -def verify_fips(granularity: CensusGranularityName, year: CensusYear, fips: Sequence[str]) -> None: +def verify_fips(granularity: CensusGranularityName, year: int, fips: Sequence[str]) -> None: """ Validates a list of FIPS codes are valid for the given granularity and year. If any FIPS code is found to be invalid, raises GeographyError. @@ -393,20 +402,20 @@ def verify_fips(granularity: CensusGranularityName, year: CensusYear, fips: Sequ @cache -def state_code_to_fips(year: CensusYear) -> Mapping[str, str]: +def state_code_to_fips(year: int) -> Mapping[str, str]: """Mapping from state postal code to FIPS code.""" states = get_us_states(year) return dict(zip(states.code, states.geoid)) @cache -def state_fips_to_code(year: CensusYear) -> Mapping[str, str]: +def state_fips_to_code(year: int) -> Mapping[str, str]: """Mapping from state FIPS code to postal code.""" states = get_us_states(year) return dict(zip(states.geoid, states.code)) -def validate_state_codes_as_fips(year: CensusYear, codes: Sequence[str]) -> Sequence[str]: +def validate_state_codes_as_fips(year: int, codes: Sequence[str]) -> Sequence[str]: """ Validates a list of US state postal codes (two-letter abbreviations) and returns them as a sorted list of FIPS codes. @@ -428,7 +437,7 @@ def validate_state_codes_as_fips(year: CensusYear, codes: Sequence[str]) -> Sequ class CensusScope(GeoScope): """A GeoScope using US Census delineations.""" - year: CensusYear + year: int """ The Census delineation year. With every decennial census, the Census Department can (and does) define @@ -464,7 +473,7 @@ def lower_granularity(self) -> 'CensusScope': class StateScopeAll(CensusScope): """GeoScope including all US states and state-equivalents.""" # NOTE: for the Census API, we need to handle "all states" as a special case. - year: CensusYear + year: int granularity: Literal['state'] = field(init=False, default='state') def get_node_ids(self) -> NDArray[np.str_]: @@ -483,16 +492,16 @@ class StateScope(CensusScope): """GeoScope at the State granularity.""" includes_granularity: Literal['state'] includes: Sequence[str] - year: CensusYear + year: int granularity: Literal['state'] = field(init=False, default='state') @staticmethod - def all(year: CensusYear = DEFAULT_YEAR) -> StateScopeAll: + def all(year: int = DEFAULT_YEAR) -> StateScopeAll: """Create a scope including all US states and state-equivalents.""" return StateScopeAll(year) @staticmethod - def in_states(states_fips: Sequence[str], year: CensusYear = DEFAULT_YEAR) -> 'StateScope': + def in_states(states_fips: Sequence[str], year: int = DEFAULT_YEAR) -> 'StateScope': """ Create a scope including a set of US states/state-equivalents, by FIPS code. Raise GeographyError if any FIPS code is invalid. @@ -501,7 +510,7 @@ def in_states(states_fips: Sequence[str], year: CensusYear = DEFAULT_YEAR) -> 'S return StateScope('state', states_fips, year) @staticmethod - def in_states_by_code(states_code: Sequence[str], year: CensusYear = DEFAULT_YEAR) -> 'StateScope': + def in_states_by_code(states_code: Sequence[str], year: int = DEFAULT_YEAR) -> 'StateScope': """ Create a scope including a set of US states/state-equivalents, by postal code (two-letter abbreviation). Raise GeographyError if any postal code is invalid. @@ -527,11 +536,11 @@ class CountyScope(CensusScope): """GeoScope at the County granularity.""" includes_granularity: Literal['state', 'county'] includes: Sequence[str] - year: CensusYear + year: int granularity: Literal['county'] = field(init=False, default='county') @staticmethod - def in_states(states_fips: Sequence[str], year: CensusYear = DEFAULT_YEAR) -> 'CountyScope': + def in_states(states_fips: Sequence[str], year: int = DEFAULT_YEAR) -> 'CountyScope': """ Create a scope including all counties in a set of US states/state-equivalents. Raise GeographyError if any FIPS code is invalid. @@ -540,7 +549,7 @@ def in_states(states_fips: Sequence[str], year: CensusYear = DEFAULT_YEAR) -> 'C return CountyScope('state', states_fips, year) @staticmethod - def in_states_by_code(states_code: Sequence[str], year: CensusYear = DEFAULT_YEAR) -> 'CountyScope': + def in_states_by_code(states_code: Sequence[str], year: int = DEFAULT_YEAR) -> 'CountyScope': """ Create a scope including all counties in a set of US states/state-equivalents, by postal code (two-letter abbreviation). @@ -550,7 +559,7 @@ def in_states_by_code(states_code: Sequence[str], year: CensusYear = DEFAULT_YEA return CountyScope('state', states_fips, year) @staticmethod - def in_counties(counties_fips: Sequence[str], year: CensusYear = DEFAULT_YEAR) -> 'CountyScope': + def in_counties(counties_fips: Sequence[str], year: int = DEFAULT_YEAR) -> 'CountyScope': """ Create a scope including a set of US counties, by FIPS code. Raise GeographyError if any FIPS code is invalid. @@ -590,11 +599,11 @@ class TractScope(CensusScope): """GeoScope at the Tract granularity.""" includes_granularity: Literal['state', 'county', 'tract'] includes: Sequence[str] - year: CensusYear + year: int granularity: Literal['tract'] = field(init=False, default='tract') @staticmethod - def in_states(states_fips: Sequence[str], year: CensusYear = DEFAULT_YEAR) -> 'TractScope': + def in_states(states_fips: Sequence[str], year: int = DEFAULT_YEAR) -> 'TractScope': """ Create a scope including all tracts in a set of US states/state-equivalents. Raise GeographyError if any FIPS code is invalid. @@ -603,7 +612,7 @@ def in_states(states_fips: Sequence[str], year: CensusYear = DEFAULT_YEAR) -> 'T return TractScope('state', states_fips, year) @staticmethod - def in_states_by_code(states_code: Sequence[str], year: CensusYear = DEFAULT_YEAR) -> 'TractScope': + def in_states_by_code(states_code: Sequence[str], year: int = DEFAULT_YEAR) -> 'TractScope': """ Create a scope including all tracts in a set of US states/state-equivalents, by postal code (two-letter abbreviation). @@ -613,7 +622,7 @@ def in_states_by_code(states_code: Sequence[str], year: CensusYear = DEFAULT_YEA return TractScope('state', states_fips, year) @staticmethod - def in_counties(counties_fips: Sequence[str], year: CensusYear = DEFAULT_YEAR) -> 'TractScope': + def in_counties(counties_fips: Sequence[str], year: int = DEFAULT_YEAR) -> 'TractScope': """ Create a scope including all tracts in a set of US counties, by FIPS code. Raise GeographyError if any FIPS code is invalid. @@ -622,7 +631,7 @@ def in_counties(counties_fips: Sequence[str], year: CensusYear = DEFAULT_YEAR) - return TractScope('county', counties_fips, year) @staticmethod - def in_tracts(tract_fips: Sequence[str], year: CensusYear = DEFAULT_YEAR) -> 'TractScope': + def in_tracts(tract_fips: Sequence[str], year: int = DEFAULT_YEAR) -> 'TractScope': """ Create a scope including a set of US tracts, by FIPS code. Raise GeographyError if any FIPS code is invalid. @@ -669,11 +678,11 @@ class BlockGroupScope(CensusScope): """GeoScope at the Block Group granularity.""" includes_granularity: Literal['state', 'county', 'tract', 'block group'] includes: Sequence[str] - year: CensusYear + year: int granularity: Literal['block group'] = field(init=False, default='block group') @staticmethod - def in_states(states_fips: Sequence[str], year: CensusYear = DEFAULT_YEAR) -> 'BlockGroupScope': + def in_states(states_fips: Sequence[str], year: int = DEFAULT_YEAR) -> 'BlockGroupScope': """ Create a scope including all block groups in a set of US states/state-equivalents. Raise GeographyError if any FIPS code is invalid. @@ -682,7 +691,7 @@ def in_states(states_fips: Sequence[str], year: CensusYear = DEFAULT_YEAR) -> 'B return BlockGroupScope('state', states_fips, year) @staticmethod - def in_states_by_code(states_code: Sequence[str], year: CensusYear = DEFAULT_YEAR) -> 'BlockGroupScope': + def in_states_by_code(states_code: Sequence[str], year: int = DEFAULT_YEAR) -> 'BlockGroupScope': """ Create a scope including all block groups in a set of US states/state-equivalents, by postal code (two-letter abbreviation). @@ -692,7 +701,7 @@ def in_states_by_code(states_code: Sequence[str], year: CensusYear = DEFAULT_YEA return BlockGroupScope('state', states_fips, year) @staticmethod - def in_counties(counties_fips: Sequence[str], year: CensusYear = DEFAULT_YEAR) -> 'BlockGroupScope': + def in_counties(counties_fips: Sequence[str], year: int = DEFAULT_YEAR) -> 'BlockGroupScope': """ Create a scope including all block groups in a set of US counties, by FIPS code. Raise GeographyError if any FIPS code is invalid. @@ -701,7 +710,7 @@ def in_counties(counties_fips: Sequence[str], year: CensusYear = DEFAULT_YEAR) - return BlockGroupScope('county', counties_fips, year) @staticmethod - def in_tracts(tract_fips: Sequence[str], year: CensusYear = DEFAULT_YEAR) -> 'BlockGroupScope': + def in_tracts(tract_fips: Sequence[str], year: int = DEFAULT_YEAR) -> 'BlockGroupScope': """ Create a scope including all block gropus in a set of US tracts, by FIPS code. Raise GeographyError if any FIPS code is invalid. @@ -710,7 +719,7 @@ def in_tracts(tract_fips: Sequence[str], year: CensusYear = DEFAULT_YEAR) -> 'Bl return BlockGroupScope('tract', tract_fips, year) @staticmethod - def in_block_groups(block_group_fips: Sequence[str], year: CensusYear = DEFAULT_YEAR) -> 'BlockGroupScope': + def in_block_groups(block_group_fips: Sequence[str], year: int = DEFAULT_YEAR) -> 'BlockGroupScope': """ Create a scope including a set of US block groups, by FIPS code. Raise GeographyError if any FIPS code is invalid. diff --git a/epymorph/geography/us_tiger.py b/epymorph/geography/us_tiger.py index 48418215..c6881350 100644 --- a/epymorph/geography/us_tiger.py +++ b/epymorph/geography/us_tiger.py @@ -5,7 +5,7 @@ """ from io import BytesIO from pathlib import Path -from typing import Literal +from typing import Literal, Sequence, TypeGuard from urllib.request import urlopen from warnings import warn @@ -40,9 +40,14 @@ # Below there are some commented-code remnants which demonstrate what it takes to support the additional # territories, in case we ever want to reverse this choice. -TigerYear = Literal[2000, 2010, 2020] +TigerYear = Literal[2000, 2009, 2010, 2011, 2012, 2013, 2014, + 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023] """A supported TIGER file year.""" +TIGER_YEARS: Sequence[TigerYear] = (2000, 2009, 2010, 2011, 2012, 2013, 2014, + 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023) +"""All supported TIGER file years.""" + _TIGER_URL = "https://www2.census.gov/geo/tiger" _TIGER_CACHE_PATH = Path("geography/tiger") @@ -137,6 +142,11 @@ def _get_info(cols: list[str], urls: list[str], result_cols: list[str]) -> DataF return df[df['GEOID'].apply(lambda x: x[0:2]).isin(_SUPPORTED_STATES)] +def is_tiger_year(year: int) -> TypeGuard[TigerYear]: + """A type-guard function to ensure a year is a supported TIGER year.""" + return year in TIGER_YEARS + + ########## # STATES # ########## @@ -145,10 +155,10 @@ def _get_info(cols: list[str], urls: list[str], result_cols: list[str]) -> DataF def _get_states_config(year: TigerYear) -> tuple[list[str], list[str], list[str]]: """Produce the args for _get_info or _get_geo (states).""" match year: - case 2020: + case year if year in range(2011, 2024): cols = ["GEOID", "NAME", "STUSPS"] urls = [ - f"{_TIGER_URL}/TIGER2020/STATE/tl_2020_us_state.zip" + f"{_TIGER_URL}/TIGER{year}/STATE/tl_{year}_us_state.zip" ] case 2010: cols = ["GEOID10", "NAME10", "STUSPS10"] @@ -156,6 +166,11 @@ def _get_states_config(year: TigerYear) -> tuple[list[str], list[str], list[str] f"{_TIGER_URL}/TIGER2010/STATE/2010/tl_2010_{xx}_state10.zip" for xx in _SUPPORTED_STATE_FILES ] + case 2009: + cols = ["STATEFP00", "NAME00", "STUSPS00"] + urls = [ + f"{_TIGER_URL}/TIGER2009/tl_2009_us_state00.zip" + ] case 2000: cols = ["STATEFP00", "NAME00", "STUSPS00"] urls = [ @@ -185,10 +200,10 @@ def get_states_info(year: TigerYear) -> DataFrame: def _get_counties_config(year: TigerYear) -> tuple[list[str], list[str], list[str]]: """Produce the args for _get_info or _get_geo (counties).""" match year: - case 2020: + case year if year in range(2011, 2024): cols = ["GEOID", "NAME"] urls = [ - f"{_TIGER_URL}/TIGER2020/COUNTY/tl_2020_us_county.zip" + f"{_TIGER_URL}/TIGER{year}/COUNTY/tl_{year}_us_county.zip" ] case 2010: cols = ["GEOID10", "NAME10"] @@ -196,6 +211,11 @@ def _get_counties_config(year: TigerYear) -> tuple[list[str], list[str], list[st f"{_TIGER_URL}/TIGER2010/COUNTY/2010/tl_2010_{xx}_county10.zip" for xx in _SUPPORTED_STATE_FILES ] + case 2009: + cols = ["CNTYIDFP00", "NAME00"] + urls = [ + f"{_TIGER_URL}/TIGER2009/tl_2009_us_county00.zip" + ] case 2000: cols = ["CNTYIDFP00", "NAME00"] urls = [ @@ -224,25 +244,34 @@ def get_counties_info(year: TigerYear) -> DataFrame: def _get_tracts_config(year: TigerYear) -> tuple[list[str], list[str], list[str]]: """Produce the args for _get_info or _get_geo (tracts).""" - states = get_states_info(year)["GEOID"] + states = get_states_info(year) match year: - case 2020: + case year if year in range(2011, 2024): cols = ["GEOID"] urls = [ - f"{_TIGER_URL}/TIGER2020/TRACT/tl_2020_{xx}_tract.zip" - for xx in states + f"{_TIGER_URL}/TIGER{year}/TRACT/tl_{year}_{xx}_tract.zip" + for xx in states["GEOID"] ] case 2010: cols = ["GEOID10"] urls = [ f"{_TIGER_URL}/TIGER2010/TRACT/2010/tl_2010_{xx}_tract10.zip" - for xx in states + for xx in states["GEOID"] + ] + case 2009: + def state_folder(fips, name): + return f"{fips}_{name.upper().replace(' ', '_')}" + + cols = ["CTIDFP00"] + urls = [ + f"{_TIGER_URL}/TIGER2009/{state_folder(xx, name)}/tl_2009_{xx}_tract00.zip" + for xx, name in zip(states["GEOID"], states["NAME"]) ] case 2000: cols = ["CTIDFP00"] urls = [ f"{_TIGER_URL}/TIGER2010/TRACT/2000/tl_2010_{xx}_tract00.zip" - for xx in states + for xx in states["GEOID"] ] case _: raise GeographyError(f"Unsupported year: {year}") @@ -266,25 +295,34 @@ def get_tracts_info(year: TigerYear) -> DataFrame: def _get_block_groups_config(year: TigerYear) -> tuple[list[str], list[str], list[str]]: """Produce the args for _get_info or _get_geo (block groups).""" - states = get_states_info(year)["GEOID"] + states = get_states_info(year) match year: - case 2020: + case year if year in range(2011, 2024): cols = ["GEOID"] urls = [ - f"{_TIGER_URL}/TIGER2020/BG/tl_2020_{xx}_bg.zip" - for xx in states + f"{_TIGER_URL}/TIGER{year}/BG/tl_{year}_{xx}_bg.zip" + for xx in states["GEOID"] ] case 2010: cols = ["GEOID10"] urls = [ f"{_TIGER_URL}/TIGER2010/BG/2010/tl_2010_{xx}_bg10.zip" - for xx in states + for xx in states["GEOID"] + ] + case 2009: + def state_folder(fips, name): + return f"{fips}_{name.upper().replace(' ', '_')}" + + cols = ["BKGPIDFP00"] + urls = [ + f"{_TIGER_URL}/TIGER2009/{state_folder(xx, name)}/tl_2009_{xx}_bg00.zip" + for xx, name in zip(states["GEOID"], states["NAME"]) ] case 2000: cols = ["BKGPIDFP00"] urls = [ f"{_TIGER_URL}/TIGER2010/BG/2000/tl_2010_{xx}_bg00.zip" - for xx in states + for xx in states["GEOID"] ] case _: raise GeographyError(f"Unsupported year: {year}")