Mapping shelter access zones before the emergency happens

Geocode shelter sites, generate walk and drive isolines, run an N×M matrix, and find coverage gaps before an event — REST patterns for emergency planners.

| July 23, 2026
Mapping shelter access zones before the emergency happens

The worst time to discover that a neighbourhood has no shelter within walking distance is during the evacuation order. That discovery should happen on a Tuesday afternoon, three months before storm season, when there is still time to pre-position a temporary shelter or negotiate access to a school gymnasium. It should happen on a map produced by your own planning team, from your own shelter list, with your own community-resource data overlaid — not as a surprise when the county emergency coordinator calls at two in the morning asking why the Riverside district has nowhere to go.

This post shows how to build that pre-event coverage map using four API surfaces: batch geocoding for the shelter list, routing isolines for walk-time and drive-time coverage rings, an N×M matrix for zone-to-shelter assignment, and static map rendering for the briefing documents that go to decision-makers. Every example is plain REST — curl, Python requests, and Node fetch. No SDK dependency, no GIS team required, no proprietary data about which neighbourhood has a car and which does not (you bring that from your own census or community surveys).

One honest scope statement before the code: CSV2GEO provides geocoding, routing geometry, and map rendering. It does not provide population density, hazard zone boundaries, or evacuation-route data. You overlay those from your own sources. This post shows the spatial infrastructure layer; the life-safety decisions on top of it are yours.

Why walk-time isolines matter as much as drive-time ones

Emergency planners who think only in drive times miss a structural problem: a significant fraction of any community cannot drive. No licence, no vehicle, disability, age, or a car that went to work with another family member. In a no-notice event — a flash flood, a hazmat spill, a wildfire with 30-minute lead time — even car-owning households may not be able to reach their vehicle in time.

Walk-time isolines are not a nice-to-have. They are the honest picture of who can reach a shelter under the worst-case transport assumptions. A shelter that sits within a 20-minute drive of 95% of a zone's population but within a 30-minute walk of only 40% of that zone is not adequately covering the zone for a no-notice event where roads are blocked or vehicle use is impractical.

The practical split for pre-event planning:

  • 30-minute walk isoline — the honest "can get there on foot" boundary, roughly 2.0–2.5 km on flat terrain, less on hills.
  • 15-minute drive isoline — the reasonable "normal evacuation" boundary assuming roads are open.
  • 30-minute drive isoline — the outer bound, useful for identifying which shelters serve as overflow capacity and which are primary.

Overlaying all three per shelter, and then computing the union across all shelters, shows you both the covered population and the gap. The gap is where you act before the event.

Step 1 — Geocode the shelter list

Your starting point is typically a spreadsheet: shelter name, street address, city, state, capacity. Before you can do any spatial work, each shelter needs a verified coordinate pair.

curl -G "https://csv2geo.com/api/v1/geocode" \
  --data-urlencode "q=1250 W. Columbus Ave, Springfield, IL 62704" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

A representative response:

{
  "results": [
    {
      "lat": 39.7823,
      "lng": -89.6502,
      "confidence": 0.97,
      "formatted": "1250 W Columbus Ave, Springfield, IL 62704, USA"
    }
  ]
}

The confidence field matters here. For a shelter list you are using to drive life-safety planning, you want every record at 0.85 or above. Any geocode that comes back below that threshold is worth a manual address verification before you proceed — a misplaced coordinate puts a shelter's coverage ring in the wrong neighbourhood.

For a list of 200 shelters the same call works in batch. Python, grouping by batches of 50 to stay comfortably within the rate limit:

import csv
import os
import time
import requests

API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_API_KEY"]

def geocode_batch(addresses):
    """Geocode a list of address strings; returns list of result dicts."""
    results = []
    for address in addresses:
        r = requests.get(
            f"{API}/geocode",
            params={"q": address, "api_key": KEY},
            timeout=30,
        )
        r.raise_for_status()
        hits = r.json().get("results", [])
        results.append(hits[0] if hits else None)
        time.sleep(0.05)  # stay well below rate limits
    return results

shelters = []
with open("shelters.csv") as f:
    reader = csv.DictReader(f)
    for row in reader:
        shelters.append(row)

addresses = [f"{s['address']}, {s['city']}, {s['state']}" for s in shelters]
geocoded = geocode_batch(addresses)

for shelter, geo in zip(shelters, geocoded):
    if geo and geo["confidence"] >= 0.85:
        shelter["lat"] = geo["lat"]
        shelter["lng"] = geo["lng"]
        shelter["geocode_confidence"] = geo["confidence"]
    else:
        shelter["lat"] = None
        shelter["lng"] = None
        shelter["geocode_confidence"] = 0
        print(f"REVIEW: {shelter['name']} — low confidence or no result")

Any shelter with lat=None after this loop goes into a manual review queue before you do any isoline work. Do not let a bad coordinate silently produce a coverage ring in the wrong location.

Step 2 — Generate isolines for each shelter

With verified coordinates in hand, call the isoline endpoint for each shelter. You want three rings per shelter: 30-minute walk, 15-minute drive, 30-minute drive.

# 30-minute walk isoline around a shelter
curl -G "https://csv2geo.com/api/v1/isoline" \
  --data-urlencode "lat=39.7823" \
  --data-urlencode "lng=-89.6502" \
  --data-urlencode "mode=walk" \
  --data-urlencode "minutes=30" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

The response is a GeoJSON polygon. Store it — you will render it and compute spatial overlaps against community boundary polygons in later steps.

Three calls per shelter, Python:

def fetch_isoline(lat, lng, mode, minutes):
    """mode: 'walk' or 'drive'. Returns GeoJSON polygon or None."""
    r = requests.get(
        f"{API}/isoline",
        params={
            "lat": lat,
            "lng": lng,
            "mode": mode,
            "minutes": minutes,
            "api_key": KEY,
        },
        timeout=60,
    )
    if r.status_code == 429:
        raise RuntimeError("rate_limited")
    r.raise_for_status()
    return r.json()

def fetch_all_isolines(shelter):
    lat, lng = shelter["lat"], shelter["lng"]
    return {
        "walk_30":  fetch_isoline(lat, lng, "walk",  30),
        "drive_15": fetch_isoline(lat, lng, "drive", 15),
        "drive_30": fetch_isoline(lat, lng, "drive", 30),
    }

Walk routes on real street networks are asymmetric in ways that straight-line buffers miss entirely. A shelter that looks within 2 km of a residential cluster on a Euclidean measurement may be separated from it by a river with a single bridge — the 30-minute walk isoline will show that gap because it routes on the actual pedestrian network. This is the substantive reason to compute isolines rather than radial buffers.

For a list of 200 shelters with 3 calls each, that is 600 isoline requests. If the free tier (3,000 calls/day) covers your pilot, run it without a card. For a larger pre-event planning sprint, paid tiers start at $54/month for 100,000 calls. See csv2geo.com/pricing/api.

Step 3 — Run the N×M matrix to assign zones to nearest shelters

Isolines tell you coverage geometry. The N×M routing matrix tells you, for each community zone centroid, which shelter is closest by actual travel time — which is the assignment problem at the heart of shelter planning.

If your county has 80 planning zones and 12 shelters, the matrix is 80×12 = 960 travel-time queries. The matrix endpoint runs them in one call server-side, returning a grid of durations in seconds.

curl -G "https://csv2geo.com/api/v1/matrix" \
  --data-urlencode "origins=39.7823,-89.6502|39.8012,-89.6234|39.7654,-89.5901" \
  --data-urlencode "destinations=39.7100,-89.7200|39.8300,-89.6000|39.7500,-89.5800" \
  --data-urlencode "mode=drive" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

Python, building the full N×M for zone centroids against shelter coordinates:

def fetch_matrix(origins, destinations, mode="drive"):
    """
    origins, destinations: lists of (lat, lng) tuples.
    Returns list of lists: durations[origin_idx][dest_idx] in seconds.
    """
    orig_str = "|".join(f"{lat},{lng}" for lat, lng in origins)
    dest_str = "|".join(f"{lat},{lng}" for lat, lng in destinations)
    r = requests.get(
        f"{API}/matrix",
        params={
            "origins": orig_str,
            "destinations": dest_str,
            "mode": mode,
            "api_key": KEY,
        },
        timeout=120,
    )
    r.raise_for_status()
    return r.json()["durations"]

zone_coords   = [(z["lat"], z["lng"]) for z in zones]
shelter_coords = [(s["lat"], s["lng"]) for s in shelters if s["lat"]]

drive_matrix = fetch_matrix(zone_coords, shelter_coords, mode="drive")
walk_matrix  = fetch_matrix(zone_coords, shelter_coords, mode="walk")

# For each zone, find the nearest shelter and its travel time.
for i, zone in enumerate(zones):
    drive_times = drive_matrix[i]
    walk_times  = walk_matrix[i]
    zone["nearest_shelter_drive_s"] = min(drive_times)
    zone["nearest_shelter_walk_s"]  = min(walk_times)
    zone["nearest_shelter_idx"]     = drive_times.index(min(drive_times))
    zone["nearest_shelter_name"]    = shelters[zone["nearest_shelter_idx"]]["name"]

The matrix result is a plain 2-D list. durations[i][j] is the travel time in seconds from zone centroid i to shelter j. Finding the nearest shelter per zone is min() on that row. Flagging zones with no shelter within 30 minutes by drive is min(row) > 1800. These are the gap zones — the ones that need a pre-event response.

Node equivalent for the matrix fetch:

const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_API_KEY;

async function fetchMatrix(origins, destinations, mode = 'drive') {
  const origStr = origins.map(([lat, lng]) => `${lat},${lng}`).join('|');
  const destStr = destinations.map(([lat, lng]) => `${lat},${lng}`).join('|');
  const url = `${API}/matrix?origins=${encodeURIComponent(origStr)}` +
              `&destinations=${encodeURIComponent(destStr)}` +
              `&mode=${mode}&api_key=${KEY}`;
  const r = await fetch(url);
  if (!r.ok) throw new Error(`matrix ${r.status}`);
  const body = await r.json();
  return body.durations; // number[][]
}

Step 4 — Identify gap zones and quantify the exposure

The matrix gives you a drive-time and walk-time score for every zone. Gap zones are those where either:

  • No shelter is within 30 minutes by drive (road-accessible population, routine evacuation scenario), or
  • No shelter is within 30 minutes by walk (car-free or roads-blocked scenario).

The second criterion is the harder one to meet. In a dense urban environment with many shelters, the walk gap may be small. In a rural or suburban county with shelters sited at large central schools, the walk gap can be very large — and that is often where the most vulnerable population lives.

DRIVE_THRESHOLD_S = 30 * 60  # 30 minutes
WALK_THRESHOLD_S  = 30 * 60  # 30 minutes

gap_zones = []
for i, zone in enumerate(zones):
    no_drive_coverage = min(drive_matrix[i]) > DRIVE_THRESHOLD_S
    no_walk_coverage  = min(walk_matrix[i])  > WALK_THRESHOLD_S
    if no_drive_coverage or no_walk_coverage:
        gap_zones.append({
            "zone_id":           zone["id"],
            "zone_name":         zone["name"],
            "nearest_drive_min": round(min(drive_matrix[i]) / 60, 1),
            "nearest_walk_min":  round(min(walk_matrix[i])  / 60, 1),
            "no_drive_coverage": no_drive_coverage,
            "no_walk_coverage":  no_walk_coverage,
        })

print(f"{len(gap_zones)} zones lack adequate shelter coverage.")
for z in gap_zones:
    flags = []
    if z["no_drive_coverage"]: flags.append(f"drive {z['nearest_drive_min']} min")
    if z["no_walk_coverage"]:  flags.append(f"walk {z['nearest_walk_min']} min")
    print(f"  {z['zone_name']}: {', '.join(flags)}")

The output of this step — a CSV of gap zones with drive and walk times — is the document you bring to the planning session. It is not a life-safety guarantee; it is a spatial analysis that tells decision-makers where to focus the pre-event work: negotiate temporary shelter agreements, pre-position transportation assets, or revise the community communication plan to direct car-free residents to a different site.

Step 5 — Render static briefing maps

Emergency management decisions happen in briefing rooms, not in GIS software. Decision-makers need a printable map that shows coverage rings, gap zones, and shelter locations in a single glance. The Static Maps endpoint generates that image from the isoline polygons and point markers you already have.

# A static map centred on the county, with shelter markers
curl -G "https://csv2geo.com/api/v1/staticmap" \
  --data-urlencode "center=39.7823,-89.6502" \
  --data-urlencode "zoom=11" \
  --data-urlencode "size=1200x900" \
  --data-urlencode "markers=39.7823,-89.6502,red|39.8012,-89.6234,red|39.7654,-89.5901,red" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY" \
  -o county_shelter_map.png

For the briefing document you typically want three separate map images: one showing walk-time coverage (30-minute walk isolines unioned across all shelters, with gap zones highlighted), one showing drive-time coverage, and one overview that layers both. Produce these programmatically so they regenerate automatically when the shelter list changes:

def render_static_map(center_lat, center_lng, markers, zoom=11,
                      width=1200, height=900, output_path="map.png"):
    marker_str = "|".join(
        f"{lat},{lng},{colour}" for lat, lng, colour in markers
    )
    r = requests.get(
        f"{API}/staticmap",
        params={
            "center":  f"{center_lat},{center_lng}",
            "zoom":    zoom,
            "size":    f"{width}x{height}",
            "markers": marker_str,
            "api_key": KEY,
        },
        timeout=30,
        stream=True,
    )
    r.raise_for_status()
    with open(output_path, "wb") as f:
        for chunk in r.iter_content(chunk_size=8192):
            f.write(chunk)

shelter_markers = [
    (s["lat"], s["lng"], "blue")
    for s in shelters if s["lat"]
]
gap_markers = [
    (z["lat"], z["lng"], "red")
    for z in gap_zones
]

render_static_map(
    center_lat=39.78,
    center_lng=-89.65,
    markers=shelter_markers + gap_markers,
    output_path="briefing_shelter_coverage.png",
)

Blue pins are operational shelters. Red pins are gap zone centroids. A county emergency coordinator can hand this image to a commissioner and explain the coverage picture in two sentences without opening a laptop. That is the practical value of a generated briefing image over a live GIS layer — it is a document, not a session.

Caching and pipeline structure for a planning cycle

Shelter geocodes and isoline polygons do not change between planning cycles unless the shelter list changes. Cache aggressively.

What to cache and for how long:

| Data type | Cache duration | Reasoning | |---|---|---| | Geocoded shelter coordinates | Indefinite until address changes | Addresses do not move | | Isoline polygons | Until shelter list changes | Regenerate on any shelter add/remove | | N×M matrix results | Until shelter or zone list changes | Regenerate on any change to either list | | Static briefing maps | Until any input changes | Regenerate before each briefing cycle |

Store isoline GeoJSON in a simple key-value store keyed by (shelter_id, mode, minutes). On a planning-cycle run, check the cache before calling the API — for a list that has not changed since last quarter, you will pay for zero isoline calls. The caching geocoding results post covers the Redis and filesystem caching patterns that apply here directly.

For the matrix, the cache key is the sorted tuple of (zone_centroid_ids, shelter_ids, mode). If any shelter is added or removed, invalidate and recompute. If only a zone boundary changes without moving its centroid, the matrix is still valid.

What to watch in production

If you are running this pipeline on a scheduled basis — say, a quarterly refresh before each storm season — a few observability points are worth instrumenting.

Geocode confidence distribution. Track the distribution of confidence scores for your shelter list over time. If confidence drops on addresses you have previously geocoded successfully, that is often a sign that the address has changed (shelter relocated, street renaming, new postal code) and needs a manual review. A histogram that looked healthy last quarter and now has a tail below 0.85 is a signal before the bad data causes a problem downstream.

Isoline shape change. When you regenerate isolines for an unchanged shelter location, the polygon should be essentially identical. If a shelter's isoline shrinks or expands by more than a few percent between quarterly runs, the underlying road network data has changed — which is usually fine, but worth logging so you can explain it to a planner who notices the map has shifted.

Matrix duration drift. Travel times in the matrix can drift as road network data is updated. Log the mean drive time from each zone to its nearest shelter quarter over quarter. A sudden increase is worth flagging — it might mean a road closure has been incorporated into the routing graph, which could be a real planning concern.

The observability post for geocoding pipelines covers the instrumentation patterns in detail — the same Prometheus counters and p99-latency alerts apply equally to a routing-heavy pipeline.

The limits of this analysis

Honest accounting of what the spatial coverage picture above does and does not tell you.

Isolines are not population density. A gap zone that contains 200 residents is a different problem from one that contains 20,000. You need to overlay your own census or community survey data to prioritise. The API provides the spatial geometry; the demographic weight is your data.

Routing assumes the network is intact. Drive-time isolines route on the current road network. In an actual event, bridges may be closed, roads may be flooded, and the effective drive time to some shelters will be longer — possibly infinite. Walk-time isolines are more robust because they do not depend on bridges being open, but a pedestrian crossing a flooded road is not a safe routing assumption either. The pre-event analysis is a baseline; your operations team needs separate flood-closure scenarios.

Shelter capacity is not in this API. A shelter that is within 15 minutes of 50,000 residents but has a capacity of 300 is not actually serving those 50,000 residents. You bring the capacity column from your own data and layer it on top of the coverage analysis. The spatial reach tells you who can get there; your capacity data tells you how many of them can actually be accommodated.

CSV2GEO makes no life-safety guarantees. Isoline geometry is produced from routing graph data and represents a modelled travel time, not a guarantee of safe passage in an emergency. Decisions about shelter adequacy, evacuation planning, and public communications remain entirely with the planning team.

Frequently Asked Questions

What is the difference between walk mode and drive mode in the isoline endpoint? Walk mode routes on the pedestrian network — pavements, footpaths, pedestrian crossings — at a typical walking speed. Drive mode routes on the vehicle network at posted or modelled speeds. Walk mode returns a significantly smaller polygon for the same time budget, and the shape is more sensitive to barriers like rivers and motorways with no pedestrian crossing. Both are necessary for shelter planning because they answer different questions: drive mode tells you the routine evacuation reach; walk mode tells you the car-free or roads-blocked reach.

How many isoline calls does a county-scale shelter analysis consume? Three isolines per shelter (walk-30, drive-15, drive-30). A county with 40 shelters needs 120 isoline calls per planning cycle. With caching on the shelter list, subsequent quarterly runs are free unless the list changes. 120 calls sits well within the free tier (3,000 calls/day) for a first pilot.

Can I pass GeoJSON polygon boundaries as input to the matrix — for example, census tract shapes — rather than single centroids? The matrix endpoint takes point coordinates — one lat/lng per origin and one per destination. Use the centroid of each census tract or planning zone as the representative origin point. If your zones are small relative to the shelter spacing, centroid approximation is fine. For large zones with significant internal variation, split each zone into sub-centroids and run the matrix on the sub-points.

What happens if a shelter address geocodes to the wrong location? Every geocode below confidence 0.85 should trigger a manual address review before you use the coordinate. A misplaced shelter coordinate produces an isoline in the wrong part of the city, which is worse than having no isoline at all because it creates false confidence in coverage. Confidence score filtering is the first defence; the second is a human eyeball on a spot-check map of the geocoded shelter points before any downstream analysis.

Can I generate isolines for walking speeds slower than average — for elderly or mobility-impaired residents? The isoline endpoint uses a standard pedestrian network speed. It does not currently accept a custom speed parameter. To approximate slower movement, reduce the time budget: a 20-minute isoline at standard speed approximates a 30-minute isoline for someone moving at two-thirds walking pace. This is an approximation; for a rigorous accessibility analysis you would need to pair the isoline geometry with building-level accessibility data from your own sources.

How should I handle the update cycle when shelters are added or removed from the list? Invalidate and recompute isolines and the matrix for any change to the shelter list. In practice, emergency management shelter lists are relatively stable — they tend to change before season, not continuously. A quarterly recompute timed to the planning cycle is the right cadence for most programmes. A webhook or a file-hash check on the shelter CSV is a clean way to trigger the recompute automatically rather than running it on a fixed schedule regardless of whether anything changed.

Does CSV2GEO provide the community boundary polygons I need to determine which zones are in a gap? No. You supply your own zone boundaries — census tracts, planning districts, neighbourhood polygons, or whatever spatial unit your emergency management programme uses. The API provides geocoding, isoline geometry, and routing. The overlay between isoline polygons and your zone boundaries is done in your analysis layer (Python with shapely, PostGIS, QGIS, or similar). CSV2GEO does not provide population, hazard, or administrative boundary data.

Related Articles

---

*I.A. / CSV2GEO Creator*

Ready to geocode your addresses?

Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.

Try Batch Geocoding Free →