@@ -22,6 +22,7 @@ here applied to Richmond, Virginia.
2222``` {code-cell} python
2323:tags: [remove-cell]
2424import os
25+ import pandas as pd
2526from closecity import Client, close_map
2627import plotly.io as pio
2728
@@ -33,7 +34,7 @@ close = Client(os.environ.get("CLOSECITY_KEY"))
3334## Set up
3435
3536Read 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
3940from 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"]))
4748basket = {
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
6769blocks = 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 )
7274total_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
98103Count, 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
103108covered = blocks[blocks.travel_time <= 15]
104109score = covered.groupby("geoid")["dest_type_id"].nunique()
105110one_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),
122127print(f"All six amenities: {100 * basket_pop / total_pop:.0f}% of residents")
123128
124129one_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
145184The 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" `
148187isochrone 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
154194near_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
165205one_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