10 min read 5 sections

Wi-Fi and Cell-Tower Fallback Accuracy Tiers

When GNSS is unavailable, the platform location provider does not stop answering — it answers from Wi-Fi access points, from cell towers, or from a cached fix, and it returns a position with the same shape as a satellite fix. Nothing in the payload announces that the answer just got two orders of magnitude worse. A pipeline that treats every fix identically will happily test a cell-tower position with 780 m of error against a 120 m depot fence and emit a confident trigger. This page sits under fallback routing for GPS dropouts within Core Architecture & Latency Constraints, and it defines the tiers and the admission rule that keeps a degraded fix from producing an undegraded trigger.

Concept and specification

The provider tier determines the error distribution, and the reported accuracy is a poor proxy for it — receivers report a confidence radius under their own model, and those models are calibrated differently per provider. Measured against surveyed references across a mixed fleet:

Tier Typical source Median error P95 error Reported accuracy Reported/actual
RTK / dual-frequency Corrected GNSS 0.4 m 1.1 m 1 m 0.9×
GNSS, open sky 10+ satellites 4.2 m 11 m 5 m 1.2×
GNSS, urban canyon 5–8 satellites, multipath 18 m 74 m 12 m 0.7×
Wi-Fi, dense AP Indoor or city centre 32 m 140 m 45 m 1.4×
Wi-Fi, sparse AP Suburban or industrial 190 m 900 m 60 m 0.3×
Cell tower LTE/5G triangulation 780 m 3,400 m 500 m 0.6×
Cached / last known Provider cache unbounded unbounded previous value
Median error, P95 error, Reported accuracy — 7 options Each panel scales on its own, so Median error, P95 error, Reported accuracy are compared across 7 options without sharing an axis they do not share a unit with. Median error, P95 error, Reported accuracy — 7 options Tier — the table above, drawn to scale RTK / dual-frequency GNSS, open sky GNSS, urban canyon Wi-Fi, dense AP Wi-Fi, sparse AP Cell tower Cached / last known Median error 0.4 m 4.2 m 18 m 32 m 190 m 780 m P95 error 1.1 m 11 m 74 m 140 m 900 m 3,400 m Reported accuracy 1 m 5 m 12 m 45 m 60 m 500 m
Median error, P95 error and 1 more for RTK / dual-frequency, GNSS, open sky, GNSS, urban canyon and 4 more, drawn from the measurements in the table above. Each panel carries its own scale, so the shape of the gap is comparable even where the units are not.

The reported/actual column is the finding that matters. In the urban-canyon and sparse-Wi-Fi tiers the receiver understates its own error by factors of 1.4× and 3.3× respectively — precisely the tiers where a geofence decision is most likely to be wrong. A design that sizes its hysteresis band from reported accuracy alone is therefore under-provisioned exactly when it matters, which is why the band must be sized from measured error per tier and selected by tier at runtime.

The admission rule follows directly. A fix may be tested against a fence only when the fence is large relative to the fix’s uncertainty:

where is the tier’s P95 error and is the fence’s inradius. Below that, the fix cannot resolve the fence and the honest output is “unknown” rather than a coin flip dressed as a trigger.

Step-by-step implementation

1. Capture the provider, not just the coordinates. Both mobile platforms expose it — Android through the provider name and a mock-location flag, iOS indirectly through accuracy and source hints — and where the SDK does not surface it, satellite count and accuracy together classify most fixes reliably.

2. Classify into tiers at ingest and stamp the tier on the event. Everything downstream branches on tier, so classifying once at the edge is cheaper and more consistent than re-deriving it.

python
from __future__ import annotations
from enum import IntEnum

class Tier(IntEnum):
    RTK = 0
    GNSS_OPEN = 1
    GNSS_URBAN = 2
    WIFI_DENSE = 3
    WIFI_SPARSE = 4
    CELL = 5
    CACHED = 6

# P95 error in metres, measured against surveyed references — NOT reported.
TIER_P95_M = {Tier.RTK: 1.1, Tier.GNSS_OPEN: 11.0, Tier.GNSS_URBAN: 74.0,
              Tier.WIFI_DENSE: 140.0, Tier.WIFI_SPARSE: 900.0,
              Tier.CELL: 3400.0, Tier.CACHED: float("inf")}

def classify(provider: str, sats: int | None, acc_m: float, age_s: float) -> Tier:
    if age_s > 30.0:
        return Tier.CACHED
    p = provider.lower()
    if "gps" in p or "gnss" in p:
        if acc_m <= 2.0:
            return Tier.RTK
        return Tier.GNSS_OPEN if (sats or 0) >= 9 and acc_m <= 8.0 else Tier.GNSS_URBAN
    if "wifi" in p or "wlan" in p:
        return Tier.WIFI_DENSE if acc_m <= 60.0 else Tier.WIFI_SPARSE
    if "cell" in p or "network" in p:
        return Tier.CELL
    return Tier.CACHED

def may_test(tier: Tier, fence_inradius_m: float, alpha: float = 0.5) -> bool:
    """A fix may decide a fence only if the fence is big enough to resolve."""
    return TIER_P95_M[tier] <= alpha * fence_inradius_m

3. Filter the candidate set by tier before the exact test. The admission rule is a cheap numeric comparison against a value stored on each fence, so applying it during candidate filtering removes work rather than adding it — a cell-tower fix in a city with 4,000 small fences drops to the handful of large ones it can actually resolve.

4. Emit UNKNOWN rather than nothing. A fence the fix cannot resolve is not “outside”; suppressing the evaluation silently makes a device appear to have left. Emit an explicit unresolved marker so the state machine can hold its committed state rather than infer a departure.

5. Age out cached fixes aggressively. A cached fix carries a fresh timestamp and stale content, which is the worst combination in the whole system — it defeats the clock-drift estimator, the speed gate and the segment join simultaneously. Treat any fix whose provider-reported age exceeds a few tens of seconds as CACHED and refuse to test it against anything.

6. Publish the tier with the trigger. Downstream consumers need to know a trigger came from a 780 m fix. The confidence field defined in graceful degradation strategies for location APIs is where it belongs.

Benchmark and verification

Measured over a week on a fleet with 22% of fixes from non-GNSS providers:

Policy False triggers Missed triggers Fences evaluated per fix Unknown emissions
Treat all fixes equally 6.10% 0.20% 3.9 0.0%
Reject non-GNSS entirely 0.40% 4.90% 3.1 0.0%
Tier + admission rule (α = 0.5) 0.37% 0.31% 2.4 3.8%
Tier + admission + per-tier band 0.31% 0.24% 2.4 3.8%
False triggers, Missed triggers, Fences evaluated per fix — 4 options Each panel scales on its own, so False triggers, Missed triggers, Fences evaluated per fix are compared across 4 options without sharing an axis they do not share a unit with. False triggers, Missed triggers, Fences evaluated per fix — 4 options Policy — the table above, drawn to scale Treat all fixes equally Reject non-GNSS entirely Tier + admission rule (α = 0.5) Tier + admission + per-tier band False triggers 6.10% 0.40% 0.37% 0.31% Missed triggers 0.20% 4.90% 0.31% 0.24% Fences evaluated per fix 3.9 3.1 2.4 2.4
False triggers, Missed triggers and 1 more for Treat all fixes equally, Reject non-GNSS entirely, Tier + admission rule (α = 0.5) and 1 more, drawn from the measurements in the table above. Each panel carries its own scale, so the shape of the gap is comparable even where the units are not.

Rejecting non-GNSS outright — the fix teams reach for first — trades one error for another: false triggers fall 15× and missed triggers rise 24×, because a 32 m Wi-Fi fix is perfectly capable of deciding a 2 km congestion zone. The admission rule keeps both under 0.4% by asking the only question that matters, which is not “how good is this fix” but “is this fix good enough for this fence”. The candidate column shows the rule pays for itself: evaluating 2.4 fences per fix instead of 3.9 is a 38% reduction in exact-containment work.

The unknown-emission rate is the honest cost, and it should be reported rather than hidden. 3.8% of evaluations produce no decision, concentrated in indoor and rural populations, and the right response is either better fences or better hardware for those populations — not a lower .

Verify by joining triggers against tier and checking that false-trigger rate is roughly flat across tiers. A rate that rises with tier index means is too permissive; a rate near zero for the poor tiers alongside a high unknown rate means it is too strict.

Failure modes and edge cases

Failure mode Signature Mitigation
Trusting reported accuracy Urban-canyon and sparse-Wi-Fi fixes under-provisioned Use measured per-tier P95, not the reported value
Cached fix treated as fresh Speed gate and segment join both corrupted Classify by provider-reported age; refuse tests on CACHED
Unknown emitted as outside Devices appear to leave fences while indoors Emit an explicit unresolved marker; hold committed state
Fence inradius not stored Admission rule needs a geometry call per evaluation Precompute inradius at fence authoring time
Tier boundaries tuned on one city Classification wrong for a different AP density Re-fit tier thresholds per market
Mock locations admitted Confident triggers from a spoofed provider Treat the mock flag as its own tier and refuse all tests
Failure mode at a glance: Signature, Mitigation A row per failure mode, a column per option, so a single axis can be compared across options in one sweep. Failure mode at a glance: Signature, Mitigation the same trade-offs, read across instead of down Signature Mitigation Trusting reported accuracy Urban-canyon and sparse-Wi-Fi fixes under-provisioned Use measured per-tier P95, not the reported value Cached fix treated as fresh Speed gate and segment join both corrupted Classify by provider-reported age; refuse tests on CACHED Unknown emitted as outside Devices appear to leave fences while indoors Emit an explicit unresolved marker; hold committed state Fence inradius not stored Admission rule needs a geometry call per evaluation Precompute inradius at fence authoring time Tier boundaries tuned on one city Classification wrong for a different AP density Re-fit tier thresholds per market Mock locations admitted Confident triggers from a spoofed provider Treat the mock flag as its own tier and refuse all tests
The failure mode table read across rather than down — each row is one axis of the decision, colour-keyed so a single trade-off can be followed across Signature, Mitigation.

The fourth row is a performance trap rather than a correctness one. Computing a polygon’s inradius is expensive — it is a maximum-inscribed-circle problem — and doing it per evaluation would cost more than the containment test it is guarding. Compute it once when the fence is created, store it beside the geometry, and recompute on edit; a cheap lower bound such as half the minimum bounding-box side is adequate when the exact value is unavailable, and errs toward refusing rather than admitting.

The last row deserves emphasis because it is the one with an adversary. A device reporting mock locations is not degraded, it is lying, and the correct response is not a wider band but a refusal to derive any trigger with commercial consequence from it. The gate belongs at ingest, alongside the speed gate, with its own rejection stream.