@@ -17,12 +17,13 @@ where the gaps are. The idea follows
1717[ this analysis] ( https://nathenry.com/writing/2023-02-07-seattle-walkability.html ) ,
1818here 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]
2424import os
2525import pandas as pd
26+ import numpy as np
2627from closecity import Client, close_map
2728import 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
4041from closecity import Client, close_map
@@ -45,14 +46,11 @@ close = Client("ck_live_your_key") # use your own key here
4546``` {code-cell} python
4647amenity_types = close.destination_types()
4748ids = 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
5755city = close.places(q = "Richmond").iloc[0]
5856city_boundary = close.place_boundary(geoid = city["geoid"])
@@ -67,43 +65,50 @@ same way (at a higher token cost).
6765
6866``` {code-cell} python
6967blocks = 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)
7883total_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
8392For 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
93101Parks 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
113118blocks. 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)
119122close_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()
135132print(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
149141most 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])
176161pops = 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-
183163almost = np.isin(n_missing, [1, 2])
184164almost_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
190168by_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()
219193print(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