Dockless E-Bike Trips Pile Up on a Grid: Rounded Coordinates
On this page
Problem statement
You plot the start points of a city's bike-share trips and the e-bikes that were not at a dock form a perfect lattice: rows of dots about a kilometre apart, each dot stacked with thousands of trips, none of them on a street. The trip file is not wrong about where the trips were; it is deliberately vague. Positions of trips without a station are published rounded to two decimal places, and a map, a distance or a spatial join that treats them as exact positions inherits an error of hundreds of metres.
This page measures that error on Capital Bikeshare's 2025 trips in Washington, D.C. — using e-bike trips whose true position is known to see what the rounding does — and shows which analyses survive it and which do not.
Quick answer
Check whether the positions of trips without a station sit on the 0.01° grid; if they do, aggregate to that grid and never treat a point as more precise than its cell:
free = trips[trips.start_station_id.isna()]
on_grid = (free.start_lat * 100).round(6).mod(1).eq(0) & (free.start_lng * 100).round(6).mod(1).eq(0)
print(f"{len(free):,} trips start away from a station; {on_grid.mean():.2%} of them on the 0.01-degree grid")
print(f"{free.groupby(['start_lat', 'start_lng']).ngroups} distinct start positions among them")
cells = free.groupby([free.start_lat.round(2), free.start_lng.round(2)]).size().rename("trips").reset_index()
print(cells.nlargest(3, "trips").to_string(index=False))
1,414,391 trips start away from a station; 100.00% of them on the 0.01-degree grid
834 distinct start positions among them
start_lat start_lng trips
38.93 -77.03 50132
38.92 -77.03 40889
38.92 -77.04 37138
Every one of the 1,414,391 trips that started away from a station has coordinates on the 0.01° grid, and between them they use only 834 distinct positions. The busiest single position carries 50,132 trip starts. Any map of these points is a map of grid cells.
How large the error is, and where it bites
1. Recognise the rounding
Count decimal places with repr, which shows the value the file held rather than a formatted one:
def decimals(s):
return s.map(lambda v: len(repr(v).split(".")[1]))
ebike = trips[trips.rideable_type == "electric_bike"]
for label, part in (("from a station", ebike[ebike.start_station_id.notna()]),
("no station", ebike[ebike.start_station_id.isna()])):
print(f"{label:15s}", decimals(part.start_lat.sample(200_000, random_state=0)).value_counts(normalize=True)
.round(3).head(3).to_dict())
from a station {6: 0.492, 9: 0.174, 14: 0.115}
no station {2: 0.856, 1: 0.144}
The share of e-bike trips without a start station was 45.0% through July, fell in the week the August file starts, and was 19.4% three weeks later. The trip files carry no note about it. Whatever the reason — a change in how trips that end near a dock are attributed, or in where e-bikes may be parked — a map of dockless trips before and after August compares two different recording rules.
Trips without a station have two decimal places, or one when the second is a zero and was dropped. Trips from a station have six or more — the station's surveyed position, sometimes with floating-point noise out to 14 digits. The two kinds of trip sit side by side in the same columns and can only be told apart by the station id.
2. How big a cell is
lat = 38.9
ns_m, ew_m = 0.01 * 111_132, 0.01 * 111_320 * np.cos(np.radians(lat))
print(f"cell: {ns_m:.0f} m north-south x {ew_m:.0f} m east-west; worst-case shift {np.hypot(ns_m, ew_m) / 2:.0f} m")
cell: 1111 m north-south x 866 m east-west; worst-case shift 705 m
At Washington's latitude a 0.01° cell is 1,111 m tall and 866 m wide, because degrees of longitude shrink towards the poles. The farthest a true position can be from its rounded one is half the cell's diagonal, 705 m.
3. Measure the error on trips whose position is known
The worst case is a bound. The typical error can be measured, because the e-bike trips that started at a station carry its exact position: round those positions as the file would and see how far they move.
known = ebike[ebike.start_station_id.notna()].sample(500_000, random_state=0)
shift = metres(known.start_lat, known.start_lng, known.start_lat.round(2), known.start_lng.round(2))
print(f"rounding a known position moves it: median {np.median(shift):.0f} m, 90th percentile "
f"{np.percentile(shift, 90):.0f} m, max {shift.max():.0f} m")
rounding a known position moves it: median 385 m, 90th percentile 562 m, max 689 m
A median shift of 385 m and a 90th percentile of 562 m: a rounded point is typically four blocks from where the trip started. That is the error every dockless position on the map carries.
4. What it does to trip distances
both_free = trips[trips.start_station_id.isna() & trips.end_station_id.isna()].copy()
both_free["km"] = metres(both_free.start_lat, both_free.start_lng, both_free.end_lat, both_free.end_lng) / 1000
zero = both_free.km == 0
print(f"{len(both_free):,} trips with no station at either end; {zero.mean():.1%} start and end in the same cell")
print(f"those 'zero-distance' trips: median {both_free.minutes[zero].median():.1f} min")
print("distances that occur:", both_free.km.round(2).value_counts().head(5).to_dict())
857,522 trips with no station at either end; 26.1% start and end in the same cell
those 'zero-distance' trips: median 4.6 min
distances that occur: {0.0: 223442, 1.11: 102176, 1.41: 101703, 0.87: 81827, 2.06: 37689}
Distances between rounded points can only take a handful of values: 0, 0.87 km (one cell east or west), 1.11 km (one cell north or south), 1.41 km (one diagonally), and so on. 26.1% of the 857,522 trips with no station at either end start and end in the same cell, so their distance is zero, although the median one lasted 4.6 minutes. Speeds computed from these trips are meaningless for anything shorter than a few kilometres.
5. Do not snap a rounded point to the nearest station
A tempting repair is to move each rounded point to the nearest station. Testing it on the trips whose station is known:
st = (ebike[ebike.start_station_id.notna()].groupby("start_station_id")[["start_lat", "start_lng"]].median())
tree = cKDTree(np.c_[st.start_lat, st.start_lng * np.cos(np.radians(lat))])
_, i = tree.query(np.c_[known.start_lat.round(2), known.start_lng.round(2) * np.cos(np.radians(lat))])
print(f"nearest station to the rounded point is the trip's real station: {(st.index[i] == known.start_station_id.values).mean():.1%}")
nearest station to the rounded point is the trip's real station: 24.6%
The nearest station to the rounded point is the right station only 24.6% of the time. In central Washington a single cell holds several stations; the rounding has removed the information snapping would need.
Working at the grid's resolution
The data used on this page
Capital Bikeshare system data, the twelve monthly trip files for 2025, with trips under a minute, unreturned bikes and the 36 trips that started in 2024 removed as in how to clean a year of bike-share trip data.
from pathlib import Path
import zipfile
import numpy as np
import pandas as pd
import requests
from scipy.spatial import cKDTree
Path("data").mkdir(exist_ok=True)
MONTHS = [f"2025{m:02d}" for m in range(1, 13)]
for m in MONTHS:
f = Path(f"data/{m}-capitalbikeshare-tripdata.zip")
if not f.exists():
f.write_bytes(requests.get(f"https://s3.amazonaws.com/capitalbikeshare-data/{f.name}", timeout=600).content)
def read_month(m):
with zipfile.ZipFile(f"data/{m}-capitalbikeshare-tripdata.zip") as z, z.open(f"{m}-capitalbikeshare-tripdata.csv") as fh:
return pd.read_csv(fh, dtype={"start_station_id": "Int64", "end_station_id": "Int64"},
parse_dates=["started_at", "ended_at"])
def metres(lat1, lon1, lat2, lon2):
lat1, lon1, lat2, lon2 = (np.radians(np.asarray(v, dtype=float)) for v in (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 * 6_371_008.8 * np.arcsin(np.sqrt(a))
trips = pd.concat([read_month(m) for m in MONTHS], ignore_index=True)
trips["minutes"] = (trips.ended_at - trips.started_at).dt.total_seconds() / 60
trips = trips[(trips.started_at >= "2025-01-01") & (trips.minutes >= 1) & trips.end_lat.notna()].reset_index(drop=True)
print(f"{len(trips):,} clean trips")
6,508,775 clean trips
Example 1: map dockless trips at the resolution they have
grid = trips.assign(cell_lat=trips.start_lat.round(2), cell_lng=trips.start_lng.round(2))
per_cell = grid.groupby(["cell_lat", "cell_lng"]).agg(trips=("ride_id", "size"),
dockless=("start_station_id", lambda s: s.isna().mean()))
print(f"{len(per_cell)} cells with a trip start; the busiest:")
print(per_cell.nlargest(4, "trips").round(2).to_string())
print(f"cells where most starts are dockless: {(per_cell.dockless > 0.5).sum()}")
835 cells with a trip start; the busiest:
trips dockless
cell_lat cell_lng
38.91 -77.03 282237 0.11
38.92 -77.04 270201 0.14
38.91 -77.04 259682 0.11
38.93 -77.03 236770 0.21
cells where most starts are dockless: 566
Rounding every trip — station trips included — to the same cells makes the two kinds comparable: 835 cells, and in the busiest the dockless share is 11–21%, so the map is not dominated by whichever cells the dockless trips happen to round into. 566 cells, mostly at the edges of the system, have a majority of dockless starts: there, the grid is the only resolution the data has.
Example 2: the August change
e = trips[trips.rideable_type == "electric_bike"]
week = e.started_at.dt.to_period("W-SUN")
share = e.start_station_id.isna().groupby(week).mean()
print((100 * share.loc["2025-07-07":"2025-08-24"]).round(1).to_string())
started_at
2025-07-07/2025-07-13 44.8
2025-07-14/2025-07-20 45.0
2025-07-21/2025-07-27 45.0
2025-07-28/2025-08-03 39.0
2025-08-04/2025-08-10 23.7
2025-08-11/2025-08-17 21.0
2025-08-18/2025-08-24 19.4
Freq: W-SUN
Why the positions are rounded
A trip with a station has a public position: the dock. A trip without one ends wherever the rider left the bike, which can be a front door. Publishing such a position at full precision would let anyone link trips to homes, so the files round it to a grid fine enough to say which part of town a trip used and coarse enough not to say which building. The GBFS feed publishes parked bikes at full precision, but only while they are parked and without any trip attached.
The consequence for analysis is a rule of resolution: a measurement can be no finer than its coarsest input. Counts per cell, flows between cells and comparisons between neighbourhoods all work at 0.01°, as long as station trips are aggregated to the same cells. Anything that needs the street — distances, speeds, routes, the nearest station, a join to census blocks smaller than a cell — does not, and should be restricted to the trips that have a station at both ends.
Working with rounded positions
- Detect the grid, do not assume it. Multiply by 100 and check for whole numbers; one decimal place is two with a trailing zero.
- Cells are not square. 0.01° of longitude is narrower than 0.01° of latitude everywhere but the equator.
- Aggregate station trips to the same cells before comparing them with dockless ones.
- Do not snap to the nearest station. It found the right one for 24.6% of known trips.
- Small polygons are below the data's resolution. A census block or a park smaller than a cell cannot be assigned dockless trips reliably.
- The share of dockless trips changed in August 2025. Compare like with like across that date.
Internal links
- Bike-share trip data explained — the same rounding in Chicago and the Bay Area
- How to read a GBFS bike-share feed — the same bikes at full precision, live
- How to estimate bike-share trip distance and speed — why dockless trips are left out of it
- How to clean a year of bike-share trip data — the rest of the cleaning
FAQ
Why are bike-share e-bike coordinates rounded to two decimals?
Trips that do not end at a station are published with their positions rounded to 0.01°, which in Washington, D.C. is a cell about 1.1 km by 0.9 km. A position is typically 385 m from where the trip actually started.
Can I recover the exact location of a dockless trip?
No. Snapping to the nearest station recovered the right one for only 24.6% of trips whose station was known; the information is gone from the file.