Drive-time isochrones for ambulance coverage gap analysis

Use drive-time isochrones and reverse geocoding to find ambulance coverage gaps. REST patterns, Python + Node code, production failure modes.

| May 31, 2026
Drive-time isochrones for ambulance coverage gap analysis

Every EMS director knows the eight-minute benchmark. It is not a law in most jurisdictions — it is a clinical observation baked into decades of cardiac-arrest outcome data, and it is the number that appears in every city council budget presentation about why a new station needs funding. What most EMS directors do not have is a repeatable, programmatic way to answer the follow-up question: how many addresses in our service area actually fall outside it, and exactly where are they?

The traditional answer involves a GIS analyst, a network routing dataset, a week of processing, and a static PDF map that is obsolete the moment a road closes or a new housing estate opens. The answer this post describes involves two REST endpoints, a Python script, and a repeatable pipeline you can run on demand.

The two endpoints are Isochrones — which returns a polygon representing all points reachable from a given origin within a given drive time — and Reverse Geocoding — which takes a coordinate and tells you the address, jurisdiction, and human-readable context for any point. Combine them and you have everything you need to audit ambulance coverage programmatically.

The coverage gap problem in plain terms

An ambulance station has a physical location. From that location, a unit can reach some set of addresses within eight minutes under normal road conditions. The isochrone for that station is the polygon that encloses all of those addresses. Everything outside the polygon is a coverage gap — an address from which a 999 or 911 call dispatched right now has less than an eight-minute response window.

The complexity comes from three directions.

Road network, not straight-line distance. An address 3 km from a station might be a 12-minute drive because of a river crossing, a motorway interchange, or a dirt road. Straight-line buffers (the "as the crow flies" circles you can draw in any mapping tool) overstate coverage dramatically in rural areas and understate the cost of urban congestion. Drive-time isochrones model the road network; straight-line buffers do not.

Multiple stations, overlapping polygons. A service area with eight stations has eight isochrones. The union of those polygons defines the covered area. Addresses in the covered area are served. Addresses outside all eight polygons are the gap. That set operation is easy to compute programmatically and cumbersome to do reliably by hand.

Dynamic inputs. Stations open, close, and relocate. Road networks change. New residential developments add addresses that were not on any map when the coverage analysis was last run. A pipeline that re-runs the analysis on demand — triggered by any change to station locations or the address inventory — is worth far more than a static annual report.

What the Isochrones endpoint gives you

GET /api/v1/isochrone accepts a starting point (lat/lng or free-text address), a travel mode (driving, walking, cycling), and one or more time limits in minutes. It returns a GeoJSON FeatureCollection where each feature is a polygon representing the catchment area for that time threshold.

A minimal request:

curl -G "https://csv2geo.com/api/v1/isochrone" \
  --data-urlencode "lat=51.5007" \
  --data-urlencode "lng=-0.1246" \
  --data-urlencode "mode=driving" \
  --data-urlencode "times=5,8,12" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

The response is standard GeoJSON:

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "properties": { "time_minutes": 5 },
      "geometry": { "type": "Polygon", "coordinates": [[...]] }
    },
    {
      "type": "Feature",
      "properties": { "time_minutes": 8 },
      "geometry": { "type": "Polygon", "coordinates": [[...]] }
    },
    {
      "type": "Feature",
      "properties": { "time_minutes": 12 },
      "geometry": { "type": "Polygon", "coordinates": [[...]] }
    }
  ]
}

Three time thresholds, three polygons. For an eight-minute coverage audit you only need the eight-minute polygon, but requesting the five-minute ring at the same time costs nothing extra and gives you the "immediate response" zone for cardiac-arrest triage — a useful second signal for station placement decisions.

The polygons are road-network-derived. A station sitting next to a motorway junction will have a long, narrow isochrone that follows the motorway corridors; a station in a dense urban grid will have a roughly circular isochrone that shrinks along diagonal streets. That asymmetry is the whole point — it reflects the actual road geometry that your units have to navigate.

What the Reverse Geocoding endpoint gives you

GET /api/v1/reverse accepts a lat/lng pair and returns a structured address: house number, street, city, county, postcode, country code, and a confidence score. For the coverage gap analysis, you use this in two ways.

First, to label gap coordinates for a human reader. A set of latitude/longitude pairs that fall outside all isochrone polygons is technically correct but operationally useless to an EMS planner. "There are 847 coordinates outside coverage" is not a briefing slide. "There are 847 addresses outside coverage, concentrated in the Millbrook Farm Road corridor in the eastern sector and the Ridgeline Drive estate in the north" is a briefing slide. Reverse geocoding turns the coordinate set into the second format.

Second, to verify that the coordinate you are testing is actually a residential or commercial address and not a motorway median or a reservoir. A point-in-polygon test against the isochrone union will flag anything outside the polygon, including road junctions, parks, and bodies of water. Reverse geocoding with a type filter lets you restrict the gap analysis to address-class results and discard the noise.

curl -G "https://csv2geo.com/api/v1/reverse" \
  --data-urlencode "lat=51.6023" \
  --data-urlencode "lng=-0.0981" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

Response:

{
  "results": [
    {
      "formatted": "14 Ridgeline Drive, Waltham Cross, EN8 7PQ",
      "confidence": 0.93,
      "components": {
        "house_number": "14",
        "road": "Ridgeline Drive",
        "city": "Waltham Cross",
        "postcode": "EN8 7PQ",
        "country_code": "gb"
      }
    }
  ]
}

Building the coverage gap pipeline

The following five steps walk through a complete pipeline from station locations to a labelled gap report. The code is stripped to the essential logic — no third-party spatial libraries are required for the core workflow; the GeoJSON point-in-polygon test is a few lines of Ray Casting that you can copy from any geometry reference.

Step 1: Load station locations and generate isochrones

Start with a CSV of station locations: station_id,name,lat,lng. For each station, call the Isochrones endpoint and save the eight-minute polygon.

import csv, os, json, time
import requests

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

def fetch_isochrone(lat, lng, minutes=8, mode="driving"):
    r = requests.get(
        f"{API}/isochrone",
        params={
            "lat": lat,
            "lng": lng,
            "mode": mode,
            "times": str(minutes),
            "api_key": KEY,
        },
        timeout=30,
    )
    r.raise_for_status()
    features = r.json()["features"]
    # Return the polygon for the requested time threshold.
    return next(
        (f["geometry"] for f in features
         if f["properties"]["time_minutes"] == minutes),
        None,
    )

stations = []
with open("stations.csv") as f:
    for row in csv.DictReader(f):
        polygon = fetch_isochrone(row["lat"], row["lng"])
        if polygon:
            stations.append({
                "id": row["station_id"],
                "name": row["name"],
                "polygon": polygon,
            })
        time.sleep(0.1)  # polite pacing; adjust to your rate limit tier

with open("isochrones.json", "w") as f:
    json.dump(stations, f)

The same pattern in Node:

import { writeFile, readFile } from 'node:fs/promises';

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

async function fetchIsochrone(lat, lng, minutes = 8, mode = 'driving') {
  const url = `${API}/isochrone?lat=${lat}&lng=${lng}` +
              `&mode=${mode}&times=${minutes}&api_key=${KEY}`;
  const r = await fetch(url);
  if (!r.ok) throw new Error(`HTTP ${r.status} for station ${lat},${lng}`);
  const body = await r.json();
  return body.features.find(f => f.properties.time_minutes === minutes)
             ?.geometry ?? null;
}

You end up with one polygon per station, persisted to disk. This collection is your coverage union — the set of all points that at least one station can reach in eight minutes.

Step 2: Test address coordinates against the coverage union

For each address in your service area, run a point-in-polygon test against every station's isochrone polygon. If the address falls inside at least one polygon, it is covered. If it falls outside all of them, it is a gap candidate.

A minimal ray-casting point-in-polygon check that works on the GeoJSON coordinate arrays:

def point_in_polygon(lat, lng, polygon_coords):
    """polygon_coords: list of [lng, lat] pairs (GeoJSON order)."""
    x, y = lng, lat
    n = len(polygon_coords)
    inside = False
    j = n - 1
    for i in range(n):
        xi, yi = polygon_coords[i]
        xj, yj = polygon_coords[j]
        if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi) + xi):
            inside = not inside
        j = i
    return inside

def is_covered(lat, lng, station_isochrones):
    for s in station_isochrones:
        ring = s["polygon"]["coordinates"][0]
        if point_in_polygon(lat, lng, ring):
            return True
    return False

For a service area with tens of thousands of addresses and a handful of stations, this runs in seconds. For a county-scale operation with 200,000 addresses and twenty stations, the inner loop is still fast enough to run end-to-end without parallelisation — but batching across threads or processes is straightforward if you need it.

Step 3: Collect gap coordinates

Pull your address inventory (from your CAD system, your address database, or a geocoded CSV of the electoral roll) and filter to those that fail the coverage test:

import csv, json

with open("isochrones.json") as f:
    station_isochrones = json.load(f)

gaps = []
with open("addresses.csv") as f:
    for row in csv.DictReader(f):
        lat, lng = float(row["lat"]), float(row["lng"])
        if not is_covered(lat, lng, station_isochrones):
            gaps.append({"lat": lat, "lng": lng, "address_id": row["id"]})

print(f"{len(gaps)} gap addresses identified out of {len(all_rows)} total.")

At this point you have a list of coordinates. They are not yet human-readable.

Step 4: Reverse-geocode the gap addresses for the briefing report

Batch the reverse-geocode calls. The endpoint is a single-point call, so for a large gap set you will want to parallelise with concurrent.futures or equivalent. A simple sequential version to show the pattern:

def reverse_geocode(lat, lng):
    r = requests.get(
        f"{API}/reverse",
        params={"lat": lat, "lng": lng, "api_key": KEY},
        timeout=15,
    )
    if r.status_code == 404:
        return None  # no address at this point (road, park, water)
    r.raise_for_status()
    results = r.json().get("results", [])
    return results[0] if results else None

labelled_gaps = []
for g in gaps:
    result = reverse_geocode(g["lat"], g["lng"])
    if result and result.get("confidence", 0) >= 0.6:
        labelled_gaps.append({
            "address_id": g["address_id"],
            "formatted": result["formatted"],
            "postcode": result["components"].get("postcode"),
            "lat": g["lat"],
            "lng": g["lng"],
        })

The confidence >= 0.6 filter removes low-quality matches — points that resolved to a road centreline rather than a doorstep. For the gap report you want address-level precision; anything hazier than that is noise.

Step 5: Produce the gap report and ingest into your planning tool

Write the labelled gap list to a CSV, a GeoJSON file, or directly to your CAD/GIS system's API. For a briefing presentation, group by postcode sector and sort descending by gap count per sector — that ordering tells the planning committee where a new station would deliver the highest marginal coverage improvement.

import csv
from collections import Counter

sector_counts = Counter(
    g["postcode"].rsplit(" ", 1)[0] for g in labelled_gaps if g["postcode"]
)

with open("gap_report.csv", "w", newline="") as f:
    writer = csv.writer(f)
    writer.writerow(["postcode_sector", "gap_address_count"])
    for sector, count in sector_counts.most_common():
        writer.writerow([sector, count])

print("Top 5 gap sectors:")
for sector, count in sector_counts.most_common(5):
    print(f"  {sector}: {count} addresses outside 8-minute coverage")

The output is a CSV that your planning team can open in any spreadsheet tool, or ingest directly into your budget-presentation template.

Production considerations

Isochrone caching is non-negotiable

A station's eight-minute isochrone does not change between dispatch calls. It changes when the road network changes — which happens on the order of weeks or months, not minutes. Cache each station's isochrone polygon aggressively: write it to Redis or a local JSON file, and invalidate only when a road closure or network update is flagged in your refresh logic. Re-calling the isochrone endpoint per dispatch event is both expensive and operationally unnecessary. See Caching Geocoding Results — 90% Cost Reduction for the general pattern; isochrone polygons are an even better caching candidate than geocode results because they are larger objects with longer useful lifetimes.

Latency budgets for real-time dispatch

There is an important distinction between two use cases for isochrones in EMS software.

Offline coverage audit — the pipeline above — has no latency constraint. Run it nightly, produce a report, cache the results. No human is waiting.

Real-time "nearest available unit" assist — where the dispatch console queries which unit can reach an incident address within a threshold — does have a latency constraint. In that scenario, isochrone polygon generation happens offline (same pattern as the audit), and the real-time path is a point-in-polygon test against pre-computed polygons that are already in memory. The point-in-polygon test is microseconds; it does not hit the API at runtime. Conflating the two use cases and trying to call the Isochrones endpoint inline in the dispatch loop is the architectural mistake that produces unacceptable p99 latency at the worst possible moment. See P99 Latency — Why Averages Lie for the reasoning that applies directly here.

Multi-ring isochrones for tiered response

Requesting three thresholds per call (times=5,8,12) adds almost nothing to the per-call cost and gives your planning team a tiered picture. The five-minute ring identifies the highest-confidence cardiac-arrest intervention zone. The eight-minute ring is the standard response benchmark. The twelve-minute ring shows the outer envelope where mutual-aid agreements with neighbouring services become operationally relevant. Build these rings into your planning dashboard as three toggle layers, not as three separate analyses.

Elevation as a supplementary signal

Drive-time isochrones model the road network. They do not model the terrain that road sits on. A station at altitude — Denver is at 1,597 m; mountain communities in Colorado or the Scottish Highlands sit higher still — faces different unit performance in winter conditions that the isochrone alone does not capture. Pairing the isochrone with an elevation lookup per station (and per gap address cluster) gives your planning team the altitude context that explains why the eight-minute polygon for a mountain station looks smaller than the same station would produce at sea level. Miami, at 1 m, and a mountain station at 1,800 m are not comparable on isochrone size alone. See Enriching Property Data with Elevation API for the elevation endpoint pattern — the same call applies here with no changes.

Handling the confidence floor on reverse geocoding

Rural coverage-gap addresses frequently sit on roads that the address database does not resolve to a precise house number — they match to road centrelines with confidence scores in the 0.4-0.6 range. Do not drop these silently. Flag them as low_confidence_match in the gap report and route them to a manual address-verification step. A coverage gap at a farm address that the system cannot reverse-geocode precisely is exactly the kind of address that a rural EMS service is most likely to struggle to find at 02:00. The low-confidence flag is the signal to prioritise manual verification of those specific coordinates, not to discard them.

What this pipeline does not replace

Honest scope, same as always.

Congestion-weighted isochrones. The endpoint models drive times on the nominal road network. Rush-hour congestion in a dense urban service area will expand actual response times beyond the nominal isochrone boundary. For urban services where time-of-day variation is material, layer historical congestion data on top of the nominal isochrone — or run the isochrone analysis twice, once for off-peak and once for peak-hour speeds, and present both polygons to the planning committee.

Unit availability modelling. An isochrone tells you what is geographically reachable. It does not tell you whether a unit is available to make the run — that depends on your current fleet deployment, active calls, and crew scheduling. The gap analysis assumes full unit availability. For realistic coverage modelling under typical operational loads, you need to weight the analysis by your historical unit-availability rate per station, which is data that lives in your CAD system, not in a geocoding API.

Legal compliance certification. Coverage gap analysis produced by this pipeline is an operational planning tool. It is not a regulatory certification of compliance with whatever response-time standards your jurisdiction mandates. The distinction matters if a gap-address incident results in litigation — an internal planning spreadsheet and a certified compliance audit have different legal weights.

Cost maths for a realistic EMS service area

A county service area with ten stations and 80,000 addresses.

  • Isochrone generation: 10 calls at pipeline setup, cached indefinitely. Refreshed monthly: 10 calls/month = 120 calls/year.
  • Point-in-polygon tests: zero API calls — these run against cached polygon data locally.
  • Reverse geocoding of gap addresses: assume 8% gap rate on 80,000 addresses = 6,400 reverse-geocode calls per monthly run.
  • Annual total: roughly 77,000 API calls per year for a monthly gap-analysis cadence.

The free tier covers 3,000 calls per day — 90,000 per month. A county-scale monthly analysis comfortably fits within the free tier. A real-time dispatch augmentation that reverse-geocodes every inbound call address adds call volume that justifies moving to a paid bracket; the entry-level paid tier at $54/month for 100,000 calls covers a medium-sized urban service's monthly dispatch volume with headroom. Current pricing at csv2geo.com/pricing/api.

Frequently Asked Questions

What travel mode should I use for ambulance coverage analysis? Use driving. Walking and cycling modes model pedestrian and cycle-path networks respectively; neither is relevant for a vehicle-based service. For air-ambulance or fast-response cycle paramedic coverage, the cycling mode can produce a useful supplementary analysis, but the primary ambulance coverage audit should always use driving.

How does the isochrone handle one-way streets and turn restrictions? The routing model underlying the Isochrones endpoint respects one-way designations and turn restrictions in the road network data. This means the polygon is directional — it models travel *from* the station *to* the address, not the reverse. For EMS dispatch that is the correct direction. If you also want to model a unit returning to the station (e.g. for post-call turnaround planning), request the isochrone with origin and destination swapped.

Can I request isochrones for multiple stations in a single API call? No — each call takes a single origin point. Batch across stations with a simple loop, as shown in the pipeline above. With ten stations, that is ten calls. Even at a conservative pace of one call per second, the full station set completes in ten seconds. Cache the results and re-call only when station locations change.

The gap report shows addresses I know are covered. Why? The most common cause is a point-in-polygon test applied to the outer ring of a GeoJSON polygon without accounting for interior holes. Some isochrone polygons contain holes — areas enclosed by the outer ring but excluded because they are unreachable (a lake, a private estate with no road access). Check that your point-in-polygon test handles GeoJSON's coordinates[1], coordinates[2], etc. as hole rings and excludes points that fall inside them.

How often should I refresh the isochrone polygons? Refresh when the road network changes — typically when a new road opens, a bridge closes, or a station relocates. A monthly refresh cron job is a reasonable default for most service areas. Do not refresh on every analysis run unless you have specific reason to believe the network has changed; repeated calls for unchanged station locations waste credits and introduce unnecessary variance into the analysis.

Is this pipeline suitable for real-time dispatch augmentation? The pipeline as written is an offline analysis tool. For real-time dispatch — showing a dispatcher which unit can reach an incident address within threshold — pre-compute and cache the isochrone polygons, and run the point-in-polygon test at dispatch time against the cached polygons. Never call the Isochrones API inline on the dispatch event path. The offline pre-computation is the pattern; the real-time path is pure geometry against cached data.

What if my service area spans multiple counties with different CAD systems? The geocoding and isochrone APIs are agnostic to administrative boundaries. Pull address inventories from each CAD system, geocode them to a unified lat/lng CSV, and run the analysis across the merged set. The gap report will surface cross-boundary clusters naturally — addresses that fall in no station's coverage polygon regardless of which county they administratively belong to. Cross-boundary gaps are frequently where mutual-aid agreements are most valuable, and this analysis makes them visible.

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 →