ANALYSIS TOOL BOX
intermediate25 min

Geographic Data Analysis: Distance, Nearest Neighbor & Clustering

Use pairwise distance, nearest-neighbor matching, and DBSCAN clustering on geographic data to find where to open a new warehouse.

geographic-datageospatialdistanceclusteringsite-selection

A lot of "geographic data analysis" comes down to three questions asked over and over: how far apart are these points, which known location is each point closest to, and where are the points dense enough to matter? Answer those three well and you can do real site-selection and coverage analysis without touching a GIS desktop tool.

This tutorial walks through all three using a logistics scenario: a regional retailer with three warehouses wants to know whether their delivery network has a coverage gap, and if so, where a fourth warehouse should go. We'll use CalculateHaversineDistance to check warehouse spacing, FindNearestPointOfInterest to match deliveries to their nearest warehouse, and ConductClusterAnalysis to find where underserved demand is dense enough to justify a new facility.

Prerequisites

  • Python 3.9+
  • analysistoolbox installed (pip install analysistoolbox)
  • folium installed for the interactive map views (pip install folium)

Step 1: Build the warehouse and delivery data

We'll simulate three existing warehouses around Denver, and 230 delivery addresses. Most cluster near the two closer-in warehouses; a third, denser group sits up near Boulder — about 25 miles from the nearest warehouse.

python
import pandas as pd
import numpy as np

np.random.seed(42)

warehouses = pd.DataFrame({
    'site_name': ['Denver Downtown', 'Aurora', 'Lakewood'],
    'lat': [39.7392, 39.7294, 39.7047],
    'lon': [-104.9903, -104.8319, -105.0814],
})

def make_cluster(center_lat, center_lon, n, spread=0.03):
    return pd.DataFrame({
        'lat': np.random.normal(center_lat, spread, n),
        'lon': np.random.normal(center_lon, spread, n),
    })

served_near_denver = make_cluster(39.74, -104.99, 90)
served_near_aurora = make_cluster(39.73, -104.83, 80)
underserved_boulder = make_cluster(40.015, -105.27, 60, spread=0.02)

deliveries = pd.concat(
    [served_near_denver, served_near_aurora, underserved_boulder],
    ignore_index=True,
)
deliveries['delivery_id'] = [f'D{i:04d}' for i in range(len(deliveries))]

print(f"{len(warehouses)} warehouses, {len(deliveries)} delivery addresses")

Step 2: Check warehouse spacing with pairwise distance

Before diagnosing a coverage gap, confirm the existing network isn't already overlapping. CalculateHaversineDistance computes the great-circle distance between every pair of points in a DataFrame:

python
from analysistoolbox.geospatial_analysis import CalculateHaversineDistance

warehouse_distances = CalculateHaversineDistance(
    warehouses,
    id_column='site_name',
    distance_unit='mi',
    plot_connections=True,
)

print(warehouse_distances)
Teaching Note

Latitude and longitude are angular coordinates on a sphere, not (x, y) points on a flat plane. A one-degree change in longitude covers a different ground distance depending on how close you are to the equator or the poles. Treating coordinates as Cartesian and computing ordinary Euclidean distance silently distorts every result — the distortion grows with distance from the equator and with how far apart the points are. The Haversine formula computes the actual great-circle distance instead, which is why it's the standard building block for point-to-point distance work in GIS, logistics, and pattern-of-life analysis.

With three warehouses roughly 10–15 miles apart, the network isn't overlapping — so if there's a coverage gap, it's not because facilities are redundant.

Step 3: Match every delivery to its nearest warehouse

FindNearestPointOfInterest answers a more targeted question than the full pairwise matrix: for each observation, which reference point is closest, and how far away is it? It indexes the warehouses in a BallTree with the haversine metric, so it scales even with a large reference set.

python
from analysistoolbox.geospatial_analysis import FindNearestPointOfInterest

matches = FindNearestPointOfInterest(
    deliveries,
    warehouses,
    id_column='delivery_id',
    poi_id_column='site_name',
    distance_unit='mi',
    map_matches=True,
)

print(matches.head())

Now flag deliveries that fall outside a reasonable service radius:

python
underserved_threshold_miles = 15
underserved = matches[matches['distance_mi'] > underserved_threshold_miles]

print(
    f"{len(underserved)} of {len(matches)} deliveries are more than "
    f"{underserved_threshold_miles} mi from their nearest warehouse"
)
Note

matches carries the original observation coordinates (lat, lon) alongside the matched warehouse's identifier and distance — you don't need to rejoin it back to deliveries for the next step.

Step 4: Cluster the underserved deliveries

Knowing that ~60 deliveries are underserved doesn't tell you whether they're scattered randomly (nothing to do) or concentrated enough to justify a new facility (something to do). ConductClusterAnalysis runs DBSCAN with the haversine metric to find dense pockets of points, labeling anything too sparse to form a cluster as Noise rather than forcing it into a group.

python
from analysistoolbox.geospatial_analysis import ConductClusterAnalysis

cluster_summary = ConductClusterAnalysis(
    underserved,
    longitude_column='lon',
    latitude_column='lat',
    distance_km=3,
    min_samples=15,
    map_clusters=True,
)

print(cluster_summary)
Teaching Note

DBSCAN's two parameters encode a business decision, not just a statistical one. distance_km should reflect the radius within which deliveries are genuinely part of the same neighborhood — too large and you'll merge separate pockets of demand into one; too small and a real cluster gets fragmented into noise. min_samples should reflect the minimum volume that would actually justify the thing you're deciding (here, a new warehouse) — not a default value picked without reference to the business question.

Step 5: Identify the candidate site

The cluster summary gives you the midpoint (mean coordinates) and size of every cluster found. The largest non-noise cluster is your strongest candidate for a new warehouse location:

python
candidate_site = (
    cluster_summary[cluster_summary['Cluster ID'] != -1]
    .sort_values('Point Count', ascending=False)
    .iloc[0]
)

print(
    f"Candidate site near ({candidate_site['Midpoint Latitude']:.4f}, "
    f"{candidate_site['Midpoint Longitude']:.4f}), which would newly cover "
    f"{candidate_site['Point Count']} currently underserved deliveries"
)

Given how the data was constructed, this should surface the Boulder-area cluster — a group of deliveries dense enough, and far enough from the existing network, to justify a fourth warehouse there rather than expanding capacity at an existing site.

Common mistakes to avoid

Mistake 1: Computing distance with plain Euclidean math on lat/lon. It's tempting to just do sqrt((lat1-lat2)**2 + (lon1-lon2)**2). This is wrong on a sphere and gets worse the farther apart the points are or the higher the latitude. Always use a haversine (or projected) distance for real-world geographic data.

Mistake 2: Clustering the full delivery set instead of the underserved subset. Running ConductClusterAnalysis on all 230 deliveries just re-discovers the existing warehouse service areas — it won't tell you anything about whitespace. Filter to the underserved population first.

Mistake 3: Treating "Noise" (Cluster ID -1) points as a rounding error. Noise means those points weren't dense enough to form a cluster at your chosen distance_km/min_samples. That's a legitimate finding — it means those underserved deliveries are isolated and probably don't justify a dedicated facility, only individually higher delivery cost.

Mistake 4: Picking DBSCAN parameters with no connection to the decision. distance_km=3, min_samples=15 here encodes "a neighborhood radius of 3 km, with at least 15 deliveries, is worth a facility." Different businesses — same-day grocery vs. freight — would set very different values.

Full code

python
import pandas as pd
import numpy as np
from analysistoolbox.geospatial_analysis import (
    CalculateHaversineDistance,
    FindNearestPointOfInterest,
    ConductClusterAnalysis,
)

np.random.seed(42)

warehouses = pd.DataFrame({
    'site_name': ['Denver Downtown', 'Aurora', 'Lakewood'],
    'lat': [39.7392, 39.7294, 39.7047],
    'lon': [-104.9903, -104.8319, -105.0814],
})

def make_cluster(center_lat, center_lon, n, spread=0.03):
    return pd.DataFrame({
        'lat': np.random.normal(center_lat, spread, n),
        'lon': np.random.normal(center_lon, spread, n),
    })

deliveries = pd.concat([
    make_cluster(39.74, -104.99, 90),
    make_cluster(39.73, -104.83, 80),
    make_cluster(40.015, -105.27, 60, spread=0.02),
], ignore_index=True)
deliveries['delivery_id'] = [f'D{i:04d}' for i in range(len(deliveries))]

# 1. Confirm existing warehouses aren't already overlapping
warehouse_distances = CalculateHaversineDistance(
    warehouses, id_column='site_name', distance_unit='mi'
)

# 2. Match each delivery to its nearest warehouse
matches = FindNearestPointOfInterest(
    deliveries, warehouses,
    id_column='delivery_id', poi_id_column='site_name',
    distance_unit='mi',
)

# 3. Flag underserved deliveries
underserved = matches[matches['distance_mi'] > 15]

# 4. Cluster the underserved deliveries to find where demand is dense
cluster_summary = ConductClusterAnalysis(
    underserved, longitude_column='lon', latitude_column='lat',
    distance_km=3, min_samples=15,
)

# 5. Surface the strongest candidate site
candidate_site = (
    cluster_summary[cluster_summary['Cluster ID'] != -1]
    .sort_values('Point Count', ascending=False)
    .iloc[0]
)
print(candidate_site)