Skip to content

Commit e6fa19c

Browse files
author
Nathaniel Henry
committed
Rewrite the tutorials and README around interactive close_map() maps and the supermarket naming.
1 parent 3c5593d commit e6fa19c

8 files changed

Lines changed: 206 additions & 157 deletions

File tree

README.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,16 @@ You make requests through a client. Routes with geometry come back as
2323
GeoDataFrames, so you can map them right away.
2424

2525
```python
26-
from closecity import Client
26+
from closecity import Client, close_map
2727

2828
# The key (ck_live_) comes from https://account.close.city (5,000 free tokens
2929
# on signup, no card). You can also set the CLOSECITY_KEY environment variable
3030
# and call Client() with no argument.
3131
close = Client("ck_live_your_key") # use your own key here
3232

33-
# Grocery stores within a 1.5 km walk of a point, as points:
34-
groceries = close.pois_search(lat = 41.823, lon = -71.412, radius_m = 1500, type = 30)
35-
groceries.plot()
33+
# Supermarkets within a 1.5 km walk of a point (type 30 is grocery stores):
34+
supermarkets = close.pois_search(lat = 41.823, lon = -71.412, radius_m = 1500, type = 30)
35+
close_map(supermarkets, color = "#e8590c") # interactive map, bright hoverable points
3636
```
3737

3838
Catalog and lookup routes are free, need no key, and come back as data frames:
@@ -43,7 +43,7 @@ close.modes() # walk, bike, transit
4343
close.places("Providence") # a city name to its GEOID and centre
4444
```
4545

46-
## Words you will see
46+
## Key terms
4747

4848
A few terms come up throughout the API:
4949

@@ -52,9 +52,9 @@ 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.** The area you can reach from a point within a time limit, as a
56-
polygon.
57-
- **Catchment.** The reverse of an isochrone: every block that can reach a place.
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).
5858

5959
## Choosing an output
6060

docs/getting_started.md

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ A short tour of the client. The tutorials go further.
1515
```{code-cell} python
1616
:tags: [remove-cell]
1717
import os
18-
from closecity import Client
18+
from closecity import Client, close_map
1919
close = Client(os.environ.get("CLOSECITY_KEY"))
2020
```
2121

22-
## Words you will see
22+
## Key terms
2323

2424
A few terms come up throughout:
2525

@@ -28,15 +28,23 @@ A few terms come up throughout:
2828
- **Destination type.** A category of place, such as grocery stores or libraries.
2929
Every type has a numeric id.
3030
- **Mode.** How someone travels: walk, bike, or transit.
31-
- **Isochrone.** The area reachable from a point within a time limit, as a polygon.
32-
- **Catchment.** The reverse: every block that can reach a given place.
31+
- **Isochrone** or **catchment.** Two views of the same reachability: the area
32+
reachable from a point within a time limit (an isochrone), or every block that
33+
can reach a given place (a catchment).
34+
35+
## Travel times
36+
37+
Times to nearby places are **capped at 30 minutes** for each mode, and recorded in
38+
**whole minutes**. A missing time means the place is not reachable within the cap,
39+
not that it is zero. Isochrones are the exception: they are available for any
40+
budget up to an hour.
3341

3442
## Build a client
3543

3644
You make every request through a client.
3745

3846
```python
39-
from closecity import Client
47+
from closecity import Client, close_map
4048

4149
close = Client("ck_live_your_key") # use your own key here
4250
```
@@ -52,51 +60,47 @@ close.modes()
5260

5361
Read the numeric id for a category from the catalog, and turn a city name into a
5462
GEOID and a centre point. Both are plain data frames, so you filter and index them
55-
the usual way.
63+
the usual way. The catalog's `name` column is the readable label ("Grocery
64+
stores"); the underscored `label` is the internal key you match on. A place lookup
65+
carries a `state`, so you can tell Providence, RI from the one in Utah.
5666

5767
```{code-cell} python
5868
types = close.destination_types()
59-
grocery = types.loc[types["label"] == "grocery_stores", "dest_type_id"].iloc[0]
69+
supermarket_dest_type = types.loc[types["label"] == "grocery_stores",
70+
"dest_type_id"].iloc[0]
6071
61-
matches = close.places("Providence")
62-
providence = matches.iloc[0]
63-
providence["geoid"]
72+
providence_ri = close.places("Providence").iloc[0]
73+
providence_ri[["name", "state", "geoid"]]
6474
```
6575

6676
## Make a call and map it
6777

68-
Routes with geometry return a GeoDataFrame, so you can map the result straight
69-
away.
78+
Routes with geometry return a GeoDataFrame. `close_map()` draws it on an
79+
interactive basemap in one line — bright, hoverable points here.
7080

7181
```{code-cell} python
72-
groceries = close.pois_search(lat = providence["lat"], lon = providence["lon"],
73-
radius_m = 1500, type = grocery)
74-
groceries.plot(color = "#202a5b")
82+
supermarkets = close.place_pois(providence_ri["geoid"], type = supermarket_dest_type)
83+
close_map(supermarkets, color = "#e8590c")
7584
```
7685

7786
## Choose an output
7887

79-
Every route returns tabular data by default. The `output` setting controls the
80-
shape:
81-
82-
- `"spatial"` (the default) returns a GeoDataFrame where geometry applies, and a
83-
plain DataFrame otherwise.
84-
- `"tabular"` returns a plain DataFrame for every route and never downloads block
85-
boundaries. Reach for it when you only want the numbers.
86-
- `"raw"` returns the underlying reply, with the parsed body on `.data` and the
87-
token counts alongside.
88+
Every route returns tabular data by default: a GeoDataFrame where geometry applies,
89+
a plain DataFrame otherwise. The `output` setting changes the shape — `"tabular"`
90+
never downloads boundaries, and `"raw"` returns the underlying reply with its
91+
metering and cursor fields. Set it on the client, or pass `output=` to one call.
8892

89-
Set it on the client, or pass `output=` to a single call.
93+
The same block summary as a DataFrame (the default):
9094

9195
```{code-cell} python
92-
close.output = "raw"
93-
summary = close.block_summary("440070008001068", mode = "walk")
94-
summary.results
96+
close.block_summary("440070008001068", mode = "walk")
9597
```
9698

99+
...and as the raw reply, whose `results` you can index yourself:
100+
97101
```{code-cell} python
98-
:tags: [remove-cell]
99-
close.output = "spatial"
102+
raw = close.block_summary("440070008001068", mode = "walk", output = "raw")
103+
raw.results[:3]
100104
```
101105

102106
In the frame modes, the token counts and other reply metadata ride on `df.attrs`.

docs/index.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ public transit. This is the data behind `close.city <https://close.city>`_.
77

88
.. code-block:: python
99
10-
from closecity import Client
10+
from closecity import Client, close_map
1111
1212
# The key (ck_live_) comes from https://account.close.city
1313
close = Client("ck_live_your_key") # use your own key here
1414
1515
# Routes with geometry return a GeoDataFrame, ready to map:
16-
groceries = close.pois_search(lat = 41.823, lon = -71.412, radius_m = 1500)
17-
groceries.plot()
16+
supermarkets = close.pois_search(lat = 41.823, lon = -71.412, radius_m = 1500, type = 30)
17+
close_map(supermarkets, color = "#e8590c") # interactive map, bright hoverable points
1818
1919
Results are tabular by default: a GeoDataFrame where geometry applies, a plain
2020
DataFrame otherwise. Install with ``pip install closecity``. The catalog and

docs/token_use.md

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ The API is metered in tokens: one per returned row, minimum one per request, and
2121
10 per isochrone contour. Tokens are rows, so spending well is mostly about not
2222
asking for rows you will throw away.
2323

24-
## Free things
24+
## Free queries
2525

26-
The whole catalog is free and keyless: `modes()`, `destination_types()`,
26+
Metadata queries are free and keyless: `modes()`, `destination_types()`,
2727
`vintage()`, `places()`, `isochrone_meta()`, `last_updated()`, and `health()`. Do
2828
your lookups before you spend a token.
2929

@@ -44,8 +44,11 @@ again.not_modified # True, and nothing was charged
4444
The rows a query returns multiply out as modes times types times blocks:
4545

4646
- **Pass `mode`.** Omitting it returns all three modes, so three times the rows.
47-
- **Request leaf types, not parents.** A parent type expands to its leaves, so
48-
`type = grocery` (one leaf) is a quarter of the rows of the `parks` parent.
47+
- **Filter by `type`.** The POI and areal routes take a destination `type`, so ask
48+
the server for only the category you want rather than fetching everything and
49+
filtering in pandas. Look the id up in `destination_types()`.
50+
- **Request leaf types, not parents.** A parent type expands to its leaves, so a
51+
single leaf id is a fraction of the rows of a parent like `parks`.
4952
- **Shrink the area.** Cost grows with the square of `radius_m`, so halving the
5053
radius is roughly a quarter of the tokens.
5154
- **Use `max_minutes`.** It drops rows you would filter out anyway.
@@ -64,11 +67,18 @@ shed = close.isochrone(block = "440070008001068", minutes = 30, mode = "walk",
6467
## Watch what you spend
6568

6669
Every metered reply carries the token counts. In the frame output modes they ride
67-
on `df.attrs`; in `output="raw"` they are on the reply.
70+
on `df.attrs`; in `output="raw"` they are on the reply. Here the `type` filter asks
71+
only for supermarkets, so you pay for a handful of rows, not every POI in the
72+
radius:
6873

6974
```{code-cell} python
70-
groceries = close.pois_search(lat = 41.823, lon = -71.412, radius_m = 1200)
71-
groceries.attrs["tokens_charged"], groceries.attrs["tokens_remaining"]
75+
types = close.destination_types()
76+
supermarket_dest_type = types.loc[types["label"] == "grocery_stores",
77+
"dest_type_id"].iloc[0]
78+
79+
supermarkets = close.pois_search(lat = 41.823, lon = -71.412, radius_m = 1200,
80+
type = supermarket_dest_type)
81+
supermarkets.attrs["tokens_charged"], supermarkets.attrs["tokens_remaining"]
7282
```
7383

7484
When you only want the numbers, `output="tabular"` skips the block-boundary

docs/tutorials/amenity_basket.md

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ kernelspec:
1111
# The amenity basket
1212

1313
A city planner wants every resident to be able to walk to a basket of six everyday
14-
amenities: a grocery store, a library, a park, a frequent-transit stop, a restaurant,
14+
amenities: a supermarket, a library, a park, a frequent-transit stop, a restaurant,
1515
and a cafe. This tutorial measures how many residents already have that, and shows
1616
where the gaps are. The idea follows
1717
[this analysis](https://nathenry.com/writing/2023-02-07-seattle-walkability.html),
@@ -22,7 +22,7 @@ here applied to Richmond, Virginia.
2222
```{code-cell} python
2323
:tags: [remove-cell]
2424
import os
25-
from closecity import Client
25+
from closecity import Client, close_map
2626
close = Client(os.environ.get("CLOSECITY_KEY"))
2727
```
2828

@@ -32,7 +32,7 @@ Read the six category ids from the free catalog, and turn the city name into a c
3232
point.
3333

3434
```python
35-
from closecity import Client
35+
from closecity import Client, close_map
3636

3737
close = Client("ck_live_your_key") # use your own key here
3838
```
@@ -41,7 +41,7 @@ close = Client("ck_live_your_key") # use your own key here
4141
types = close.destination_types()
4242
ids = dict(zip(types["label"], types["dest_type_id"]))
4343
basket = {
44-
"grocery": ids["grocery_stores"],
44+
"supermarket": ids["grocery_stores"],
4545
"library": ids["libraries"],
4646
"park": ids["parks"],
4747
"transit": ids["frequent_transit"],
@@ -54,15 +54,17 @@ city = close.places("Richmond").iloc[0]
5454

5555
## Pull the blocks, with population
5656

57-
One call gets the walk time from every block near downtown to each of the six
58-
categories, along with each block's population.
57+
One call gets the walk time from every block to each of the six categories, plus
58+
each block's population. To keep this tutorial cheap we take the central blocks
59+
within a radius; `place_blocks(city["geoid"])` pulls **every** block in the city
60+
the same way (at a higher token cost).
5961

6062
```{code-cell} python
6163
blocks = close.blocks_query(
6264
center = {"lon": city["lon"], "lat": city["lat"]}, radius_m = 2500,
6365
mode = "walk", type = list(basket.values()), include_population = True)
6466
65-
one_per_block = blocks.drop_duplicates("geoid")
67+
one_per_block = blocks.drop_duplicates("geoid").copy()
6668
total_pop = one_per_block["population"].sum()
6769
```
6870

@@ -78,14 +80,13 @@ for name, type_id in basket.items():
7880
print(f"{name:11} {100 * pop / total_pop:3.0f}%")
7981
```
8082

81-
Map one amenity to see the pattern.
83+
Map one amenity — every block shown, the covered ones highlighted.
8284

8385
```{code-cell} python
8486
near_transit = set(blocks.loc[(blocks.dest_type_id == basket["transit"]) &
8587
(blocks.travel_time <= 15), "geoid"])
86-
one_per_block = one_per_block.assign(
87-
has_transit = one_per_block.geoid.isin(near_transit))
88-
one_per_block.plot(column = "has_transit", cmap = "Greens")
88+
one_per_block["has_transit"] = one_per_block.geoid.isin(near_transit)
89+
close_map(one_per_block, highlight = "has_transit", color = "#058040")
8990
```
9091

9192
## The 15-minute-city score
@@ -97,9 +98,8 @@ already pulled, so it costs nothing more.
9798
```{code-cell} python
9899
covered = blocks[blocks.travel_time <= 15]
99100
score = covered.groupby("geoid")["dest_type_id"].nunique()
100-
one_per_block = one_per_block.assign(
101-
score = one_per_block.geoid.map(score).fillna(0).astype(int))
102-
one_per_block.plot(column = "score", cmap = "viridis", legend = True)
101+
one_per_block["score"] = one_per_block.geoid.map(score).fillna(0).astype(int)
102+
close_map(one_per_block, fill = "score")
103103
```
104104

105105
## Who can reach all six
@@ -117,9 +117,8 @@ basket_pop = one_per_block.loc[one_per_block.geoid.isin(covered_all),
117117
"population"].sum()
118118
print(f"All six amenities: {100 * basket_pop / total_pop:.0f}% of residents")
119119
120-
one_per_block = one_per_block.assign(
121-
full_basket = one_per_block.geoid.isin(covered_all))
122-
one_per_block.plot(column = "full_basket", cmap = "Oranges")
120+
one_per_block["full_basket"] = one_per_block.geoid.isin(covered_all)
121+
close_map(one_per_block, highlight = "full_basket", color = "#f36e21")
123122
```
124123

125124
## Which amenity to add first
@@ -137,10 +136,28 @@ for name, type_id in basket.items():
137136
print(f"{name:11} {pop:6.0f} residents would gain access")
138137
```
139138

140-
Map the uncovered blocks to see where new amenities would do the most good.
139+
## Site a new supermarket
140+
141+
The counts above say *which* amenity to add; the next question is *where*. Take a
142+
candidate site near the city centre and ask how many residents would newly gain a
143+
supermarket within a 15-minute walk if one opened there. A `direction="to"`
144+
isochrone gives exactly the blocks that could reach the site on foot in 15 minutes.
145+
146+
```{code-cell} python
147+
reachable = close.isochrone(lon = city["lon"], lat = city["lat"], mode = "walk",
148+
direction = "to", minutes = 15, format = "blocks")
149+
150+
near_supermarket = set(blocks.loc[(blocks.dest_type_id == basket["supermarket"]) &
151+
(blocks.travel_time <= 15), "geoid"])
152+
newly_served = set(reachable.geoid) - near_supermarket
153+
gain_pop = one_per_block.loc[one_per_block.geoid.isin(newly_served),
154+
"population"].sum()
155+
print(f"A supermarket here would newly serve {gain_pop:.0f} residents")
156+
```
157+
158+
Map the whole city and highlight the blocks that would newly gain access.
141159

142160
```{code-cell} python
143-
one_per_block = one_per_block.assign(
144-
uncovered = one_per_block.geoid.isin(uncovered))
145-
one_per_block.plot(column = "uncovered", cmap = "Blues")
161+
one_per_block["newly_served"] = one_per_block.geoid.isin(newly_served)
162+
close_map(one_per_block, highlight = "newly_served", color = "#e8590c")
146163
```

0 commit comments

Comments
 (0)