The amenity basket

A city planner wants every resident to be able to walk to a basket of six everyday amenities: a grocery store, a library, a park, a frequent-transit stop, a restaurant, and a cafe. This tutorial measures how many residents already have that, and shows where the gaps are. The idea follows this analysis, here applied to Richmond, Virginia.

Running this tutorial uses about 3,500 tokens.

Set up

Read the six category ids from the free catalog, and turn the city name into a centre point.

from closecity import Client

close = Client("ck_live_your_key")   # use your own key here
types = close.destination_types()
ids = dict(zip(types["label"], types["dest_type_id"]))
basket = {
    "grocery": ids["grocery_stores"],
    "library": ids["libraries"],
    "park": ids["parks"],
    "transit": ids["frequent_transit"],
    "restaurant": ids["restaurants"],
    "cafe": ids["cafes"],
}

city = close.places("Richmond").iloc[0]

Pull the blocks, with population

One call gets the walk time from every block near downtown to each of the six categories, along with each block’s population.

blocks = close.blocks_query(
    center = {"lon": city["lon"], "lat": city["lat"]}, radius_m = 2500,
    mode = "walk", type = list(basket.values()), include_population = True)

one_per_block = blocks.drop_duplicates("geoid")
total_pop = one_per_block["population"].sum()

Coverage, one amenity at a time

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

for name, type_id in basket.items():
    covered = set(blocks.loc[(blocks.dest_type_id == type_id) &
                             (blocks.travel_time <= 15), "geoid"])
    pop = one_per_block.loc[one_per_block.geoid.isin(covered), "population"].sum()
    print(f"{name:11} {100 * pop / total_pop:3.0f}%")
grocery      13%
library      38%
park         89%
transit       5%
restaurant   71%
cafe         64%

Map one amenity to see the pattern.

near_transit = set(blocks.loc[(blocks.dest_type_id == basket["transit"]) &
                              (blocks.travel_time <= 15), "geoid"])
one_per_block = one_per_block.assign(
    has_transit = one_per_block.geoid.isin(near_transit))
one_per_block.plot(column = "has_transit", cmap = "Greens")
<Axes: >
../_images/60639419844af48f1c78849e9cbb50c5498c75d3f595a9dcdcae7d0762789106.png

The 15-minute-city score

Count, for each block, how many of the six amenities are within a 15-minute walk. That score, from 0 to 6, is the map planners reach for. It reuses the data you already pulled, so it costs nothing more.

covered = blocks[blocks.travel_time <= 15]
score = covered.groupby("geoid")["dest_type_id"].nunique()
one_per_block = one_per_block.assign(
    score = one_per_block.geoid.map(score).fillna(0).astype(int))
one_per_block.plot(column = "score", cmap = "viridis", legend = True)
<Axes: >
../_images/05762e71611d18a93c5cae7955ff79a7c6e34f4641d4abe508c6644f727ab728.png

Who can reach all six

A block is fully covered only if all six amenities are within 15 minutes.

covered_all = set(one_per_block.geoid)
for type_id in basket.values():
    covered = set(blocks.loc[(blocks.dest_type_id == type_id) &
                             (blocks.travel_time <= 15), "geoid"])
    covered_all &= covered

basket_pop = one_per_block.loc[one_per_block.geoid.isin(covered_all),
                               "population"].sum()
print(f"All six amenities: {100 * basket_pop / total_pop:.0f}% of residents")

one_per_block = one_per_block.assign(
    full_basket = one_per_block.geoid.isin(covered_all))
one_per_block.plot(column = "full_basket", cmap = "Oranges")
All six amenities: 4% of residents
<Axes: >
../_images/b66445dfa88a4d1a0588501eff0434de85bfe044f093c642a8f909e1a00d2f94.png

Which amenity to add first

Count how many not-yet-covered residents are missing each amenity. The amenity the most people lack is the one to add first.

uncovered = set(one_per_block.geoid) - covered_all
for name, type_id in basket.items():
    covered = set(blocks.loc[(blocks.dest_type_id == type_id) &
                             (blocks.travel_time <= 15), "geoid"])
    lacking = uncovered - covered
    pop = one_per_block.loc[one_per_block.geoid.isin(lacking), "population"].sum()
    print(f"{name:11} {pop:6.0f} residents would gain access")
grocery      26219 residents would gain access
library      18720 residents would gain access
park          3373 residents would gain access
transit      28424 residents would gain access
restaurant    8732 residents would gain access
cafe         10798 residents would gain access

Map the uncovered blocks to see where new amenities would do the most good.

one_per_block = one_per_block.assign(
    uncovered = one_per_block.geoid.isin(uncovered))
one_per_block.plot(column = "uncovered", cmap = "Blues")
<Axes: >
../_images/a5c2be5d84db81d59b6a0dd66f50c99ac554b1c8ed8c88dddc2a81084c95e7a4.png