Skip to content

Commit 36a82ab

Browse files
author
Nathaniel Henry
committed
Use one multi-origin block_pois call for the walkshed in the competitor tutorial.
1 parent 5600835 commit 36a82ab

1 file changed

Lines changed: 59 additions & 66 deletions

File tree

docs/tutorials/competitor_walksheds.md

Lines changed: 59 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,18 @@ kernelspec:
1010

1111
# Competitor walksheds
1212

13-
A coffee shop wants to know which competitors draw from the same neighbourhood it does.
14-
Its **walkshed** is every residential block that can walk to it. This tutorial finds
15-
the competitors — the cafes those same blocks can also walk to — and maps them. The
16-
example city is Providence, Rhode Island.
13+
A coffee shop wants to understand the competition inside its own catchment. Its
14+
**walkshed** is every residential block that can walk to it in 10 minutes. This
15+
tutorial asks, block by block, how many other cafes those residents can also reach on
16+
foot, and which one is closest. The example city is Providence, Rhode Island.
1717

18-
*Running this tutorial uses about 2,000 tokens.*
18+
*Running this tutorial uses about 450 tokens.*
1919

2020
```{code-cell} python
2121
:tags: [remove-cell]
2222
import os
23+
import numpy as np
24+
import plotly.colors as pc
2325
from closecity import Client, close_map
2426
import plotly.io as pio
2527
@@ -49,91 +51,82 @@ city_boundary = close.place_boundary(geoid = city["geoid"])
4951

5052
## Find the shops and our walkshed
5153

52-
`place_pois` returns every cafe within the city's boundary — no radius to guess. Pick
53-
one as the subject, then pull its walkshed: every block that can walk to it in 10
54-
minutes.
54+
`place_pois` returns every cafe within the city's boundary. Pick one as the subject,
55+
then pull its walkshed: every block that can walk to it in 10 minutes. Draw the
56+
walkshed with the cafes on top, our shop in orange.
5557

5658
```{code-cell} python
5759
cafes = close.place_pois(geoid = city["geoid"], type = cafe)
5860
ours = cafes.iloc[0]
5961
print(ours["name"])
60-
cafes["is_ours"] = cafes["dest_id"] == ours["dest_id"]
6162
62-
our_shed = close.poi_catchment(
63-
dest_id = int(ours["dest_id"]),
64-
mode = "walk",
65-
max_minutes = 10
63+
our_shed = close.poi_catchment(dest_id = int(ours["dest_id"]), mode = "walk",
64+
max_minutes = 10)
65+
close_map(
66+
cafes,
67+
color = ["#f36e21" if d == ours["dest_id"] else "#202a5b" for d in cafes["dest_id"]],
68+
label = "name",
69+
background = our_shed.dissolve(),
70+
background_color = "#74b9ff",
71+
boundary = city_boundary,
6672
)
67-
walkshed = our_shed.dissolve()
6873
```
6974

70-
Draw the walkshed, with the cafes on top and our shop in orange.
75+
## What each block can reach
76+
77+
Now split the walkshed by block. A single `block_pois` call takes the whole list of
78+
walkshed blocks and returns, for every block, each cafe its residents can walk to
79+
within 10 minutes — the real routed answer, not a straight-line guess, and one
80+
request rather than one per block. Passing a list of GEOIDs tags every row with its
81+
origin `geoid`, so grouping by it reads two things per block: how many cafes are in
82+
reach, and which one is closest by walk time.
7183

7284
```{code-cell} python
73-
close_map(
74-
cafes,
75-
color = ["#f36e21" if o else "#202a5b" for o in cafes["is_ours"]],
76-
label = "name",
77-
background = walkshed,
78-
background_color = "#74b9ff",
79-
boundary = city_boundary
85+
reach = close.block_pois(
86+
list(our_shed["geoid"]),
87+
mode = "walk", type = cafe, max_minutes = 10, output = "tabular",
88+
)
89+
90+
per_block = reach.groupby("geoid")
91+
our_shed["n_cafes"] = (
92+
our_shed["geoid"].map(per_block.size()).fillna(0).astype(int)
8093
)
94+
winners = reach.loc[per_block["travel_time"].idxmin()].set_index("geoid")
95+
our_shed["closest_cafe"] = our_shed["geoid"].map(winners["dest_id"])
8196
```
8297

83-
## Who else those blocks can reach
98+
## How many cafes each block can reach
8499

85-
A competitor is a cafe that the residents of *our* walkshed can also walk to. So take
86-
the blocks in our walkshed, and for each of the nearest cafes, pull its walkshed: if
87-
the two share blocks, those residents can reach it too. `our_shed["geoid"]` is just a
88-
column of block ids, so the overlap is a plain set intersection. (We check the nearest
89-
cafes to keep the token cost down; the recipe scales to all of them.)
100+
Shade every block in the walkshed by the number of cafes within a 10-minute walk;
101+
blue marks the blocks with the most choice.
90102

91103
```{code-cell} python
92-
our_geoids = set(our_shed["geoid"])
93-
others = cafes[~cafes["is_ours"]].copy()
94-
others["dist"] = (others["lon"] - ours["lon"]) ** 2 + (others["lat"] - ours["lat"]) ** 2
95-
nearest = others.nsmallest(min(15, len(others)), "dist")
96-
97-
shared = {}
98-
for _, cafe_row in nearest.iterrows():
99-
their_shed = close.poi_catchment(
100-
dest_id = int(cafe_row["dest_id"]),
101-
mode = "walk",
102-
max_minutes = 10
103-
)
104-
shared[cafe_row["dest_id"]] = len(our_geoids & set(their_shed["geoid"]))
105-
106-
for dest_id, n in sorted(shared.items(), key = lambda kv: -kv[1]):
107-
if n == 0:
108-
continue
109-
name = nearest.loc[nearest["dest_id"] == dest_id, "name"].iloc[0]
110-
print(f"{name:28} {n:3} shared blocks ({100 * n / len(our_geoids):.0f}% of ours)")
111-
112-
competitors = {d for d, n in shared.items() if n > 0}
113-
cafes["is_competitor"] = cafes["dest_id"].isin(competitors)
104+
close_map(our_shed, fill = "n_cafes", reverse = True, boundary = city_boundary)
114105
```
115106

116-
## Map the contested ground
107+
## Which cafe is closest
117108

118-
Draw every cafe over our walkshed: our shop in orange, the competitors those blocks can
119-
also reach in red, and the rest in navy. The red cafes are the ones fighting for the
120-
same walk-in traffic.
109+
Give each cafe that wins at least one block a colour, then paint every block with the
110+
colour of its closest cafe. The cafe points share those colours. The result is the
111+
contested ground — where our shop's catchment gives way to a competitor's.
121112

122113
```{code-cell} python
123-
def cafe_color(row):
124-
if row["is_ours"]:
125-
return "#f36e21"
126-
return "#e03131" if row["is_competitor"] else "#202a5b"
114+
closest = [int(c) for c in our_shed["closest_cafe"].dropna().unique()]
115+
colors = pc.qualitative.Bold
116+
palette = {cid: colors[i % len(colors)] for i, cid in enumerate(closest)}
117+
118+
block_color = [palette[int(c)] if not np.isnan(c) else "#dddddd"
119+
for c in our_shed["closest_cafe"]]
120+
winning = cafes[cafes["dest_id"].isin(closest)]
127121
128122
close_map(
129-
cafes,
130-
color = [cafe_color(r) for _, r in cafes.iterrows()],
131-
label = "name",
132-
background = walkshed,
133-
background_color = "#74b9ff",
134-
boundary = city_boundary
123+
our_shed,
124+
color = block_color,
125+
points = winning,
126+
points_color = [palette[int(d)] for d in winning["dest_id"]],
127+
boundary = city_boundary,
135128
)
136129
```
137130

138-
The same recipe scales up: raise the cafe count you check, or compare whole cities by
139-
pulling each one's cafes with `place_pois`.
131+
The same recipe scales up: raise `max_minutes`, or compare whole cities by pulling
132+
each one's cafes with `place_pois`.

0 commit comments

Comments
 (0)