Using HDOP and satellite count to weight GPS fixes
Most pipelines use HDOP as a gate — drop everything above 4 — and throw away a great deal of usable data in the process. HDOP is a multiplier on positional uncertainty, so the better use is as a weight: convert it into a per-fix standard deviation, feed that into the measurement covariance of a smoother, and let the filter decide how much to believe each fix rather than deciding in advance.
Why this happens
Horizontal dilution of precision is a geometric quantity, not an error. It says that whatever the ranging error is, the horizontal position error will be approximately HDOP times it. A receiver with a 2 m ranging error and an HDOP of 5 has roughly 10 m of horizontal uncertainty; the same HDOP with a better receiver means less. That is why an absolute HDOP threshold travels badly between fleets.
Treating it as a gate also discards information twice over. A fix at HDOP 5 is worse than one at HDOP 1 and much better than nothing, and a smoother that is told the uncertainty of each measurement will weight them correctly on its own. The threshold approach forces a binary decision that the filter would have made better, and it is why urban traces filtered on HDOP develop gaps precisely in the places where positions were hardest to get. The wider error taxonomy is in GPS precision and error handling.
Core pipeline
- Calibrate the ranging error for your receiver population against a known reference, once.
- Convert each fix’s HDOP into a standard deviation, adjusted by satellite count and fix type.
- Feed the variance into the measurement covariance of the smoother rather than into a filter predicate.
- Keep a hard gate only for the implausible, which is a much higher threshold than the usual one.
Production-ready Python implementation
import numpy as np
import pandas as pd
def fix_uncertainty(
df: pd.DataFrame,
hdop_col: str = "hdop",
sats_col: str | None = "sats",
fixtype_col: str | None = "fix_type",
sigma_range_m: float = 2.2,
hdop_reject: float = 20.0,
) -> pd.DataFrame:
"""
Convert receiver quality fields into a per-fix positional sigma.
Parameters
----------
sigma_range_m : float
The receiver's ranging error, in metres. CALIBRATE THIS: it is the
one number that makes HDOP comparable across fleets, and the default
is a mid-range consumer module under open sky.
hdop_reject : float
A hard gate for the implausible only. HDOP above 20 usually means
the receiver reported a fix it should not have; below that, weight
rather than drop.
Returns
-------
pd.DataFrame
Input plus 'sigma_m', 'variance_m2' and 'usable'.
Raises
------
ValueError
On a missing HDOP column or an empty frame.
"""
if hdop_col not in df.columns:
raise ValueError(f"Missing quality column: {hdop_col}")
if df.empty:
raise ValueError("Input DataFrame is empty.")
out = df.copy()
hdop = out[hdop_col].to_numpy(dtype=float)
# A missing or zero HDOP is not a perfect fix — it is a receiver that did
# not report one. Treat it as mediocre rather than excellent.
hdop = np.where(np.isfinite(hdop) & (hdop > 0), hdop, 3.0)
sigma = sigma_range_m * hdop
# ── satellite count adjustment ────────────────────────────────────
# Fewer satellites means a weaker solution than HDOP alone conveys,
# because the geometry is not just poor but under-determined.
if sats_col and sats_col in out:
n = out[sats_col].to_numpy(dtype=float)
penalty = np.where(n >= 8, 1.0, np.where(n >= 6, 1.25, np.where(n >= 4, 1.8, 3.0)))
sigma = sigma * np.nan_to_num(penalty, nan=1.5)
# ── fix type ──────────────────────────────────────────────────────
# A 2D fix has no vertical constraint, which degrades the horizontal
# solution as well; a differential fix is substantially better.
if fixtype_col and fixtype_col in out:
ft = out[fixtype_col].astype(str).str.lower()
sigma = sigma * np.select(
[ft.str.contains("dgps|rtk"), ft.str.contains("2d")],
[0.35, 1.6],
default=1.0,
)
out["sigma_m"] = sigma
out["variance_m2"] = sigma ** 2
out["usable"] = hdop <= hdop_reject
return out
def measurement_covariance(sigma_m: np.ndarray) -> np.ndarray:
"""
Per-fix 2x2 measurement covariance for a Kalman filter.
Passing a per-fix R is what makes a filter downweight a poor fix
automatically. A constant R forces the same trust in every measurement,
which is precisely the information HDOP was reporting.
"""
s = np.asarray(sigma_m, dtype=float)
if np.any(~np.isfinite(s)) or np.any(s <= 0):
raise ValueError("sigma_m must be finite and positive for every fix.")
R = np.zeros((len(s), 2, 2))
R[:, 0, 0] = s ** 2
R[:, 1, 1] = s ** 2
return R
Validation block
def validate_uncertainty(out: pd.DataFrame, residuals_m: np.ndarray | None = None) -> dict:
"""
Check the sigma is calibrated, not just plausible.
residuals_m : distance from each fix to a trusted reference position,
available from an instrumented run. Without it, only the shape can be
checked; with it, the scale can.
"""
s = out["sigma_m"]
assert (s > 0).all() and np.isfinite(s).all(), "non-positive or non-finite sigma"
# 1. Range sanity. A consumer receiver under open sky is 2-5 m; anything
# claiming sub-metre without RTK is mis-parameterised.
assert s.min() > 0.8, f"minimum sigma {s.min():.2f} m implies RTK — check sigma_range_m"
assert s.median() < 40, f"median sigma {s.median():.0f} m — the HDOP column may be scaled"
out_stats = {"median_sigma_m": float(s.median()),
"p95_sigma_m": float(s.quantile(0.95)),
"rejected_frac": float((~out["usable"]).mean())}
# 2. Calibration against truth: about 68% of residuals should fall inside
# one sigma if the scale is right. Far below means over-confident.
if residuals_m is not None:
within = float((residuals_m <= s.to_numpy()).mean())
assert 0.55 < within < 0.82, (
f"{within:.0%} of residuals within 1σ (expect ~68%) — "
f"{'over-confident' if within < 0.55 else 'over-cautious'}; "
"recalibrate sigma_range_m"
)
out_stats["within_1sigma"] = within
# 3. The hard gate should be rare. A high rejection rate means the gate
# is doing the weighting's job.
assert out_stats["rejected_frac"] < 0.02, (
f"{out_stats['rejected_frac']:.1%} hard-rejected — lower the reliance "
"on the gate and let the weight handle marginal fixes"
)
return out_stats
Common mistakes and gotchas
-
Treating HDOP as an error in metres. It is a unitless multiplier on the ranging error. Multiplying by a calibrated
sigma_range_mis what turns it into metres. -
Treating a missing or zero HDOP as excellent. It means the receiver did not report one. Substitute a mediocre value, not a perfect one.
-
A hard gate at 4. It discards a third of urban fixes and creates gaps that interpolation then fills with straight lines through buildings.
-
Ignoring satellite count. HDOP with four satellites is a different situation from HDOP with twelve, even at the same value, because the solution is barely over-determined.
-
A constant measurement covariance. Passing a fixed R to a Kalman filter discards exactly the information HDOP was providing — see interpolating missing GPS points with Kalman filters.
-
Never calibrating. Without one instrumented run against a reference, the sigma has the right shape and an unknown scale, and the filter is either over-confident or over-cautious throughout.
FAQ
Is HDOP or PDOP the right field?
HDOP for horizontal work, which is nearly all movement analysis. PDOP includes the vertical component and is dominated by it, so a receiver in an urban canyon can show a poor PDOP and a perfectly acceptable HDOP. If only PDOP is available, it is a usable but pessimistic proxy.
What if the receiver reports an accuracy estimate directly?
Use it — many modern chipsets and mobile APIs report a horizontal accuracy in metres that already incorporates DOP, satellite count and their own error model, and it is generally better calibrated than anything derived externally. Validate it the same way, against residuals from a reference run, because vendors differ in whether the figure is one sigma or a 68% radius.
Does weighting help if I am not running a Kalman filter?
Yes, in two places. A weighted least-squares fit of a smoothing polynomial accepts per-point weights directly. And any downstream threshold — stay-point radius, route deviation, harsh-braking — can be made per-fix rather than global by scaling it with sigma, which is usually a small change and a large improvement in urban data.
Related
- GPS Precision & Error Handling — the parent reference, including the DOP geometry diagram.
- Handling GPS Drift in Raw Trajectory Logs — the cleaning pipeline this weight feeds.
- Kalman Filter vs Savitzky-Golay for Real-Time vs Batch GPS Smoothing — which smoother can use a per-fix covariance.
- Interpolating Missing GPS Points with Kalman Filters — where the measurement covariance is consumed.
- Detecting and Classifying Gaps in Trajectory Streams — the device-fault signature that a quality trend reveals.