Fixing DMS Coordinates That Land in the Wrong Hemisphere
On this page
Problem statement
You converted a column of degrees-minutes-seconds coordinates, plotted them, and the Hawaiian Islands appeared in the western Pacific south-east of Japan, or your Brazilian sites turned up in the Sahara. The conversion arithmetic was right; the sign was lost. In DMS notation the hemisphere letter carries the sign, west and south being negative, and a parser that reads the digits but ignores the letter mirrors every point across the equator or the prime meridian. The error is easy to miss in a table, because the numbers look reasonable, and obvious on a map.
This page reproduces the mistake on the USGS Geographic Names Information System's features for Hawaii, measures how far the points move, detects it automatically with a bounding box and a country lookup, and fixes it, including the double negative that appears when a source writes both a minus sign and a W.
Quick answer
Apply the sign from the hemisphere letter, and check the result against where the data should be:
def dms_naive(s):
digits = s[:-1]; n = 3 if s[-1] in "EW" else 2
return int(digits[:n]) + int(digits[n:n + 2]) / 60 + float(digits[n + 2:]) / 3600 # ignores the letter
def dms(s):
v = dms_naive(s)
return -v if s[-1] in "SW" else v
G["lon_naive"] = G.prim_long_dms.map(dms_naive)
G["lon"] = G.prim_long_dms.map(dms)
print(f"features: {len(G):,}; naive longitudes from {G.lon_naive.min():.2f} to {G.lon_naive.max():.2f}; correct from {G.lon.min():.2f} to {G.lon.max():.2f}")
features: 7,333; naive longitudes from 154.81 to 178.33; correct from -178.33 to -154.81
Ignoring the letter gives longitudes from 154.81° to 178.33° east instead of west: every feature is mirrored across the prime meridian.
How far points move, automatic detection, doubled signs
1. How far the points move
Dropping a W mirrors a point across the prime meridian. Measure the displacement along the Earth's surface:
G["lat"] = G.prim_lat_dms.map(dms)
d = geod.inv(G.lon.values, G.lat.values, G.lon_naive.values, G.lat.values)[2] / 1000
print(f"displacement: min {d.min():,.0f} km, median {np.median(d):,.0f} km, max {d.max():,.0f} km")
print(f"mirrored Hawaii lands near {G.lat.median():.1f}°N, {G.lon_naive.median():.1f}°E")
displacement: min 327 km, median 4,862 km, max 5,268 km
mirrored Hawaii lands near 20.9°N, 156.6°E
The median feature moves 4,862 km and the farthest 5,268 km; features near 178°W, in the north-western islands, move only 327 km, because they are close to the 180° meridian where the mirror image is nearby. Mirrored Hawaii lands at about 20.9°N, 156.6°E, in the open western Pacific.
2. Detect it automatically
A check against the data's expected extent catches a lost sign at once. Use a bounding box for the state, and a country lookup for a second opinion:
box = (-179.5, 18.0, -154.0, 29.0) # Hawaii, including the north-western islands
inside = lambda lon, lat: (lon >= box[0]) & (lon <= box[2]) & (lat >= box[1]) & (lat <= box[3])
print(f"naive points inside the Hawaii box: {inside(G.lon_naive, G.lat).mean():.0%}; correct points: {inside(G.lon, G.lat).mean():.0%}")
pts = gpd.GeoDataFrame(geometry=gpd.points_from_xy(G.lon_naive, G.lat), crs=4326)
hit = gpd.sjoin_nearest(pts.to_crs(8857), C[["NAME", "geometry"]].to_crs(8857), how="left")
print("nearest countries to the mirrored points:", hit.NAME.value_counts().head(4).to_dict())
naive points inside the Hawaii box: 0%; correct points: 100%
nearest countries to the mirrored points: {'Japan': 7300, 'U.S. Minor Outlying Is.': 33}
A bounding box catches the problem completely: no naive point and every corrected point is inside the box for Hawaii. The country check says the same thing differently: the nearest country to 7,300 of the mirrored points is Japan.
3. Signs written twice
Some sources write -157°51'30"W. Applying both the minus and the letter flips the sign back to positive. Decide on one rule and test it:
def parse_signed(text):
neg = text.strip().startswith("-")
d, m, s, h = re.match(r"-?(\d+)°(\d+)'([\d.]+)\"([NSEW])", text.strip()).groups()
v = int(d) + int(m) / 60 + float(s) / 3600
return -v if (h in "SW" or neg) else v # either marker means negative, never both
def parse_both(text): # the bug: applies the minus and the letter
d, m, s, h = re.match(r"-?(\d+)°(\d+)'([\d.]+)\"([NSEW])", text.strip()).groups()
v = (int(d) + int(m) / 60 + float(s) / 3600) * (-1 if text.strip().startswith("-") else 1)
return -v if h in "SW" else v
for t in ["157°51'30\"W", "-157°51'30\"W", "21°18'25.5\"N"]:
print(f"{t!r:>18}: either-marker rule {parse_signed(t):+.6f}; applying both {parse_both(t):+.6f}")
'157°51\'30"W': either-marker rule -157.858333; applying both -157.858333
'-157°51\'30"W': either-marker rule -157.858333; applying both +157.858333
'21°18\'25.5"N': either-marker rule +21.307083; applying both +21.307083
With the either-marker rule, 157°51'30"W and -157°51'30"W both give -157.858333. Applying the minus and the letter turns the second into +157.858333, back in the eastern hemisphere.
A validation function for converted columns
The data used on this page
The USGS Geographic Names Information System domestic names for Hawaii, with compact DMS fields such as 1575130W, and Natural Earth's 1:10m countries for the country lookup.
import io
import re
import zipfile
from pathlib import Path
import geopandas as gpd
import numpy as np
import pandas as pd
import requests
from pyproj import Geod
Path("data").mkdir(exist_ok=True)
def cached(path, url):
p = Path(path)
if not p.exists():
r = requests.get(url, timeout=600)
r.raise_for_status()
p.write_bytes(r.content)
return p
with zipfile.ZipFile(cached("data/DomesticNames_HI_Text.zip", "https://prd-tnm.s3.amazonaws.com/StagedProducts/GeographicNames/DomesticNames/DomesticNames_HI_Text.zip")) as z:
G = pd.read_csv(io.BytesIO(z.read("Text/DomesticNames_HI.txt")), sep="|", dtype={"prim_lat_dms": str, "prim_long_dms": str}, encoding="utf-8-sig")
G = G[G.state_name == "Hawaii"].dropna(subset=["prim_lat_dms", "prim_long_dms"]).reset_index(drop=True)
C = gpd.read_file(cached("data/ne_10m_admin_0_countries.zip", "https://naciscdn.org/naturalearth/10m/cultural/ne_10m_admin_0_countries.zip"))
geod = Geod(ellps="WGS84")
print(f"{len(G):,} Hawaii features; hemisphere letters: {G.prim_long_dms.str[-1].value_counts().to_dict()} {G.prim_lat_dms.str[-1].value_counts().to_dict()}")
7,333 Hawaii features; hemisphere letters: {'W': 7333} {'N': 7333}
All 7,333 features carry W longitudes and N latitudes; only the longitude sign is at stake here.
Example 1: a validation function for any converted column
Wrap the checks so every conversion is tested before use:
def validate(lat, lon, box, name):
issues = {"latitude out of range": int((lat.abs() > 90).sum()), "longitude out of range": int((lon.abs() > 180).sum()),
"outside expected box": int((~inside(lon, lat)).sum())}
print(f"{name}: {issues}")
validate(G.lat, G.lon_naive, box, "naive")
validate(G.lat, G.lon, box, "correct")
naive: {'latitude out of range': 0, 'longitude out of range': 0, 'outside expected box': 7333}
correct: {'latitude out of range': 0, 'longitude out of range': 0, 'outside expected box': 0}
Range checks pass for both versions, because mirrored coordinates are still valid numbers; only the expected-location check separates them, failing all 7,333 naive points and none of the corrected ones.
Signs against hemisphere letters
Decimal degrees put the hemisphere in the sign: negative latitudes are south of the equator, negative longitudes west of Greenwich. DMS puts it in a letter and keeps the numbers positive. A conversion that keeps the numbers but drops the letter therefore produces valid-looking coordinates, all in the northern and eastern hemisphere, and every western point is mirrored across the prime meridian, every southern one across the equator. For Hawaii, at about 157°W, that is a jump to about 157°E: only 46° of longitude across the 180° meridian, but nearly 5,000 km to the western Pacific.
Because the output is in range, value checks alone do not catch it; a check against the expected location does. A bounding box of where the data should be, a country lookup or simply a map before any analysis reveals the problem immediately. The double negative is the mirror image of the same bug: a source that writes the sign and the letter, read by code that applies both, flips western points back east. Pick one rule, either marker means negative, and test it on a known coordinate.
Points near zero and 180, and southern data
- Points near 0° or 180°: a lost sign moves them only slightly; the bounding-box check may miss them.
- Southern data: the same bug mirrors southern points into the northern hemisphere; check latitude ranges too.
- Mixed sources: if some rows have letters and others signs, normalise to one form before converting.
- Lowercase letters:
wandsoccur; compare case-insensitively.
Internal links
- How to convert degrees, minutes and seconds to decimal degrees in Python — the conversion itself
- Fixing points that plot in the ocean — the related swap of latitude and longitude
- How to find the country of a coordinate in Python — the country check
FAQ
Why are my converted coordinates in the wrong hemisphere?
The hemisphere letter was ignored: W and S must make the decimal value negative.
Why is my US data showing up in China or the Pacific?
Longitudes that should be negative, west, were converted as positive, east, which mirrors them across the prime meridian.
What if the coordinate has a minus sign and a W?
Treat either marker as negative, but never apply both, or the sign flips back.
How do I check converted coordinates?
Test them against a bounding box of the expected area and look at them on a map before any analysis.