summaryrefslogtreecommitdiff
path: root/geocode.py
blob: e9e5c35c5fbec6a272518351c461ec5a4fed4243 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
#!/usr/bin/env python3

# © Copyright 2022, Scott Gasch

"""Wrapper around US Census address geocoder API described here:

* https://www2.census.gov/geo/pdfs/maps-data/data/Census_Geocoder_User_Guide.pdf
* https://geocoding.geo.census.gov/geocoder/Geocoding_Services_API.pdf

Also try::

    $ curl --form [email protected] \
           --form benchmark=2020 \
           https://geocoding.geo.census.gov/geocoder/locations/addressbatch \
           --output geocoderesult.csv
"""

import json
import logging
from typing import Any, Dict, List, Optional

import requests
from requests.utils import requote_uri

import list_utils

logger = logging.getLogger(__name__)


def geocode_address(address: str) -> Optional[Dict[str, Any]]:
    """Send a single address to the US Census geocoding API in order to
    lookup relevant data about it (including, if possible, its
    lat/long).  The response is a parsed JSON chunk of data with N
    addressMatches in the result section and the details of each match
    within it.

    Args:
        address: the full address to lookup in the form: "STREET
        ADDRESS, CITY, STATE, ZIPCODE".  These components may be
        omitted and the service will make educated guesses but
        the commas delimiting each component must be included.

    Returns:
        A parsed json dict with a bunch of information about the
            address contained within it.  Each 'addressMatch'
            in the JSON describes the details of a possible match.
            Returns None if there was an error or the address is
            not known.

    >>> json = geocode_address('4600 Silver Hill Rd,, 20233')
    >>> json['result']['addressMatches'][0]['matchedAddress']
    '4600 SILVER HILL RD, WASHINGTON, DC, 20233'

    >>> json['result']['addressMatches'][0]['coordinates']
    {'x': -76.9274328556918, 'y': 38.845989080537514}
    """
    url = 'https://geocoding.geo.census.gov/geocoder/geographies/onelineaddress'
    url += f'?address={address}'
    url += '&returntype=geographies&layers=all&benchmark=4&vintage=4&format=json'
    url = requote_uri(url)
    logger.debug('GET: %s', url)
    try:
        r = requests.get(url)
    except Exception as e:
        logger.exception(e)
        return None

    if r.status_code != 200:
        logger.debug(r.text)
        logger.error('Unexpected response code %d, wanted 200.  Fail.', r.status_code)
        return None
    logger.debug('Response: %s', json.dumps(r.json(), indent=4, sort_keys=True))
    return r.json()


def batch_geocode_addresses(addresses: List[str]) -> Optional[List[str]]:
    """Send a list of addresses for batch geocoding to a web service
    operated by the US Census Bureau.

    Args:
        addresses: a list of addresses to geocode.  Each line of the
            input list should be a single address in the form: "STREET
            ADDRESS, CITY, STATE, ZIPCODE".  Individual address components
            may be omitted and the service will make educated guesses but
            the commas delimiters between address components may not be
            omitted.

    Returns:
        An array of the same size as the input array with one
        answer record per line.  Returns None on error.

    Note: this code will deal with requests >10k addresses by chunking
    them internally because the census website disallows requests >
    10k lines.

    >>> batch_geocode_addresses(
    ...     [
    ...         '4600 Silver Hill Rd, Washington, DC, 20233',
    ...         '935 Pennsylvania Avenue, NW, Washington, DC, 20535-0001',
    ...         '1600 Pennsylvania Avenue NW, Washington, DC, 20500',
    ...         '700 Pennsylvania Avenue NW, Washington, DC, 20408',
    ...     ]
    ... )
    ['"1"," 4600 Silver Hill Rd,  Washington,  DC,  20233","Match","Exact","4600 SILVER HILL RD, WASHINGTON, DC, 20233","-76.92743285599994,38.84598908100003","76355984","L","24","033","802405","2004"', '"2"," 935 Pennsylvania Avenue,  NW,  Washington,  DC","No_Match"', '"3"," 1600 Pennsylvania Avenue NW,  Washington,  DC,  20500","Match","Exact","1600 PENNSYLVANIA AVE NW, WASHINGTON, DC, 20500","-77.03534009999998,38.89875363300007","76225813","L","11","001","980000","1034"', '"4"," 700 Pennsylvania Avenue NW,  Washington,  DC,  20408","Match","Exact","700 PENNSYLVANIA AVE NW, WASHINGTON, DC, 20408","-77.02304089899997,38.89361872300003","76226346","L","11","001","980000","1025"']
    """

    n = 1
    url = 'https://geocoding.geo.census.gov/geocoder/geographies/addressbatch'
    payload = {'benchmark': '4', 'vintage': '4'}
    out = []
    for chunk in list_utils.shard(addresses, 9999):
        raw_file = ''
        for address in chunk:
            raw_file += f'{n}, {address}\n'
            n += 1
        files = {'addressFile': ('input.csv', raw_file)}
        logger.debug('POST: %s', url)
        try:
            r = requests.post(url, files=files, data=payload)
        except Exception as e:
            logger.exception(e)
            return None

        if r.status_code != 200:
            logger.debug(r.text)
            logger.error('Unexpected response code %d, wanted 200.  Fail.', r.status_code)
            return None
        logger.debug('Response: %s', r.text)
        for line in r.text.split('\n'):
            line = line.strip()
            if len(line) > 0:
                out.append(line)
    return out


if __name__ == '__main__':
    import doctest

    doctest.testmod()