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

  1. Calibrate the ranging error for your receiver population against a known reference, once.
  2. Convert each fix’s HDOP into a standard deviation, adjusted by satellite count and fix type.
  3. Feed the variance into the measurement covariance of the smoother rather than into a filter predicate.
  4. Keep a hard gate only for the implausible, which is a much higher threshold than the usual one.

Production-ready Python implementation

PYTHON
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

PYTHON
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_m is 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.

Back to GPS Precision & Error Handling