Skip to content

Commit 6630b1e

Browse files
author
Nathaniel Henry
committed
Rewrite amenity-basket around a basket table, per-block travel-time columns, and an auto-picked site.
1 parent 30c9eef commit 6630b1e

1 file changed

Lines changed: 79 additions & 108 deletions

File tree

docs/tutorials/amenity_basket.md

Lines changed: 79 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,13 @@ where the gaps are. The idea follows
1717
[this analysis](https://nathenry.com/writing/2023-02-07-seattle-walkability.html),
1818
here applied to Richmond, Virginia.
1919

20-
*Running this tutorial uses about 3,500 tokens.*
20+
*Running this tutorial uses about 3,600 tokens.*
2121

2222
```{code-cell} python
2323
:tags: [remove-cell]
2424
import os
2525
import pandas as pd
26+
import numpy as np
2627
from closecity import Client, close_map
2728
import plotly.io as pio
2829
@@ -33,8 +34,8 @@ close = Client(os.environ.get("CLOSECITY_KEY"))
3334

3435
## Set up
3536

36-
Read the six category ids from the free catalog, and turn the city name into a centre
37-
point and a boundary.
37+
Turn the city name into a centre point and a boundary, and build the basket as a
38+
small table: each amenity paired with its destination-type id.
3839

3940
```python
4041
from closecity import Client, close_map
@@ -45,14 +46,11 @@ close = Client("ck_live_your_key") # use your own key here
4546
```{code-cell} python
4647
amenity_types = close.destination_types()
4748
ids = dict(zip(amenity_types["label"], amenity_types["dest_type_id"]))
48-
basket = {
49-
"supermarket": ids["grocery_stores"],
50-
"library": ids["libraries"],
51-
"park": ids["parks"],
52-
"transit": ids["frequent_transit"],
53-
"restaurant": ids["restaurants"],
54-
"cafe": ids["cafes"],
55-
}
49+
basket = pd.DataFrame({
50+
"amenity": ["supermarket", "library", "park", "transit", "restaurant", "cafe"],
51+
"dest_type_id": [ids[k] for k in ["grocery_stores", "libraries", "parks",
52+
"frequent_transit", "restaurants", "cafes"]],
53+
})
5654
5755
city = close.places(q = "Richmond").iloc[0]
5856
city_boundary = close.place_boundary(geoid = city["geoid"])
@@ -67,43 +65,50 @@ same way (at a higher token cost).
6765

6866
```{code-cell} python
6967
blocks = close.blocks_query(
70-
center = {"lon": city["lon"], "lat": city["lat"]},
71-
radius_m = 2500,
72-
mode = "walk",
73-
type = list(basket.values()),
74-
include_population = True
75-
)
76-
77-
one_per_block = blocks.drop_duplicates("geoid").reset_index(drop = True)
68+
center = {"lon": city["lon"], "lat": city["lat"]}, radius_m = 2500,
69+
mode = "walk", type = basket["dest_type_id"].tolist(), include_population = True)
70+
```
71+
72+
Reshape to one row per block: keep just the GEOID and geometry, add the population,
73+
and add a walk-time column for each amenity (`NaN` when it is more than 30 minutes
74+
away). One clean table, no leftover census-block metadata.
75+
76+
```{code-cell} python
77+
one_per_block = blocks.drop_duplicates("geoid")[["geoid", "geometry"]].reset_index(drop = True)
78+
population = blocks.drop_duplicates("geoid").set_index("geoid")["population"]
79+
one_per_block["population"] = one_per_block["geoid"].map(population)
80+
for _, row in basket.iterrows():
81+
times = blocks[blocks["dest_type_id"] == row["dest_type_id"]].set_index("geoid")["travel_time"]
82+
one_per_block[f"{row['amenity']}_min"] = one_per_block["geoid"].map(times)
7883
total_pop = one_per_block["population"].sum()
84+
85+
# The six walk-time columns as a boolean "within 15 minutes" table, for the tallies.
86+
min_cols = [f"{a}_min" for a in basket["amenity"]]
87+
within_15 = (one_per_block[min_cols] <= 15).fillna(False)
7988
```
8089

8190
## Coverage, one amenity at a time
8291

8392
For each amenity, a block counts as covered when it is within a 15-minute walk.
8493

8594
```{code-cell} python
86-
for name, type_id in basket.items():
87-
covered = set(blocks.loc[(blocks.dest_type_id == type_id) &
88-
(blocks.travel_time <= 15), "geoid"])
89-
pop = one_per_block.loc[one_per_block.geoid.isin(covered), "population"].sum()
90-
print(f"{name:11} {100 * pop / total_pop:3.0f}%")
95+
for a in basket["amenity"]:
96+
covered = within_15[f"{a}_min"]
97+
pop = one_per_block.loc[covered, "population"].sum()
98+
print(f"{a:11} {100 * pop / total_pop:3.0f}%")
9199
```
92100

93101
Parks and restaurants tend to be everywhere; supermarkets and libraries are usually
94-
the hardest to reach. Map one amenity — every block shown, the covered ones
95-
highlighted, the city boundary behind.
102+
the hardest to reach. Map the library coverage — every block shown, the covered ones
103+
highlighted, the library locations as points, the city boundary behind.
96104

97105
```{code-cell} python
98-
near_library = set(blocks.loc[(blocks.dest_type_id == basket["library"]) &
99-
(blocks.travel_time <= 15), "geoid"])
100-
one_per_block["has_library"] = one_per_block.geoid.isin(near_library)
101-
close_map(
102-
one_per_block,
103-
highlight = "has_library",
104-
color = "#058040",
105-
boundary = city_boundary
106-
)
106+
one_per_block["has_library"] = within_15["library_min"]
107+
library_type = int(basket.loc[basket["amenity"] == "library", "dest_type_id"].iloc[0])
108+
libraries = close.pois_search(lat = city["lat"], lon = city["lon"], radius_m = 2500,
109+
type = library_type)
110+
close_map(one_per_block, highlight = "has_library", color = "#058040",
111+
points = libraries, boundary = city_boundary)
107112
```
108113

109114
## The 15-minute-city score
@@ -113,34 +118,21 @@ That score, from 0 to 6, is the map planners reach for; blue marks the best-serv
113118
blocks. It reuses the data you already pulled, so it costs nothing more.
114119

115120
```{code-cell} python
116-
covered = blocks[blocks.travel_time <= 15]
117-
score = covered.groupby("geoid")["dest_type_id"].nunique()
118-
one_per_block["score"] = one_per_block.geoid.map(score).fillna(0).astype(int)
121+
one_per_block["score"] = within_15.sum(axis = 1)
119122
close_map(one_per_block, fill = "score", reverse = True, boundary = city_boundary)
120123
```
121124

122125
## Who can reach all six
123126

124-
A block is fully covered only if all six amenities are within 15 minutes.
127+
A block is fully covered only when all six amenities are within 15 minutes.
125128

126129
```{code-cell} python
127-
covered_all = set(one_per_block.geoid)
128-
for type_id in basket.values():
129-
covered = set(blocks.loc[(blocks.dest_type_id == type_id) &
130-
(blocks.travel_time <= 15), "geoid"])
131-
covered_all &= covered
132-
133-
basket_pop = one_per_block.loc[one_per_block.geoid.isin(covered_all),
134-
"population"].sum()
130+
one_per_block["full_basket"] = one_per_block["score"] == 6
131+
basket_pop = one_per_block.loc[one_per_block["full_basket"], "population"].sum()
135132
print(f"All six amenities: {100 * basket_pop / total_pop:.0f}% of residents")
136133
137-
one_per_block["full_basket"] = one_per_block.geoid.isin(covered_all)
138-
close_map(
139-
one_per_block,
140-
highlight = "full_basket",
141-
color = "#f36e21",
142-
boundary = city_boundary
143-
)
134+
close_map(one_per_block, highlight = "full_basket", color = "#f36e21",
135+
boundary = city_boundary)
144136
```
145137

146138
## Which amenity to add first
@@ -149,43 +141,29 @@ Count how many not-yet-covered residents are missing each amenity. The amenity t
149141
most people lack is the one to add first.
150142

151143
```{code-cell} python
152-
uncovered = set(one_per_block.geoid) - covered_all
153-
for name, type_id in basket.items():
154-
covered = set(blocks.loc[(blocks.dest_type_id == type_id) &
155-
(blocks.travel_time <= 15), "geoid"])
156-
lacking = uncovered - covered
157-
pop = one_per_block.loc[one_per_block.geoid.isin(lacking), "population"].sum()
158-
print(f"{name:11} {pop:6.0f} residents would gain access")
144+
for a in basket["amenity"]:
145+
lacking = ~one_per_block["full_basket"] & ~within_15[f"{a}_min"]
146+
print(f"{a:11} {one_per_block.loc[lacking, 'population'].sum():6.0f} residents would gain access")
159147
```
160148

161149
## Who is one or two amenities away
162150

163-
The residents worth targeting first are the ones *almost* there — a block that has
164-
five of the six is a much easier win than one that has none. Build a block-by-amenity
165-
coverage grid, count how many amenities each block is missing, and for the blocks
166-
short by just one or two, break down which amenities are the gap.
151+
The residents worth targeting first are the ones *almost* there — a block with five
152+
of the six is a much easier win than one with none. Count how many amenities each
153+
block is missing, and for the blocks short by just one or two, break down which
154+
amenities are the gap.
167155

168156
```{code-cell} python
169-
import numpy as np
170-
171-
names = list(basket)
172-
covered_by = {name: set(blocks.loc[(blocks.dest_type_id == basket[name]) &
173-
(blocks.travel_time <= 15), "geoid"])
174-
for name in names}
175-
geoids = one_per_block["geoid"].to_numpy()
157+
have = within_15.to_numpy()
158+
amenities = list(basket["amenity"])
159+
n_missing = 6 - one_per_block["score"].to_numpy()
160+
gap = np.array([" + ".join(a for a, h in zip(amenities, row) if not h) for row in have])
176161
pops = one_per_block["population"].to_numpy()
177162
178-
# One row per block: which amenities are missing (not within 15 minutes)?
179-
missing = np.array([[g not in covered_by[name] for name in names] for g in geoids])
180-
n_missing = missing.sum(axis = 1)
181-
gap = np.array([" + ".join(n for n, m in zip(names, row) if m) for row in missing])
182-
183163
almost = np.isin(n_missing, [1, 2])
184164
almost_pop = pops[almost].sum()
185-
print(
186-
f"{100 * almost_pop / total_pop:.0f}% of residents are one or two amenities "
187-
f"short of the full basket.\n"
188-
)
165+
print(f"{100 * almost_pop / total_pop:.0f}% of residents are one or two amenities "
166+
f"short of the full basket.\n")
189167
190168
by_gap = (pd.Series(pops[almost], index = gap[almost])
191169
.groupby(level = 0).sum().sort_values(ascending = False))
@@ -195,38 +173,31 @@ for g, p in by_gap.items():
195173

196174
## Site a new supermarket
197175

198-
The counts above say *which* amenity to add; the next question is *where*. Take a
199-
candidate site on land north of the James River and ask how many residents would newly
200-
gain a supermarket within a 15-minute walk if one opened there. A `direction="to"`
201-
isochrone gives exactly the blocks that could reach the site on foot in 15 minutes.
176+
The counts above say *which* amenity to add; the next question is *where*. Pick the
177+
candidate site automatically: a populated block with no supermarket within a 15-minute
178+
walk, nearest the middle of the study area. A `direction="to"` isochrone then gives
179+
the blocks that could reach the site on foot in 15 minutes, and the ones not already
180+
served are the population this site would newly reach.
202181

203182
```{code-cell} python
204-
site_lon, site_lat = -77.437, 37.548
205-
reachable = close.isochrone(
206-
lon = site_lon,
207-
lat = site_lat,
208-
mode = "walk",
209-
direction = "to",
210-
minutes = 15,
211-
format = "blocks"
212-
)
213-
214-
near_supermarket = set(blocks.loc[(blocks.dest_type_id == basket["supermarket"]) &
215-
(blocks.travel_time <= 15), "geoid"])
216-
newly_served = set(reachable.geoid) - near_supermarket
217-
gain_pop = one_per_block.loc[one_per_block.geoid.isin(newly_served),
218-
"population"].sum()
183+
uncovered = one_per_block[~within_15["supermarket_min"] & (one_per_block["population"] > 0)]
184+
points = uncovered.geometry.representative_point()
185+
middle = one_per_block.geometry.union_all().centroid
186+
site = points.iloc[(((points.x - middle.x) ** 2 + (points.y - middle.y) ** 2)).to_numpy().argmin()]
187+
188+
reachable = close.isochrone(lon = site.x, lat = site.y, mode = "walk",
189+
direction = "to", minutes = 15, format = "blocks")
190+
near_supermarket = set(one_per_block.loc[within_15["supermarket_min"], "geoid"])
191+
newly_served = (set(reachable["geoid"]) & set(one_per_block["geoid"])) - near_supermarket
192+
gain_pop = one_per_block.loc[one_per_block["geoid"].isin(newly_served), "population"].sum()
219193
print(f"A supermarket here would newly serve {gain_pop:.0f} residents")
220194
```
221195

222-
Map the whole city and highlight the blocks that would newly gain access.
196+
Map the whole city and highlight the blocks that would newly gain access, with the
197+
candidate site marked by an X.
223198

224199
```{code-cell} python
225-
one_per_block["newly_served"] = one_per_block.geoid.isin(newly_served)
226-
close_map(
227-
one_per_block,
228-
highlight = "newly_served",
229-
color = "#e8590c",
230-
boundary = city_boundary
231-
)
200+
one_per_block["newly_served"] = one_per_block["geoid"].isin(newly_served)
201+
close_map(one_per_block, highlight = "newly_served", color = "#e8590c",
202+
mark = (site.x, site.y), boundary = city_boundary)
232203
```

0 commit comments

Comments
 (0)