Skip to content

Commit cd225d3

Browse files
author
Nathaniel Henry
committed
Rewrite the tutorials and README around the boundary endpoint, layered maps, and named arguments.
1 parent 7bf43cd commit cd225d3

7 files changed

Lines changed: 286 additions & 190 deletions

File tree

README.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,15 @@ A few terms come up throughout the API:
5252
- **Destination type.** A category of place, such as grocery stores or libraries.
5353
Each type has a numeric id. Look them up with `close.destination_types()`.
5454
- **Mode.** How someone travels: walk, bike, or transit.
55-
- **Isochrone** or **catchment.** Two views of the same reachability: the area you
56-
can reach from a point within a time limit (an isochrone), or every block that
57-
can reach a place (a catchment).
55+
- **Isochrone** or **catchment**: the area you can reach starting from a point
56+
within a time limit, by a selected travel mode.
5857

5958
## Choosing an output
6059

6160
Set `output` on the client, or per call:
6261

63-
- `output = "spatial"` (the default) returns a GeoDataFrame where geometry applies
64-
and a DataFrame otherwise. Block routes join census-block boundaries with
62+
- `output = "spatial"` (the default) returns a GeoDataFrame for inherently spatial
63+
data and a DataFrame otherwise. Block routes join census-block boundaries with
6564
`pygris` (the `tiger` extra), downloaded once and cached.
6665
- `output = "tabular"` returns a plain DataFrame for every route and never
6766
downloads boundaries. Reach for it when you only want the numbers.

docs/getting_started.md

Lines changed: 58 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@ kernelspec:
88
name: python3
99
---
1010

11-
# Getting started
11+
# Get started
1212

13-
A short tour of the client. The tutorials go further.
13+
`closecity` reads the Close API: travel times from every US census block to nearby
14+
places, on foot, by bike, and by public transit. This page is a short tour; the
15+
three tutorials go further. The full list of query methods is on the
16+
[API reference](api.rst), and the wider API is documented at
17+
[docs.close.city](https://docs.close.city).
1418

1519
```{code-cell} python
1620
:tags: [remove-cell]
@@ -28,20 +32,21 @@ close = Client(os.environ.get("CLOSECITY_KEY"))
2832
A few terms come up throughout:
2933

3034
- **Census block.** The smallest area the Census Bureau publishes. Each one has a
31-
15-digit id, its **GEOID**.
35+
15-digit id, its **GEOID**. Block GEOIDs come from the census — look them up with
36+
`pygris` or the Census Bureau geocoder/API, or read them straight off Close's
37+
block routes (`blocks_query`, `place_blocks`).
3238
- **Destination type.** A category of place, such as grocery stores or libraries.
3339
Every type has a numeric id.
3440
- **Mode.** How someone travels: walk, bike, or transit.
35-
- **Isochrone** or **catchment.** Two views of the same reachability: the area
36-
reachable from a point within a time limit (an isochrone), or every block that
37-
can reach a given place (a catchment).
41+
- **Isochrone** or **catchment**: the area you can reach starting from a point
42+
within a time limit, by a selected travel mode.
3843

3944
## Travel times
4045

4146
Times to nearby places are **capped at 30 minutes** for each mode, and recorded in
4247
**whole minutes**. A missing time means the place is not reachable within the cap,
43-
not that it is zero. Isochrones are the exception: they are available for any
44-
budget up to an hour.
48+
not that it is zero. Isochrones are the exception: they are available for any budget
49+
up to an hour.
4550

4651
## Build a client
4752

@@ -53,71 +58,85 @@ from closecity import Client, close_map
5358
close = Client("ck_live_your_key") # use your own key here
5459
```
5560

56-
The catalog and lookup routes are free, so `Client()` with no key works for those.
57-
They come back as data frames:
61+
The catalog and lookup routes are free, so `Client()` with no key also works for
62+
those.
5863

5964
```{code-cell} python
6065
close.modes()
6166
```
6267

6368
## Look things up instead of guessing
6469

65-
Read the numeric id for a category from the catalog, and turn a city name into a
66-
GEOID and a centre point. Both are plain data frames, so you filter and index them
67-
the usual way. The catalog's `name` column is the readable label ("Grocery
68-
stores"); the underscored `label` is the internal key you match on. A place lookup
69-
carries a `state`, so you can tell Providence, RI from the one in Utah.
70+
Two free calls save you from memorising codes. Both come back as data frames, so you
71+
filter and index them the usual way: read the numeric id for a category from the
72+
catalog, and turn a city name into a GEOID and a centre point.
7073

7174
```{code-cell} python
72-
types = close.destination_types()
73-
supermarket_dest_type = types.loc[types["label"] == "grocery_stores",
74-
"dest_type_id"].iloc[0]
75+
amenity_types = close.destination_types()
76+
supermarket_type = amenity_types.loc[amenity_types["label"] == "grocery_stores",
77+
"dest_type_id"].iloc[0]
7578
76-
providence_ri = close.places("Providence").iloc[0]
79+
providence_ri = close.places(q = "Providence").iloc[0]
7780
providence_ri[["name", "state", "geoid"]]
7881
```
7982

83+
The catalog's `name` column is the readable label ("Grocery stores"); the underscored
84+
`label` is the internal key you match on. A place lookup carries a `state`, so you can
85+
tell Providence, RI from the one in Utah. When you have a point rather than a block,
86+
`point_summary(lat = , lon = )` reads the same travel times for a `lat`/`lon` starting
87+
point instead of a GEOID.
88+
8089
## Make a call and map it
8190

82-
Routes with geometry return a GeoDataFrame. `close_map()` draws it on an
83-
interactive basemap in one line — bright, hoverable points here.
91+
Routes with geometry return a GeoDataFrame. `close_map()` draws it on an interactive
92+
basemap in one line — bright, hoverable points here, with the city boundary behind
93+
them and the view zoomed to fit.
8494

8595
```{code-cell} python
86-
supermarkets = close.place_pois(providence_ri["geoid"], type = supermarket_dest_type)
87-
close_map(supermarkets, color = "#e8590c")
96+
supermarkets = close.place_pois(geoid = providence_ri["geoid"], type = supermarket_type)
97+
city_boundary = close.place_boundary(geoid = providence_ri["geoid"])
98+
close_map(supermarkets, color = "#e8590c", boundary = city_boundary, label = "name")
8899
```
89100

90101
## Choose an output
91102

92-
Every route returns tabular data by default: a GeoDataFrame where geometry applies,
93-
a plain DataFrame otherwise. The `output` setting changes the shape — `"tabular"`
94-
never downloads boundaries, and `"raw"` returns the underlying reply with its
95-
metering and cursor fields. Set it on the client, or pass `output=` to one call.
103+
Set `output` on the client, or per call:
104+
105+
- `output = "spatial"` (the default) returns a GeoDataFrame for inherently spatial
106+
data and a DataFrame otherwise. Block routes join census-block boundaries with
107+
`pygris` (the `tiger` extra), downloaded once and cached.
108+
- `output = "tabular"` returns a plain DataFrame for every route and never downloads
109+
boundaries. Reach for it when you only want the numbers.
110+
- `output = "raw"` returns the underlying `Reply` / `Paginator`, with the parsed body
111+
on `.data` and the token counts alongside.
96112

97-
The same block summary as a DataFrame (the default):
113+
A block summary, with the readable category names merged on and sorted by time:
98114

99115
```{code-cell} python
100-
close.block_summary("440070008001068", mode = "walk")
116+
walk_times = close.block_summary(geoid = "440070008001068", mode = "walk")
117+
walk_times = walk_times.merge(amenity_types[["dest_type_id", "name"]], on = "dest_type_id")
118+
walk_times.sort_values("travel_time")[["name", "travel_time"]]
101119
```
102120

103-
...and as the raw reply, whose `results` you can index yourself:
121+
...and the same call as the raw reply, whose parsed `results` you can inspect yourself:
104122

105123
```{code-cell} python
106-
raw = close.block_summary("440070008001068", mode = "walk", output = "raw")
107-
raw.results[:3]
124+
raw = close.block_summary(geoid = "440070008001068", mode = "walk", output = "raw")
125+
raw.data["results"][:3]
108126
```
109127

110-
In the frame modes, the token counts and other reply metadata ride on `df.attrs`.
111-
112-
## Errors
128+
## Handling errors
113129

114-
Problem responses become typed exceptions.
130+
Problem responses become typed exceptions. Catch a specific one, or the
131+
`CloseAPIError` base.
115132

116-
```{code-cell} python
117-
from closecity import CloseAPIError
133+
```python
134+
from closecity import TokensExhaustedError, CloseAPIError
118135

119136
try:
120-
close.block_summary("000000000000000")
137+
close.block_summary(geoid = "000000000000000")
138+
except TokensExhaustedError:
139+
...
121140
except CloseAPIError as err:
122141
print(err.status, err.slug)
123142
```

docs/token_use.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ when you want the etag:
3333

3434
```python
3535
close.output = "raw"
36-
first = close.block_summary("440070008001068", mode = "walk")
37-
again = close.block_summary("440070008001068", mode = "walk",
36+
first = close.block_summary(geoid = "440070008001068", mode = "walk")
37+
again = close.block_summary(geoid = "440070008001068", mode = "walk",
3838
if_none_match = first.etag)
3939
again.not_modified # True, and nothing was charged
4040
```

docs/tutorials/amenity_basket.md

Lines changed: 62 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ here applied to Richmond, Virginia.
2222
```{code-cell} python
2323
:tags: [remove-cell]
2424
import os
25+
import pandas as pd
2526
from closecity import Client, close_map
2627
import plotly.io as pio
2728
@@ -33,7 +34,7 @@ close = Client(os.environ.get("CLOSECITY_KEY"))
3334
## Set up
3435

3536
Read the six category ids from the free catalog, and turn the city name into a centre
36-
point.
37+
point and a boundary.
3738

3839
```python
3940
from closecity import Client, close_map
@@ -42,8 +43,8 @@ close = Client("ck_live_your_key") # use your own key here
4243
```
4344

4445
```{code-cell} python
45-
types = close.destination_types()
46-
ids = dict(zip(types["label"], types["dest_type_id"]))
46+
amenity_types = close.destination_types()
47+
ids = dict(zip(amenity_types["label"], amenity_types["dest_type_id"]))
4748
basket = {
4849
"supermarket": ids["grocery_stores"],
4950
"library": ids["libraries"],
@@ -53,22 +54,23 @@ basket = {
5354
"cafe": ids["cafes"],
5455
}
5556
56-
city = close.places("Richmond").iloc[0]
57+
city = close.places(q = "Richmond").iloc[0]
58+
city_boundary = close.place_boundary(geoid = city["geoid"])
5759
```
5860

5961
## Pull the blocks, with population
6062

61-
One call gets the walk time from every block to each of the six categories, plus
62-
each block's population. To keep this tutorial cheap we take the central blocks
63-
within a radius; `place_blocks(city["geoid"])` pulls **every** block in the city
64-
the same way (at a higher token cost).
63+
One call gets the walk time from every block to each of the six categories, plus each
64+
block's population. To keep this tutorial cheap we take the central blocks within a
65+
radius; `place_blocks(geoid = city["geoid"])` pulls **every** block in the city the
66+
same way (at a higher token cost).
6567

6668
```{code-cell} python
6769
blocks = close.blocks_query(
6870
center = {"lon": city["lon"], "lat": city["lat"]}, radius_m = 2500,
6971
mode = "walk", type = list(basket.values()), include_population = True)
7072
71-
one_per_block = blocks.drop_duplicates("geoid").copy()
73+
one_per_block = blocks.drop_duplicates("geoid").reset_index(drop = True)
7274
total_pop = one_per_block["population"].sum()
7375
```
7476

@@ -84,26 +86,29 @@ for name, type_id in basket.items():
8486
print(f"{name:11} {100 * pop / total_pop:3.0f}%")
8587
```
8688

87-
Map one amenity — every block shown, the covered ones highlighted.
89+
Parks and restaurants tend to be everywhere; supermarkets and libraries are usually
90+
the hardest to reach. Map one amenity — every block shown, the covered ones
91+
highlighted, the city boundary behind.
8892

8993
```{code-cell} python
90-
near_transit = set(blocks.loc[(blocks.dest_type_id == basket["transit"]) &
94+
near_library = set(blocks.loc[(blocks.dest_type_id == basket["library"]) &
9195
(blocks.travel_time <= 15), "geoid"])
92-
one_per_block["has_transit"] = one_per_block.geoid.isin(near_transit)
93-
close_map(one_per_block, highlight = "has_transit", color = "#058040")
96+
one_per_block["has_library"] = one_per_block.geoid.isin(near_library)
97+
close_map(one_per_block, highlight = "has_library", color = "#058040",
98+
boundary = city_boundary)
9499
```
95100

96101
## The 15-minute-city score
97102

98103
Count, for each block, how many of the six amenities are within a 15-minute walk.
99-
That score, from 0 to 6, is the map planners reach for. It reuses the data you
100-
already pulled, so it costs nothing more.
104+
That score, from 0 to 6, is the map planners reach for; blue marks the best-served
105+
blocks. It reuses the data you already pulled, so it costs nothing more.
101106

102107
```{code-cell} python
103108
covered = blocks[blocks.travel_time <= 15]
104109
score = covered.groupby("geoid")["dest_type_id"].nunique()
105110
one_per_block["score"] = one_per_block.geoid.map(score).fillna(0).astype(int)
106-
close_map(one_per_block, fill = "score")
111+
close_map(one_per_block, fill = "score", boundary = city_boundary)
107112
```
108113

109114
## Who can reach all six
@@ -122,7 +127,8 @@ basket_pop = one_per_block.loc[one_per_block.geoid.isin(covered_all),
122127
print(f"All six amenities: {100 * basket_pop / total_pop:.0f}% of residents")
123128
124129
one_per_block["full_basket"] = one_per_block.geoid.isin(covered_all)
125-
close_map(one_per_block, highlight = "full_basket", color = "#f36e21")
130+
close_map(one_per_block, highlight = "full_basket", color = "#f36e21",
131+
boundary = city_boundary)
126132
```
127133

128134
## Which amenity to add first
@@ -140,15 +146,49 @@ for name, type_id in basket.items():
140146
print(f"{name:11} {pop:6.0f} residents would gain access")
141147
```
142148

149+
## Who is one or two amenities away
150+
151+
The residents worth targeting first are the ones *almost* there — a block that has
152+
five of the six is a much easier win than one that has none. Build a block-by-amenity
153+
coverage grid, count how many amenities each block is missing, and for the blocks
154+
short by just one or two, break down which amenities are the gap.
155+
156+
```{code-cell} python
157+
import numpy as np
158+
159+
names = list(basket)
160+
covered_by = {name: set(blocks.loc[(blocks.dest_type_id == basket[name]) &
161+
(blocks.travel_time <= 15), "geoid"])
162+
for name in names}
163+
geoids = one_per_block["geoid"].to_numpy()
164+
pops = one_per_block["population"].to_numpy()
165+
166+
# One row per block: which amenities are missing (not within 15 minutes)?
167+
missing = np.array([[g not in covered_by[name] for name in names] for g in geoids])
168+
n_missing = missing.sum(axis = 1)
169+
gap = np.array([" + ".join(n for n, m in zip(names, row) if m) for row in missing])
170+
171+
almost = np.isin(n_missing, [1, 2])
172+
almost_pop = pops[almost].sum()
173+
print(f"{100 * almost_pop / total_pop:.0f}% of residents are one or two amenities "
174+
f"short of the full basket.\n")
175+
176+
by_gap = (pd.Series(pops[almost], index = gap[almost])
177+
.groupby(level = 0).sum().sort_values(ascending = False))
178+
for g, p in by_gap.items():
179+
print(f" missing {g:22} {100 * p / almost_pop:3.0f}%")
180+
```
181+
143182
## Site a new supermarket
144183

145184
The counts above say *which* amenity to add; the next question is *where*. Take a
146-
candidate site near the city centre and ask how many residents would newly gain a
147-
supermarket within a 15-minute walk if one opened there. A `direction="to"`
185+
candidate site on land north of the James River and ask how many residents would newly
186+
gain a supermarket within a 15-minute walk if one opened there. A `direction="to"`
148187
isochrone gives exactly the blocks that could reach the site on foot in 15 minutes.
149188

150189
```{code-cell} python
151-
reachable = close.isochrone(lon = city["lon"], lat = city["lat"], mode = "walk",
190+
site_lon, site_lat = -77.437, 37.548
191+
reachable = close.isochrone(lon = site_lon, lat = site_lat, mode = "walk",
152192
direction = "to", minutes = 15, format = "blocks")
153193
154194
near_supermarket = set(blocks.loc[(blocks.dest_type_id == basket["supermarket"]) &
@@ -163,5 +203,6 @@ Map the whole city and highlight the blocks that would newly gain access.
163203

164204
```{code-cell} python
165205
one_per_block["newly_served"] = one_per_block.geoid.isin(newly_served)
166-
close_map(one_per_block, highlight = "newly_served", color = "#e8590c")
206+
close_map(one_per_block, highlight = "newly_served", color = "#e8590c",
207+
boundary = city_boundary)
167208
```

0 commit comments

Comments
 (0)