Index Ai Academy
Phase 3 · Machine learning · Data analysis

Location data

Haversine distances, nearest places, GeoJSON, point in polygon and simple maps.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Almost every business dataset has a place in it: a customer's address, a store, a delivery, a hike's starting point. The questions are always the same: how far is it, what's nearest, which zone is it in, and what does it look like on a map? They're easy to get slightly wrong, because the Earth is round, degrees of longitude shrink toward the poles, and the standard file format lists coordinates in the opposite order to the one people say out loud. This lesson builds each tool with NumPy, so you know what map libraries are doing when you use one.

What you'll be able to do

  • Read latitude and longitude, and explain why distance on a globe isn't a straight line on a grid
  • Compute great-circle distances with the haversine formula, and find the nearest places in bulk
  • Read GeoJSON, keeping its longitude-first order straight
  • Test whether a point is inside a zone, and draw points and zones on a simple map

What it covers

  1. Latitude, longitude, and why degrees aren't miles
  2. The haversine formula: distance on a sphere
  3. The nearest places, all at once
  4. The n closest places
  5. GeoJSON, and the order trap
  6. Is the point inside the zone?
  7. Inside or outside?
  8. Worked example: members and zones on a map
Quick check
Two stores are 1 degree of longitude apart. How far apart are they?
  1. It depends on how far north they are
  2. Always exactly 1 mile
  3. Always about 69 miles, like a degree of latitude
  4. It depends on how far east they are
Show the answer

It depends on how far north they are

Lines of longitude meet at the poles, so a degree east-west shrinks as you go north: about 69 miles at the equator, about 52 in New York, zero at the pole. That's why distances need a formula, not raw degrees.

Latitude, longitude, and why degrees aren't miles

A place on Earth is two angles. Latitude is how far north (positive) or south (negative) of the equator, from -90 to 90 degrees. Longitude is how far east (positive) or west (negative) of a line through Greenwich, from -180 to 180. Portland, Oregon is at about latitude 45.52, longitude -122.68.

One degree of latitude is always about 69 miles. One degree of longitude is 69 miles only at the equator; the lines meet at the poles, so a degree of longitude shrinks with the cosine of the latitude, to about 48 miles at Portland's latitude. Treat the two angles as plain x and y and every east-west distance comes out too big:

import numpy as np

portland = (45.52, -122.68)
bend = (44.06, -121.31)              # Bend, Oregon, southeast of Portland

d_lat = bend[0] - portland[0]
d_lon = bend[1] - portland[1]
naive = np.hypot(d_lat, d_lon) * 69          # np.hypot(x, y) is the straight-line length, √(x² + y²)
better = np.hypot(d_lat * 69, d_lon * 69 * np.cos(np.radians(45)))   # longitude shrunk at this latitude
print(round(naive), "miles (naive)", round(better), "miles (longitude scaled)")
It runs right here in your browser, with nothing to install.

np.radians converts degrees to radians, the unit NumPy's cos and sin expect (180 degrees is π radians, so multiply by π / 180). Scaling longitude fixes most of the error over short distances. For long ones, and for anything that matters, use the formula for a real sphere.

The haversine formula: distance on a sphere

The shortest path between two points on a globe is an arc of a great circle, a circle around the Earth's middle like the equator. The haversine formula gives that arc's length from the two points' latitudes and longitudes, using the Earth's radius (about 3,959 miles). In code:

import numpy as np

EARTH_MILES = 3959

def haversine_miles(lat1, lon1, lat2, lon2):
    lat1, lon1, lat2, lon2 = map(np.radians, (lat1, lon1, lat2, lon2))
    a = np.sin((lat2 - lat1) / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin((lon2 - lon1) / 2) ** 2
    return 2 * EARTH_MILES * np.arcsin(np.sqrt(a))

print(round(haversine_miles(45.52, -122.68, 44.06, -121.31)))     # Portland to Bend
print(round(haversine_miles(45.52, -122.68, 47.61, -122.33)))     # Portland to Seattle
print(round(haversine_miles(40.71, -74.01, 51.51, -0.13)))        # New York to London

In plain words: a combines half the north-south gap and half the east-west gap (the east-west part shrunk by both latitudes' cosines) into one number from 0, for the same point, to 1, for opposite sides of the globe. Then 2 × arcsin(√a) turns it into the angle between the two points as seen from the Earth's center, and that angle (in radians) times the radius is the distance along the surface.

Portland to Bend comes out about 121 miles and Portland to Seattle about 145; New York to London about 3,460. The Earth isn't a perfect sphere, so this can be off by up to about 0.5%, which is fine for "nearest store" and wrong for surveying. Because every step is a NumPy function, the same code works on whole arrays of points at once.

Quick check
Two trailheads are both at latitude 45, one degree of longitude apart. Roughly how far apart are they?
import numpy as np
print(round(69 * np.cos(np.radians(45))))
  1. It depends on the trailheads' elevation
  2. About 49 miles: 69 × cos 45°
  3. About 69 miles, like a degree of latitude
  4. About 98 miles, twice a degree of latitude
Show the answer

About 49 miles: 69 × cos 45°

69 × cos(45°) ≈ 69 × 0.707 ≈ 49 miles. At the equator it would be 69, and near the poles almost nothing.

The nearest places, all at once

"Which trailhead is closest to each member?" is distances between every member and every trailhead, then the smallest in each row. NumPy's broadcasting does the whole grid in one call: give the members' coordinates shape (members, 1) and the trailheads' shape (trailheads,), and haversine_miles returns a (members, trailheads) table.

import numpy as np

EARTH_MILES = 3959
def haversine_miles(lat1, lon1, lat2, lon2):
    lat1, lon1, lat2, lon2 = map(np.radians, (lat1, lon1, lat2, lon2))
    a = np.sin((lat2 - lat1) / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin((lon2 - lon1) / 2) ** 2
    return 2 * EARTH_MILES * np.arcsin(np.sqrt(a))

trailheads = {"Fern Hollow": (45.47, -122.20), "North Ridge": (45.70, -121.88), "Pine Gap": (44.98, -122.05)}
names = list(trailheads)
t_lat = np.array([trailheads[n][0] for n in names])
t_lon = np.array([trailheads[n][1] for n in names])

members = {"Ana": (45.52, -122.68), "Bo": (45.60, -121.95), "Cy": (44.94, -123.03)}
m_lat = np.array([p[0] for p in members.values()])[:, None]      # shape (3, 1)
m_lon = np.array([p[1] for p in members.values()])[:, None]

dist = haversine_miles(m_lat, m_lon, t_lat, t_lon)                # shape (3, 3)
print(np.round(dist, 1))
for member, row in zip(members, dist):
    print(member, "->", names[int(np.argmin(row))], round(float(row.min()), 1), "miles")

[:, None] adds a second axis, turning a row of three numbers into a column, so the subtraction inside the formula pairs every member with every trailhead. For thousands of points this is fast; for millions, real systems use a spatial index, a structure that skips far-away points without measuring them.

Try it now

Most location questions are one of four: how far, what's nearest, which zone, or which way around.

Drill

The n closest places

Write nearest(places, lat, lon, n=2): places maps names to (lat, lon) pairs. Return the n closest names to the point, with their distances in miles rounded to 1 decimal, as a list of (name, miles) pairs, closest first. haversine_miles is given; compute all the distances with one call on arrays, then sort.

import numpy as np

EARTH_MILES = 3959
def haversine_miles(lat1, lon1, lat2, lon2):
    lat1, lon1, lat2, lon2 = map(np.radians, (lat1, lon1, lat2, lon2))
    a = np.sin((lat2 - lat1) / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin((lon2 - lon1) / 2) ** 2
    return 2 * EARTH_MILES * np.arcsin(np.sqrt(a))


def nearest(places, lat, lon, n=2):
    return list(places.items())[:n]


stores = {"Portland": (45.52, -122.68), "Bend": (44.06, -121.31), "Seattle": (47.61, -122.33), "Salem": (44.94, -123.03)}
print(nearest(stores, 45.0, -122.9))
In the lesson, your code is checked as you write it.

GeoJSON, and the order trap

Maps and location APIs exchange shapes as GeoJSON: ordinary JSON with a few agreed keys. A Point is one position, a Polygon is a closed ring of positions (the last repeats the first), and a FeatureCollection is a list of shapes, each with its own properties such as a name.

The trap: GeoJSON writes every position as [longitude, latitude], x then y, the reverse of how people say coordinates out loud. Swap them and a store in New York (40.71, -74.01) lands at latitude -74, deep in the Southern Ocean, while Portland gets an impossible latitude of -122.68.

import json

text = """{"type": "FeatureCollection", "features": [
  {"type": "Feature", "properties": {"name": "Fern Hollow trailhead"},
   "geometry": {"type": "Point", "coordinates": [-122.20, 45.47]}},
  {"type": "Feature", "properties": {"name": "Gorge delivery zone"},
   "geometry": {"type": "Polygon", "coordinates": [[[-122.4, 45.4], [-121.8, 45.4], [-121.8, 45.8], [-122.4, 45.8], [-122.4, 45.4]]]}}
]}"""
data = json.loads(text)
for f in data["features"]:
    g = f["geometry"]
    if g["type"] == "Point":
        lon, lat = g["coordinates"]                 # longitude first
        print(f["properties"]["name"], "lat", lat, "lon", lon)
    else:
        ring = g["coordinates"][0]                  # the outer ring of the polygon
        print(f["properties"]["name"], len(ring) - 1, "corners, closed:", ring[0] == ring[-1])

A Polygon's coordinates is a list of rings: the first is the outside edge, and any others are holes cut out of it. Most zones have just one ring, which is why the code takes [0].

Quick check
A colleague's code reads a GeoJSON point with lat, lon = feature["geometry"]["coordinates"] for a store in Denver (39.74 N, 104.99 W). What will lat and lon be?
  1. lat = 39.74 and lon = -104.99, as intended
  2. Both 0, because GeoJSON stores radians
  3. An error: GeoJSON points have three numbers
  4. lat = -104.99 and lon = 39.74: swapped
Show the answer

lat = -104.99 and lon = 39.74: swapped

GeoJSON positions are [longitude, latitude]. A latitude of -104.99 is impossible (it must be between -90 and 90), which is a handy sanity check to add to any loader.

Is the point inside the zone?

Deciding whether a delivery address is inside a zone is the point-in-polygon test. The classic method casts a ray: draw a line from the point straight east, and count how many of the polygon's edges it crosses. An odd count means inside (you crossed in and never out), even means outside. For small zones you can treat longitude and latitude as plain x and y, because the shape distortion is the same for the point and the edges.

For each edge from corner (x1, y1) to (x2, y2), the ray at height y crosses it when y is between the two corners' heights (one strictly above, one at or below), and the crossing's x, found along the edge, is to the right of the point.

Drill

Inside or outside?

Write inside(lon, lat, ring): ring is a list of [lon, lat] corners, closed (the last equals the first), as in GeoJSON. Count the edges crossed by a ray going east from the point: for each pair of neighboring corners (x1, y1), (x2, y2), if (y1 > lat) != (y2 > lat), the edge spans the point's height; the crossing is at x = x1 + (lat - y1) * (x2 - x1) / (y2 - y1), and it counts if x > lon. Return True for an odd count.

def inside(lon, lat, ring):
    xs = [p[0] for p in ring]
    ys = [p[1] for p in ring]
    return min(xs) <= lon <= max(xs) and min(ys) <= lat <= max(ys)


square = [[-122.4, 45.4], [-121.8, 45.4], [-121.8, 45.8], [-122.4, 45.8], [-122.4, 45.4]]
print(inside(-122.0, 45.6, square), inside(-121.5, 45.6, square))
In the lesson, your code is checked as you write it.
Worked example

Worked example: members and zones on a map

A quick map is a scatter plot of longitude (x) against latitude (y). Set the axis aspect so a degree of longitude is drawn shorter than a degree of latitude, by the same cosine factor, or every map is stretched sideways.

import numpy as np
import matplotlib.pyplot as plt

zone = [[-122.4, 45.4], [-121.8, 45.4], [-121.8, 45.8], [-122.4, 45.8], [-122.4, 45.4]]
rng = np.random.default_rng(3)
lons = rng.uniform(-122.7, -121.5, 40)
lats = rng.uniform(45.2, 46.0, 40)

def inside(lon, lat, ring):
    crossings = 0
    for (x1, y1), (x2, y2) in zip(ring, ring[1:]):
        if (y1 > lat) != (y2 > lat) and x1 + (lat - y1) * (x2 - x1) / (y2 - y1) > lon:
            crossings += 1
    return crossings % 2 == 1

in_zone = np.array([inside(x, y, zone) for x, y in zip(lons, lats)])
fig, ax = plt.subplots(figsize=(6, 4.5))
ax.plot(*zip(*zone), color="black", linewidth=1)
ax.scatter(lons[in_zone], lats[in_zone], label=f"in zone ({in_zone.sum()})")
ax.scatter(lons[~in_zone], lats[~in_zone], marker="x", label=f"outside ({(~in_zone).sum()})")
ax.set_aspect(1 / np.cos(np.radians(lats.mean())))
ax.set_xlabel("longitude")
ax.set_ylabel("latitude")
ax.legend(loc="upper right")
plt.show()
In the lesson you can change this and run it.

ax.plot(*zip(*zone)) splits the corners into a list of longitudes and a list of latitudes and draws the outline, and ~in_zone flips every True to False and back, picking the points outside. set_aspect(1 / cos(latitude)) makes one degree up as long on screen as 1 / cos degrees across, about 1.43 here, which matches the ground. Map libraries such as GeoPandas, Folium and Shapely build on exactly these ideas, adding real map projections, background tiles and fast point-in-polygon tests for millions of shapes.

Practice what you just read

  • 5 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

Why can't you use plain straight-line (Euclidean) distance on (latitude, longitude) degrees to find the nearest store?

  1. Longitude degrees shrink away from the equator
  2. Latitude and longitude are never negative
  3. Straight-line distance needs three coordinates
  4. GeoJSON coordinates can't be subtracted

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson