The amenity basket

A city planner wants every resident to be able to walk to a basket of six everyday amenities: a supermarket, 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,600 tokens.

Set up

Turn the city name into a centre point and a boundary, and build the basket as a small table: each amenity paired with its destination-type id.

from closecity import Client, close_map

close = Client("ck_live_your_key")   # use your own key here
amenity_types = close.destination_types()
ids = dict(zip(amenity_types["label"], amenity_types["dest_type_id"]))
basket = pd.DataFrame({
    "amenity": ["supermarket", "library", "park", "transit", "restaurant", "cafe"],
    "dest_type_id": [ids[k] for k in ["grocery_stores", "libraries", "parks",
                                      "frequent_transit", "restaurants", "cafes"]],
})

city = close.places(q = "Richmond").iloc[0]
city_boundary = close.place_boundary(geoid = city["geoid"])

Pull the blocks, with population

One call gets the walk time from every block to each of the six categories, plus each block’s population. To keep this tutorial cheap we take the central blocks within a radius; place_blocks(geoid = city["geoid"]) pulls every block in the city the same way (at a higher token cost).

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

Reshape to one row per block: keep just the GEOID and geometry, add the population, and add a walk-time column for each amenity (NaN when it is more than 30 minutes away). One clean table, no leftover census-block metadata.

one_per_block = blocks.drop_duplicates("geoid")[["geoid", "geometry"]].reset_index(drop = True)
population = blocks.drop_duplicates("geoid").set_index("geoid")["population"]
one_per_block["population"] = one_per_block["geoid"].map(population)
for _, row in basket.iterrows():
    times = blocks[blocks["dest_type_id"] == row["dest_type_id"]].set_index("geoid")["travel_time"]
    one_per_block[f"{row['amenity']}_min"] = one_per_block["geoid"].map(times)
total_pop = one_per_block["population"].sum()

# The six walk-time columns as a boolean "within 15 minutes" table, for the tallies.
min_cols = [f"{a}_min" for a in basket["amenity"]]
within_15 = (one_per_block[min_cols] <= 15).fillna(False)

Coverage, one amenity at a time

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

for a in basket["amenity"]:
    covered = within_15[f"{a}_min"]
    pop = one_per_block.loc[covered, "population"].sum()
    print(f"{a:11} {100 * pop / total_pop:3.0f}%")
supermarket  13%
library      38%
park         89%
transit       5%
restaurant   71%
cafe         64%

Parks and restaurants tend to be everywhere; supermarkets and libraries are usually the hardest to reach. Map the library coverage: every block shown, the covered ones highlighted, the library locations as points, the city boundary behind.

one_per_block["has_library"] = within_15["library_min"]
library_type = int(basket.loc[basket["amenity"] == "library", "dest_type_id"].iloc[0])
libraries = close.pois_search(lat = city["lat"], lon = city["lon"], radius_m = 2500,
                              type = library_type)
close_map(one_per_block, highlight = "has_library", color = "#058040",
          points = libraries, boundary = city_boundary)

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; blue marks the best-served blocks. It reuses the data you already pulled, so it costs nothing more.

one_per_block["score"] = within_15.sum(axis = 1)
close_map(one_per_block, fill = "score", reverse = True, boundary = city_boundary)

Who can reach all six

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

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

close_map(one_per_block, highlight = "full_basket", color = "#f36e21",
          boundary = city_boundary)
All six amenities: 4% of residents

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.

for a in basket["amenity"]:
    lacking = ~one_per_block["full_basket"] & ~within_15[f"{a}_min"]
    print(f"{a:11} {one_per_block.loc[lacking, 'population'].sum():6.0f} residents would gain access")
supermarket  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

Who is one or two amenities away

The residents worth targeting first are the ones almost there: a block with five of the six is a much easier win than one with none. Count how many amenities each block is missing, and for the blocks short by just one or two, break down which amenities are the gap.

have = within_15.to_numpy()
amenities = list(basket["amenity"])
n_missing = 6 - one_per_block["score"].to_numpy()
gap = np.array([" + ".join(a for a, h in zip(amenities, row) if not h) for row in have])
pops = one_per_block["population"].to_numpy()

almost = np.isin(n_missing, [1, 2])
almost_pop = pops[almost].sum()
print(f"{100 * almost_pop / total_pop:.0f}% of residents are one or two amenities "
      f"short of the full basket.\n")

by_gap = (pd.Series(pops[almost], index = gap[almost])
          .groupby(level = 0).sum().sort_values(ascending = False))
for g, p in by_gap.items():
    print(f"  missing {g:22} {100 * p / almost_pop:3.0f}%")
31% of residents are one or two amenities short of the full basket.

  missing supermarket + transit   78%
  missing transit                 10%
  missing library + transit        8%
  missing supermarket              3%
  missing library                  1%

Site a new supermarket

The counts above say which amenity to add; the next question is where. Pick the candidate site automatically: a populated block with no supermarket within a 15-minute walk, nearest the middle of the study area. A direction="to" isochrone then gives the blocks that could reach the site on foot in 15 minutes, and the ones not already served are the population this site would newly reach.

uncovered = one_per_block[~within_15["supermarket_min"] & (one_per_block["population"] > 0)]
points = uncovered.geometry.representative_point()
middle = one_per_block.geometry.union_all().centroid
site = points.iloc[(((points.x - middle.x) ** 2 + (points.y - middle.y) ** 2)).to_numpy().argmin()]

reachable = close.isochrone(lon = site.x, lat = site.y, mode = "walk",
                            direction = "to", minutes = 15, format = "blocks")
near_supermarket = set(one_per_block.loc[within_15["supermarket_min"], "geoid"])
newly_served = (set(reachable["geoid"]) & set(one_per_block["geoid"])) - near_supermarket
gain_pop = one_per_block.loc[one_per_block["geoid"].isin(newly_served), "population"].sum()
print(f"A supermarket here would newly serve {gain_pop:.0f} residents")
A supermarket here would newly serve 643 residents

Map the whole city and highlight the blocks that would newly gain access, with the candidate site marked by an X.

one_per_block["newly_served"] = one_per_block["geoid"].isin(newly_served)
close_map(one_per_block, highlight = "newly_served", color = "#e8590c",
          mark = (site.x, site.y), boundary = city_boundary)