Boundary Hysteresis & Debounce Tuning for Geofence Triggers
A vehicle parked twelve metres outside a depot fence is, as far as the containment test is concerned, outside. A vehicle parked twelve metres outside a depot fence with eight metres of RMS positional error is outside on one fix, inside on the next, and outside again on the one after — and a pipeline that emits a trigger on every state change will emit several hundred ENTER/EXIT pairs an hour from a vehicle that has not moved at all. This page expands the accuracy-and-confidence problem introduced in Core Architecture & Latency Constraints, and the failure it addresses is boundary flapping: not a bug in the geometry, not a bug in the index, but the entirely correct output of an exact test applied to an uncertain input.
The reader is a backend engineer whose geofence service is technically correct and operationally unusable — the dispatch team has muted its notifications, the billing team has a manual filter, and someone has proposed “just add a delay”. The fix is more precise than a delay. It has two independent parts that solve two different halves of the problem: a hysteresis band, which makes the entry and exit boundaries different curves so a position hovering at the edge cannot satisfy both; and a confirmation window, which requires a transition to be supported by several consecutive fixes before it is committed. Neither alone is sufficient, and the parameters of each are derived from measurable properties of the fleet rather than chosen by taste.
The Flapping Mechanism and Its Latency Profile
Flapping is a threshold-crossing problem in the presence of noise. Model the reported position as the true position plus an error term, and consider a device whose true distance from the boundary is $d$ (positive outside, negative inside) with positional error of standard deviation . A single fix reports “inside” with probability , and each fix is close enough to independent — GPS multipath decorrelates over a few seconds — that a sequence of fixes at distance $d$ behaves like repeated Bernoulli trials. At that is a fair coin: half the fixes land inside, half outside, and the expected number of state changes over $n$ fixes is . At a 1 Hz update rate, that is 1,800 transitions an hour from one stationary device.
The distribution matters more than the average, because the flapping rate collapses quickly once the device is more than a couple of standard deviations from the boundary. The table below is measured from a fleet of 4,000 delivery vehicles at 1 Hz with a median horizontal accuracy of 8 m, bucketed by each device’s mean distance from the nearest fence edge:
| True distance from edge | Fixes landing on the wrong side | Transitions per device-hour | Notes |
|---|---|---|---|
| 0 m (on the edge) | 50% | 1,782 | Pathological; every parked-at-the-gate vehicle |
| 5 m | 27% | 1,412 | Common for kerbside and forecourt parking |
| 10 m | 11% | 702 | Still unusable for billing or dispatch |
| 20 m | 0.6% | 41 | Tolerable, but not for a compliance fence |
| 40 m | 0.0% | 0 | Beyond the noise floor entirely |
The cost of the fix is latency, and it is worth being honest about its size before choosing parameters. Both mechanisms delay the committed transition: hysteresis delays it in space (the device must travel the extra band width before the trigger fires) and confirmation delays it in time (the trigger waits for corroborating fixes). For a vehicle at 30 km/h — 8.3 m/s — a 25 m entry band adds 3.0 s, and a 3-of-5 confirmation window at 1 Hz adds up to 4 s more in the worst case. That is a seven-second envelope on a trigger the SLA might have budgeted 50 ms for, which is why the confirmation delay must be measured and reported separately from pipeline latency rather than folded into the same histogram.
| Configuration | Transitions/device-hour | P50 added latency | P95 added latency | False ENTER rate |
|---|---|---|---|---|
| Exact test, no damping | 214 | 0 ms | 0 ms | 31.0% |
| Hysteresis band only (25 m) | 37 | 2,900 ms | 4,100 ms | 4.4% |
| Confirmation only (3-of-5) | 21 | 2,000 ms | 4,000 ms | 2.6% |
| Hysteresis + confirmation | 2 | 3,100 ms | 5,200 ms | 0.2% |
| Hysteresis + confirmation + speed gate | 2 | 3,100 ms | 5,200 ms | 0.1% |
The combination is not merely additive: hysteresis removes the sustained flapping of a device sitting at the edge, and confirmation removes the isolated excursion of a device whose single fix jumped. Applied alone, each leaves the other’s failure mode intact, which is why the two-mechanism row is two orders of magnitude better than the untreated row while either mechanism alone is one.
Sizing the Band from the Error Distribution
The band half-width $h$ is not a preference; it is a quantile of the measured positional-error distribution. If the goal is that a stationary device at the nominal boundary produces a false crossing on fewer than a fraction $p$ of its fixes, then
where is the per-fix horizontal error standard deviation for the accuracy tier in question and is the inverse normal CDF. For m and , m, which is where the 25 m band in the tables above comes from. The derivation is developed with real error histograms — which are not Gaussian in the tail — in choosing hysteresis buffer widths from GPS error distributions.
Three consequences follow immediately, and each one catches teams out.
The first is that is not a constant. A device with a clear sky view and 12 satellites reports 3–5 m; the same device in an urban canyon reports 15–30 m; the same device on a Wi-Fi or cell-tower fallback reports 50–2,000 m, as catalogued in fallback routing for GPS dropouts. A fixed band sized for the good case flaps in the bad case, and one sized for the bad case is useless — a 200 m band around a 150 m fence has no interior. The band must therefore be per-fix, computed from the accuracy the fix itself reports, and clamped to a fraction of the fence’s own scale so it can never swallow the geometry.
The second is that the band is asymmetric in consequence even when it is symmetric in metres. Widening the ENTER threshold inward delays entry triggers and makes the effective fence smaller; widening the EXIT threshold outward delays exit triggers and makes the effective fence larger. For a billing fence you usually want both to err against charging: enter late, exit early. For a safety fence you want the opposite: enter early, exit late. The same band width with the two thresholds swapped encodes opposite business policies, and the choice belongs in configuration next to the fence, not in the evaluator.
The third is that a band interacts with fence geometry. Offsetting a polygon inward by 25 m is a buffer(-25) in a metric projection, and for a narrow fence — an alley, a bridge deck, a loading bay — that operation can produce an empty geometry or split the polygon into disconnected pieces. The evaluator must handle both outcomes explicitly rather than propagating a null geometry into the index. Where the fence is too narrow to carry a band, the correct behaviour is usually to fall back to confirmation-only damping and flag the fence as accuracy-limited, because the alternative is a fence that silently never fires.
Confirmation Windows and the State Machine
Hysteresis fixes the sustained case. The isolated case — one fix that jumps 300 m and returns — needs a different mechanism, because a single excursion can pass straight through both thresholds. The standard answer is an N-of-M confirmation window: a candidate transition is committed only when $N$ of the last $M$ fixes agree with it.
| Window | Committed after | Suppresses | Added latency at 1 Hz | Residual false rate |
|---|---|---|---|---|
| 1-of-1 (none) | first fix | nothing | 0 s | 31.0% |
| 2-of-3 | 2 agreeing fixes | single-fix spikes | 1–2 s | 3.8% |
| 3-of-5 | 3 agreeing fixes | two-fix bursts | 2–4 s | 2.6% |
| 4-of-7 | 4 agreeing fixes | short multipath episodes | 3–6 s | 1.9% |
| 5-of-9 | 5 agreeing fixes | sustained urban-canyon noise | 4–8 s | 1.7% |
Returns diminish sharply past 3-of-5, because beyond that the residual errors are no longer isolated spikes but genuinely ambiguous positions that no amount of voting resolves. The window should therefore be sized to the duration of the error episodes the fleet actually experiences, not pushed upward in the hope of eliminating the last percent. The state machine that implements the window, including the subtlety of what happens when a device goes silent mid-window, is developed in debouncing boundary flapping with state machines.
A compact implementation keeps one small record per (device, fence) pair and never allocates on the hot path:
from __future__ import annotations
from dataclasses import dataclass, field
from collections import deque
@dataclass(slots=True)
class FenceState:
"""Committed state plus the rolling vote for a pending transition."""
inside: bool = False
votes: deque[bool] = field(default_factory=lambda: deque(maxlen=5))
pending_since_ns: int | None = None
def observe(
st: FenceState,
raw_inside_enter: bool, # tested against the INWARD-offset ring
raw_inside_exit: bool, # tested against the OUTWARD-offset ring
t_ns: int,
need: int = 3,
) -> str | None:
# Hysteresis: which threshold applies depends on the committed state.
candidate = raw_inside_enter if not st.inside else raw_inside_exit
if candidate == st.inside:
st.votes.clear() # back in agreement; abandon any pending flip
st.pending_since_ns = None
return None
st.votes.append(candidate)
if st.pending_since_ns is None:
st.pending_since_ns = t_ns
if sum(st.votes) if candidate else sum(not v for v in st.votes) < need:
return None
st.inside = candidate
st.votes.clear()
st.pending_since_ns = None
return "ENTER" if candidate else "EXIT"
Two details in that function are load-bearing. The first is that the choice of ring depends on the committed state: while outside, the device is tested against the inward-offset ring, and while inside, against the outward-offset ring. That single line is the whole of hysteresis, and getting it backwards produces a band that makes flapping worse rather than better. The second is that a fix agreeing with the committed state clears the vote deque rather than merely failing to add to it — without that, a device alternating one-for-one would accumulate a majority eventually and commit a transition it never made.
Memory Footprint and Per-Pair State
State is per (device, fence) pair, not per device, and that product is what determines whether the design fits in memory. A fleet of 200,000 devices each near an average of three fences carries 600,000 live pairs. With slots=True the FenceState above is 56 bytes plus the deque; a deque(maxlen=5) of booleans costs roughly 200 bytes once its block is allocated, so the honest figure is about 260 bytes per pair, or 156 MB at 600k pairs — small, but only because the deque is bounded and the dataclass is slotted. The same structure written with a plain class and an unbounded list measures 1.1 KB per pair and 660 MB, which is the difference between a design that fits alongside the index and one that competes with it.
The bigger risk is pair leakage. A pair is created when a device first comes near a fence and must be destroyed when it leaves the neighbourhood, or the map grows monotonically with fleet-days rather than with fleet size. The eviction rule that works is to drop any pair whose committed state is outside and whose last observation is older than the confirmation window plus a margin — an inside pair must never be evicted, because losing it converts a future genuine EXIT into silence. Under the same discipline as the vertex buffers in memory-constrained spatial processing, the vote deques are best drawn from a pool rather than allocated per pair, since their churn is otherwise the largest single source of GC pressure in an otherwise allocation-free evaluator.
Replacing the boolean deque with a bitmask in a single integer removes the deque entirely: five votes fit in five bits, the N-of-M test becomes a population count, and the per-pair cost falls to 32 bytes with no allocation at all. That is the form to reach for above roughly a million pairs; below it, the deque is clearer and the difference does not show up in a profile.
Async Boundaries and Ordering Requirements
Debouncing is a stateful, order-sensitive operation, which makes it one of the few places in a geofence pipeline that cannot be freely parallelised. Two fixes from the same device must be processed in event-time order against the same state record, or the vote window sees them backwards and can commit a transition the true sequence never contained. This is the same constraint that drives per-device partitioning in event-time ordering and clock skew, and it composes with it directly: the reorder buffer must sit upstream of the debouncer, so the debouncer only ever sees an ordered stream.
That ordering requirement sets the partition key for the whole downstream stage. Partitioning by device_id guarantees per-device order and lets the debounce state live entirely inside one worker with no locking; partitioning by fence_id or by geographic cell does not, and forces either a shared state store or a lock per pair. The throughput cost of the wrong choice is severe — a shared Redis-backed state record turns a 40 ns dictionary lookup into a 200 µs round trip, at which point debouncing rather than geometry becomes the pipeline’s bottleneck.
The second async concern is the timer. A pending transition that never receives its confirming fixes — because the device went silent — must eventually resolve, and resolving it requires a timer rather than a message. Naively that is one asyncio.call_later per pending transition, which at fleet scale creates and cancels timers faster than the event loop can retire them. The timer-wheel pattern that avoids this, and the same problem in its more acute form for dwell triggers, is covered in dwell-time triggers without timer storms.
Operational Runbook
- Measure the flapping rate before changing anything. Export
transitions_per_device_houras a histogram, not an average — flapping is concentrated in a small population of devices parked near edges, and a fleet average of 4 can hide a P99 of 200. If the P99 is under about 5, the pipeline does not have a flapping problem and hysteresis will only add latency. - Attribute the flapping to a cause. Join the flapping devices against their reported accuracy and their speed. High flapping with poor accuracy is a hysteresis problem; high flapping with good accuracy and zero speed is a fence-geometry problem (the boundary runs through a car park); high flapping with implausible speed is a fix-quality problem and belongs to speed gating.
- Set the band from the accuracy P99, not the median. Compute per accuracy tier from a week of fixes and set for the tier. Verify that
buffer(-h)on every fence returns a non-empty, single-part geometry; list the fences where it does not and mark them accuracy-limited. - Choose the confirmation window from episode duration. Histogram the length of runs of wrong-side fixes for stationary devices. Set $M$ to the P95 run length plus one and $N$ to . Anything longer is buying tenths of a percent for whole seconds.
- Re-measure both the flapping rate and the added latency. The added-latency histogram must be reported separately from pipeline latency; folding a 3 s confirmation delay into the same series as a 12 ms index lookup destroys the ability to see either. Alert on the confirmation delay’s P99 rather than on its mean.
- Audit the suppressed transitions weekly. Every suppressed candidate should be logged with its votes and its distance from the boundary. A rising suppression rate against a stable fleet is the early signal that either accuracy has degraded or a fence has been redrawn through a parking area.
Architectural Guidance
Use hysteresis alone when the dominant failure is a device dwelling at a boundary and the trigger is not time-critical — asset-yard occupancy, depot arrival, congestion-zone billing. It is cheap, stateless per fix beyond the committed flag, and it composes with any index.
Use confirmation alone when the fence is too small to carry a band, or when fence geometry is authored by users who will draw shapes narrower than the error radius. The cost is entirely in latency and a few hundred bytes per pair, and it degrades gracefully: a device with clean fixes commits on the minimum number of votes.
Use both — the configuration this page recommends as the default — for any fence whose triggers drive money, dispatch, or compliance. The two-orders-of-magnitude reduction in the table above is not available from either mechanism alone, and the incremental cost over the better single mechanism is about a second of latency.
Use neither only when the consumer of the trigger stream does its own debouncing, which is more common than it sounds: a dispatch system that already requires a human confirmation, or a billing pipeline that aggregates to the hour, is indifferent to flapping and would rather have the raw transitions. In that case, emit undamped triggers on a separate topic and let the damped stream be the default, rather than forcing every consumer onto the same policy.
FAQ
Can I just add a fixed delay before emitting a trigger?
No, and the reason is instructive. A delay defers the trigger but does not change how many are generated: a device flapping at 1 Hz still produces one transition per second, each one delayed by the same amount. What a delay can do is give a suppression window in which a reversal cancels the pending trigger, which is a degenerate 2-of-2 confirmation window — useful, much weaker than a proper N-of-M, and no substitute for hysteresis in the sustained-dwell case.
How do hysteresis and reported accuracy interact when a device switches to a Wi-Fi fix?
The band should be recomputed per fix from that fix’s reported accuracy, so a device dropping from a 5 m GPS fix to a 400 m Wi-Fi fix widens its band by two orders of magnitude and effectively stops producing transitions until the fix quality recovers. That is the correct behaviour — a 400 m fix genuinely cannot resolve a 150 m fence — but it must be visible, so emit a degraded-confidence marker rather than silently going quiet, exactly as the degradation ladder in graceful degradation strategies for location APIs prescribes.
Does the band have to be a geometric offset, or can I use distance-to-boundary?
Both work, and the choice is a performance trade. A geometric offset pre-computes two rings per fence at authoring time, so the hot path is two ordinary containment tests with no distance computation — the fastest option, at the cost of storing two extra geometries and rebuilding them when the fence changes. Computing signed distance to the boundary per fix is more flexible, since the band width can then vary per fix without touching stored geometry, but a distance-to-polygon calculation is several times the cost of a containment test. Above roughly 20k evaluations/sec the pre-computed rings win decisively; below it, per-fix distance is simpler and lets accuracy-adaptive bands fall out for free.
Related
- Core Architecture & Latency Constraints — the parent section, where the confirmation delay has to be reconciled with the pipeline’s latency budget.
- Choosing Hysteresis Buffer Widths from GPS Error Distributions — deriving the band from real, non-Gaussian error histograms.
- Debouncing Boundary Flapping with State Machines — the confirmation window as an explicit state machine, including silent-device resolution.
- Dwell-Time Triggers Without Timer Storms — the timer-wheel pattern that keeps pending transitions off the event loop.
- Event-Time Ordering & Clock Skew — why the reorder buffer must sit upstream of the debouncer.