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.

Map of Kansas showing bridges decoded correctly in blue spread along roads and the same bridges misread as decimal degrees in red, clumped into a grid of squares Map of Kansas showing bridges decoded correctly in blue spread along roads and the same bridges misread as decimal degrees in red, clumped into a grid of squares
Misreading the packed values squeezes Kansas's bridges into a grid of squares, because minutes and seconds never exceed 59.

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.

Histogram of how far bridges move when their coordinates are misread as decimal degrees, centred near 30 km Histogram of how far bridges move when their coordinates are misread as decimal degrees, centred near 30 km
The naive reading moves bridges by a median of 31.9 km.

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.

Map of the lower 48 with bridges that decode inside a different state, lined up along state borders and rivers Map of the lower 48 with bridges that decode inside a different state, lined up along state borders and rivers
1,248 bridges decode inside a different state; 83.7% of them lie within 1 km of their own state's border.

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.

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.