NBI Coordinates Land in the Wrong Place: Packed Degrees, Minutes and Seconds
On this page
Problem statement
Plot the National Bridge Inventory's LAT_016 and LONG_017 and the bridges land in the wrong place: tens of kilometres away from their roads, or on the other side of the world. The columns are not decimal degrees. Latitude is packed as eight digits β degrees, minutes, seconds and hundredths of a second run together β and longitude as nine, with no minus sign even though every US longitude is west. Divide by a million and every bridge moves. This page shows the format, the conversion and the checks that catch the values that are wrong even after it.
Quick answer
Read both columns as text, split them into degrees, minutes and seconds (DD MM SS.ss and DDD MM SS.ss), compute degrees + minutes/60 + seconds/3600, and negate the longitude. The setup's dms() function does it in one line.
naive_lat = pd.to_numeric(nbi.LAT_016) / 1e6
naive_lon = pd.to_numeric(nbi.LONG_017) / 1e6
print(nbi[["LAT_016", "LONG_017"]].head(3).assign(naive_lat=naive_lat.head(3).round(5), naive_lon=naive_lon.head(3).round(5),
lat=nbi.lat.head(3).round(5), lon=nbi.lon.head(3).round(5)).to_string(index=False))
err = np.hypot((naive_lat - nbi.lat) * 111.2, (naive_lon.abs() + nbi.lon) * 111.2 * np.cos(np.radians(nbi.lat)))
print(f"shift from reading DDMMSSss as decimal degrees: median {err.median():.1f} km, up to {err.max():.1f} km")
LAT_016 LONG_017 naive_lat naive_lon lat lon
31061094 087341348 31.06109 87.34135 31.10304 -87.57041
31062020 087340890 31.06202 87.34089 31.10561 -87.56914
32174330 084583799 32.17433 84.58380 32.29536 -84.97722
shift from reading DDMMSSss as decimal degrees: median 31.9 km, up to 16836.2 km
Reading 31061094 as 31.06109 instead of 31.10304 moves the first bridge about 5 km northβsouth, and dropping the west sign puts it in China. Even with the sign handled, the decimal misreading shifts bridges by a median of 31.9 km, and broken values by up to 16,836.2 km.
Decoding the packed values
1. The format
print("field widths:", nbi.LAT_016.str.len().value_counts().to_dict(), nbi.LONG_017.str.len().value_counts().to_dict())
s = nbi.LAT_016.iloc[0]
print(f"{s} = {s[:2]} degrees, {s[2:4]} minutes, {s[4:6]}.{s[6:]} seconds")
field widths: {8.0: 624170} {9.0: 624171}
31061094 = 31 degrees, 06 minutes, 10.94 seconds
Latitudes are eight characters and longitudes nine in every row that has a value. 31061094 reads as 31 degrees, 06 minutes and 10.94 seconds. Because minutes and seconds only go to 59, values that look like decimals are off by up to 40% of a degree in the fractional part.
2. Longitude is positive and has three degree digits
print(f"longitudes stored as positive numbers: {(pd.to_numeric(nbi.LONG_017) > 0).mean():.2%}")
print("longitudes whose degrees start with 1 (west of 100 degrees W):", (nbi.LONG_017.str[0] == "1").sum())
longitudes stored as positive numbers: 99.94%
longitudes whose degrees start with 1 (west of 100 degrees W): 100011
99.94% of longitudes are stored as positive numbers, and 100,011 have a three-digit degree part starting with 1 β bridges west of 100Β°W, from the Great Plains to the Pacific. Slicing the longitude as if it had two degree digits would break all of them; zero-pad to nine characters and take the first three.
3. Convert, then check against the state
pts = gpd.GeoDataFrame(nbi[["STATE_CODE_001"]], geometry=gpd.points_from_xy(nbi.lon, nbi.lat), crs=4326)
j = gpd.sjoin(pts, states[["STATEFP", "geometry"]], predicate="within", how="left")
print(f"inside their state: {(j.STATEFP == j.STATE_CODE_001).mean():.2%}; outside every state: {j.STATEFP.isna().mean():.2%}")
inside their state: 99.64%; outside every state: 0.16%
After conversion, 99.64% of bridges lie inside the state their record names and 0.16% outside every state.
4. Zeros and swapped values
la, lo = pd.to_numeric(nbi.LAT_016, errors="coerce"), pd.to_numeric(nbi.LONG_017, errors="coerce")
print(f"coordinates missing: {(la.isna() | lo.isna()).sum()}; recorded as zero: {((la == 0) | (lo == 0)).sum()}")
far = j[j.STATEFP.notna() & (j.STATEFP != j.STATE_CODE_001)]
print(f"bridges inside a different state: {len(far):,}; of these within 1 km of their own state's border: "
f"{(far.to_crs(5070).distance(states.set_index('STATEFP').to_crs(5070).loc[far.STATE_CODE_001].boundary, align=False) < 1000).mean():.1%}")
coordinates missing: 23; recorded as zero: 6
bridges inside a different state: 1,248; of these within 1 km of their own state's border: 83.7%
23 bridges have no coordinates and 6 are recorded as zero. 1,248 lie inside a different state, and 83.7% of those are within 1 km of their own state's border β bridges over boundary rivers, plotted on the far bank. The rest are typos and need correcting from the road and feature names.
How far, and how precise
The data used on this page
The Federal Highway Administration's National Bridge Inventory for 2025, the latitude and longitude items, and Census Bureau 2023 states.
import zipfile
from pathlib import Path
import geopandas as gpd
import numpy as np
import pandas as pd
import requests
Path("data").mkdir(exist_ok=True)
# FHWA National Bridge Inventory, 2025 submission, highway bridges, all states in one comma-delimited file (53 MB zip).
# The server is slow and drops long downloads, so resume until the file is complete.
URL = "https://www.fhwa.dot.gov/bridge/nbi/2025hwybronefiledel.zip"
f = Path("data/2025hwybronefiledel.zip")
size = int(requests.head(URL, allow_redirects=True, timeout=60).headers["Content-Length"])
while not f.exists() or f.stat().st_size < size:
have = f.stat().st_size if f.exists() else 0
try:
with requests.get(URL, headers={"Range": f"bytes={have}-"}, stream=True, timeout=120) as r, open(f, "ab") as out:
for chunk in r.iter_content(1 << 20):
out.write(chunk)
except requests.RequestException:
pass # resume from what arrived
with zipfile.ZipFile(f) as z:
nbi = pd.read_csv(z.open("2025HwyBridgesDelimitedAllStates.txt"), quotechar="'", dtype=str, low_memory=False)
def dms(s, west=False):
"""NBI packs coordinates as DDMMSSss (latitude) and DDDMMSSss (longitude, positive west)."""
s = s.str.strip().str.zfill(9 if west else 8)
d = s.str[:-6].astype(float) + s.str[-6:-4].astype(float) / 60 + s.str[-4:].astype(float) / 100 / 3600
return -d if west else d
nbi["lat"], nbi["lon"] = dms(nbi.LAT_016), dms(nbi.LONG_017, west=True)
nbi["cond"] = nbi.BRIDGE_CONDITION # G, F or P, derived by FHWA
nbi["adt"] = pd.to_numeric(nbi.ADT_029, errors="coerce")
nbi["built"] = pd.to_numeric(nbi.YEAR_BUILT_027, errors="coerce")
nbi["area"] = pd.to_numeric(nbi.DECK_AREA, errors="coerce")
for name in ["cb_2023_us_state_20m.zip"]:
if not Path(f"data/{name}").exists():
Path(f"data/{name}").write_bytes(requests.get(f"https://www2.census.gov/geo/tiger/GENZ2023/shp/{name}", timeout=300).content)
states = gpd.read_file("data/cb_2023_us_state_20m.zip").to_crs(4326)
nbi["state"] = nbi.STATE_CODE_001.map(states.set_index("STATEFP").STUSPS)
print(f"{len(nbi):,} highway bridges, {nbi.shape[1]} fields, {nbi.STATE_CODE_001.nunique()} state codes")
624,193 highway bridges, 130 fields, 53 state codes
Example 1: how far the naive reading moves a bridge
print("median shift by state, km:", err.groupby(nbi.state).median().sort_values().round(1).iloc[[0, 1, -2, -1]].to_dict())
median shift by state, km: {'PR': 23.6, 'MA': 26.3, 'RI': 37.9, 'DC': 40.5}
The size of the error from the decimal misreading varies by state, because it depends on where the minutes and seconds fall: a median of 23.6 km in Puerto Rico and 40.5 km in DC among the extremes shown.
Example 2: precision of the stored value
print(f"one hundredth of a second of latitude is {0.01 / 3600 * 111_195:.2f} m; "
f"bridges whose seconds are whole numbers (00 hundredths): {(nbi.LAT_016.str[-2:] == '00').mean():.1%}")
one hundredth of a second of latitude is 0.31 m; bridges whose seconds are whole numbers (00 hundredths): 7.4%
The format can store a position to 0.31 m, far finer than needed; 7.4% of bridges have whole seconds, a sign of coordinates rounded or digitised from maps rather than surveyed.
Why the inventory packs coordinates
The NBI's record layout dates from fixed-width records on punched cards and tape, where every item had a fixed number of characters and no separators or signs. Degrees, minutes and seconds were the surveyors' notation, and since every US bridge is north and west, the hemisphere was implied rather than stored. The delimited file keeps the old packing for compatibility.
The same convention appears in other legacy US federal files. The robust conversion treats the value as text, pads it to its fixed width, slices by position, and validates the result against an independent geography β the state the record says it is in.
Other coordinate traps
- Never read the columns as numbers. Leading zeros on longitudes such as
087β¦disappear, and the slicing breaks. - Missing and zero values become NaN or (0, 0); drop them before a spatial join, or they all land in the Atlantic off Africa.
- Points on the wrong bank of a border river are usually right to within the river's width; keep them.
- Puerto Rico follows the same convention, west longitude unsigned.
Internal links
- How to map the National Bridge Inventory β the file mapped correctly
- How to convert DMS to decimal degrees β the notation
- NBI inspection dates that will not parse β another packed field
FAQ
What format are National Bridge Inventory coordinates in?
Packed degrees, minutes and seconds with hundredths: DDMMSSss for latitude and DDDMMSSss for longitude, west longitude stored without a minus sign.
How do I convert NBI latitude and longitude to decimal degrees?
Slice the text into degrees, minutes and seconds, compute degrees + minutes/60 + seconds/3600, and negate the longitude.