Location data
Haversine distances, nearest places, GeoJSON, point in polygon and simple maps.
Create a free account Try part of it here
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
- Latitude, longitude, and why degrees aren't miles
- The haversine formula: distance on a sphere
- The nearest places, all at once
- The n closest places
- GeoJSON, and the order trap
- Is the point inside the zone?
- Inside or outside?
- Worked example: members and zones on a map
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)")
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:
acombines 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.
import numpy as np print(round(69 * np.cos(np.radians(45))))
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.
Most location questions are one of four: how far, what's nearest, which zone, or which way around.
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))
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].
lat, lon = feature["geometry"]["coordinates"] for a store in Denver (39.74 N, 104.99 W). What will lat and lon be?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.
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))
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()
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
Try a quiz question
Why can't you use plain straight-line (Euclidean) distance on (latitude, longitude) degrees to find the nearest store?