You stop a geofence from crying wolf with three filters stacked in order: drop any GPS fix whose reported accuracy is worse than 15 m, treat a 5 m band on either side of the zone edge as undecided rather than as a breach, and only raise "entered" or "left" once the new state has held for 10 seconds. Below is a small Python module that does exactly that, plus a test file you can run.
Why does a plain point-in-polygon alert fire too often on a golf course?
The textbook approach is simple: draw the green as a polygon, run point-in-polygon on every GPS fix, alert when the answer flips to "inside." On a desk it works. On a course it fails within an hour.
The cart path on most holes runs right beside the green, often within a few meters of the fringe. GPS fixes from a cart on that path do not sit still. Under tree cover the receiver loses satellites; next to the clubhouse or a maintenance shed the signal bounces off walls before it arrives. GPS.gov is candid that real-world accuracy depends on factors outside the government's control, including atmospheric conditions, sky blockage and the quality of the receiver itself. The result is a position that wanders by a few meters from one second to the next, which is enough to cross a polygon edge that is a few meters away.
So the naive alert fires, clears, fires again. The rider learns to swipe it away without reading. The supervisor opens a breach log full of carts that never left the path, and stops opening it. That is worse than having no alert at all.
What are the three filters, and what does each one remove?
| Filter | Noise it removes | Setting in the code |
|---|---|---|
| Accuracy gate | Wild fixes with poor reported accuracy (tree cover, buildings) | max_accuracy_m = 15 |
| Grey band | Edge jitter from a cart on a path that runs beside the zone | buffer_m = 5 |
| Dwell time | Brief excursions and single bad fixes that got past the gate | dwell_s = 10 |
Each filter is cheap and none is enough alone. Together they turn a noisy yes/no into a small state machine that only changes its mind when the evidence has held for a while.
How is the module put together?
Save this as geofence.py:
import math
from dataclasses import dataclass
EARTH_R = 6_371_000 # metres
def to_xy(lat, lon, lat0, lon0):
"""Project lat/lon to metres around a reference point (fine for one property)."""
x = math.radians(lon - lon0) * EARTH_R * math.cos(math.radians(lat0))
y = math.radians(lat - lat0) * EARTH_R
return x, y
def inside(pt, poly):
"""Ray casting: is point pt inside polygon poly (list of (x, y))?"""
x, y = pt
hit = False
for (x1, y1), (x2, y2) in zip(poly, poly[1:] + poly[:1]):
if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1:
hit = not hit
return hit
def edge_distance(pt, poly):
"""Shortest distance in metres from pt to the polygon's edge."""
px, py = pt
best = float("inf")
for (x1, y1), (x2, y2) in zip(poly, poly[1:] + poly[:1]):
dx, dy = x2 - x1, y2 - y1
t = max(0, min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy)))
best = min(best, math.hypot(px - (x1 + t * dx), py - (y1 + t * dy)))
return best
@dataclass
class Fix:
t: float # seconds
lat: float
lon: float
accuracy: float # reported horizontal accuracy, metres
class NoGoZone:
def __init__(self, name, latlon_poly, buffer_m=5, dwell_s=10, max_accuracy_m=15):
self.name = name
self.lat0, self.lon0 = latlon_poly[0]
self.poly = [to_xy(la, lo, self.lat0, self.lon0) for la, lo in latlon_poly]
self.buffer_m = buffer_m # how far past the edge before we believe it
self.dwell_s = dwell_s # how long it must stay that way
self.max_accuracy_m = max_accuracy_m
self.state = "outside"
self.pending_since = None
def update(self, fix):
"""Feed one GPS fix. Returns 'entered', 'left' or None."""
if fix.accuracy > self.max_accuracy_m:
return None # too noisy to judge; keep the last state
pt = to_xy(fix.lat, fix.lon, self.lat0, self.lon0)
d = edge_distance(pt, self.poly)
is_in = inside(pt, self.poly)
# Only a clear position counts: deep enough in, or far enough out.
if is_in and d >= self.buffer_m:
clear = "inside"
elif not is_in and d >= self.buffer_m:
clear = "outside"
else:
clear = None # inside the grey band near the edge
if clear is None or clear == self.state:
self.pending_since = None
return None
if self.pending_since is None:
self.pending_since = fix.t
return None
if fix.t - self.pending_since >= self.dwell_s:
self.state, self.pending_since = clear, None
return "entered" if clear == "inside" else "left"
return None
Start with to_xy. Latitude and longitude are angles, and distances in degrees are useless for a buffer measured in meters. to_xy applies an equirectangular projection centered on the first point of the polygon, so every fix becomes an (x, y) pair in meters relative to that corner. It is not a global projection; it is accurate over one property, which is all a zone needs.
inside is standard ray casting: count how many polygon edges a horizontal ray from the point crosses, and an odd count means inside. edge_distance returns the shortest distance from the point to any edge segment of the polygon. It does not care whether the point is inside or outside; it only answers "how close to the line are you?" That is the number the grey band uses.
NoGoZone ties it together. It is built with the polygon plus buffer_m=5, dwell_s=10 and max_accuracy_m=15, and it holds two pieces of state: the last confirmed state (inside or outside) and the time a different state first appeared.
update(fix) is called once per GPS fix and returns 'entered', 'left' or None. The order of checks matters:
- If the fix reports accuracy worse than
max_accuracy_m, returnNoneand change nothing. The last confirmed state stands. - Project the fix and check
edge_distance. If it is underbuffer_m, the fix is in the grey band: the pending timer is reset and the function returnsNone. A cart hovering on the fringe never builds up dwell time. - Otherwise the fix is clearly inside or clearly outside. If that matches the confirmed state, any pending change is cleared. If it differs, the pending timer starts or continues. Only when the new state has held for
dwell_sseconds does the confirmed state flip, and that is the single moment the function returns'entered'or'left'.
Because the grey band resets the timer, a cart has to be clearly inside, beyond the buffer, continuously for the full dwell before the alert fires. That is the whole trick.
How do you prove it does not cry wolf?
Save this next to it as test_geofence.py:
from geofence import NoGoZone, Fix
# A small square "green" about 40 m x 40 m
GREEN = [(53.40000, -6.20000), (53.40036, -6.20000), (53.40036, -6.19940), (53.40000, -6.19940)]
def run(fixes):
z = NoGoZone("18th green", GREEN)
return [(f.t, e) for f in fixes if (e := z.update(f))]
# 1) Cart drives along the path, GPS jitters across the edge: no alert
jitter = [Fix(t, 53.39998 + (0.00004 if t % 2 else 0), -6.19970, 4) for t in range(0, 30)]
assert run(jitter) == [], run(jitter)
# 2) One wild fix in the middle of the green with poor accuracy: ignored
spike = [Fix(0, 53.39980, -6.19970, 4), Fix(1, 53.40018, -6.19970, 40), Fix(2, 53.39980, -6.19970, 4)]
assert run(spike) == []
# 3) Cart really drives onto the green and stays 15 s: one alert, then one 'left'
real = [Fix(t, 53.39980, -6.19970, 4) for t in range(0, 5)]
real += [Fix(t, 53.40018, -6.19970, 4) for t in range(5, 25)]
real += [Fix(t, 53.39980, -6.19970, 4) for t in range(25, 45)]
events = run(real)
assert events == [(15, "entered"), (35, "left")], events
print("all tests passed:", events)
The test file uses a roughly 40 m by 40 m square as a stand-in "18th green" and feeds the zone one fix per second. Three cases:
- Jitter on the path. A cart sits on a path along one edge and its fixes bounce across the polygon edge every second for 30 seconds, reaching about 2 m inside at most. Every one of those fixes lands in the 5 m grey band, so the timer never starts. No alert.
- One wild fix. A single fix lands in the middle of the green with 40 m reported accuracy. The gate throws it out before any geometry runs. No alert.
-
A real breach. The cart drives onto the green at t=5 and stays. The first clearly-inside fix starts the timer, and after 10 seconds
updatereturns'entered'at t=15. The cart drives off at t=25 and'left'fires at t=35.
Running python3 test_geofence.py prints:
all tests passed: [(15, 'entered'), (35, 'left')]
How do you tune buffer and dwell?
There is no free setting. A bigger buffer means the cart has to be deeper inside before the clock starts, so the alert comes later; a smaller buffer catches more real breaches but lets more path jitter through. A longer dwell cuts false alerts further but delays the warning by that many seconds; a shorter dwell warns sooner and lets single-fix glitches through.
A practical way to tune is to record a day of raw fixes from a few carts, replay them through the class with different settings, and count alerts per hole. Pick the smallest buffer and shortest dwell that produce zero alerts for carts you know stayed on the path. The accuracy gate is the least sensitive of the three; if your receivers report honest accuracy figures, 15 m is a sane default, and tightening it mainly means you drop more fixes under trees.
One more thing: the first polygon point is the projection origin. For a zone a few hundred meters across that is fine. If you ever draw one zone around an entire course, split it, or the far corners will drift.
What should you log?
Log every confirmed breach, not every fix: cart identifier, zone name, the event (entered or left) and the timestamp. Keep the raw fixes separately if storage allows, because the question a supervisor asks later is "did that cart really go on the green at 15:42, or was it a glitch?" With the confirmed events and the trace side by side, they can answer that in a minute. A log that only says "alert" with no cart and no zone is noise with a timestamp.
What does this NOT do?
A zone alert warns the rider and records the breach. That is all it should do. It must never slow or stop a cart. Speed and braking are safety decisions that belong to the cart's own controls and the people responsible for them, not to a GPS geofence that, as shown above, can be several meters off under a tree. If someone asks you to wire 'entered' to a throttle cutoff, the answer is no.
Where does this go next?
The class above is the core; the rest is drawing polygons, delivering the warning to a rider and storing the events. At Axons Mobility, operators draw no-go, speed-limit, parking and charging zones on a map, riders get a warning in the app with a message the operator writes (for example, "Too close to the pond, keep to the path"), and every breach is recorded per cart and per ride and shows in Ride Replay; zones never slow or stop a cart. If you want the operator side of the same problem for a resort or course, start with the write-up on golf cart geofencing.
Top comments (0)