geofencing
Fast, dependency-free geofencing for Python. Check which fences contain a point, index tens of thousands of fences, and turn streams of GPS fixes into ENTER / EXIT / DWELL events that do not flap when a vehicle sits on a boundary.
Built for IoT, fleet, telematics and mobility back ends:
- Pure standard library. NumPy is optional and only speeds up batch checks.
- Fence types: polygons and multipolygons with holes, geodesic circles (WGS84), corridors (buffered polylines) and lon/lat boxes.
- Correct on the whole globe: fences across the antimeridian, rings around a pole, circles and corridors at polar latitudes.
- Fast: winding-number point-in-polygon with a latitude-band edge index per fence, plus an STR-packed R-tree (pure Python) over all fences.
- Robust boundaries: inclusive or exclusive, with a tolerance in metres.
- Event engine: a state machine per device and fence with hysteresis (minimum inside/outside time, buffer distance), GPS-accuracy handling, out-of-order fixes, scheduled fences (school zones Mon-Fri 07:00-09:00 in a timezone), speed-limit zones, state you can save and resume, callbacks, iterators and asyncio.
- I/O and CLI: GeoJSON fences, CSV/JSONL positions, JSONL events;
geofencing check | replay | bench.
pip install geofencing # stdlib only
pip install "geofencing[numpy]" # vectorised batch checks
pip install "geofencing[tz]" # IANA timezone data (needed on Windows)
Python 3.9 to 3.13.
Quick start
from geofencing import CircleFence, CorridorFence, FenceIndex, GeofenceEngine, PolygonFence
fences = [
CircleFence("depot", 29.020, 41.010, radius_m=150, speed_limit=20),
PolygonFence.from_polygon(
"school-zone",
[[29.030, 41.010], [29.036, 41.010], [29.036, 41.014], [29.030, 41.014], [29.030, 41.010]],
speed_limit=30,
schedule={"timezone": "Europe/Istanbul", "rules": ["Mon-Fri 07:00-09:00"]},
),
CorridorFence("main-road", [[[29.01, 41.012], [29.05, 41.012]]], radius_m=30, speed_limit=50),
]
index = FenceIndex(fences)
index.containing_ids(29.0201, 41.0101) # ['depot']
index.speed_limit_at(29.033, 41.012, "2026-11-09T07:30:00+03:00") # 30.0 (school in session)
index.speed_limit_at(29.033, 41.012, "2026-11-09T12:00:00+03:00") # 50.0 (road only)
engine = GeofenceEngine(
index,
min_inside_s=10, # ENTER only after 10 s inside
min_outside_s=10, # EXIT only after 10 s outside
buffer_m=10, # fixes within 10 m of a boundary are "uncertain"
dwell_s=300, # DWELL after 5 minutes inside
max_accuracy_m=50, # fixes reported worse than 50 m ...
poor_accuracy="ignore",
)
engine.subscribe(lambda ev: print(ev.type, ev.fence_id), types=["enter", "exit"])
for event in engine.process_many(positions): # Position objects or dicts
send_to_queue(event.to_dict())
A position is Position(device_id, lon, lat, t, accuracy=None, speed=None) with
t in POSIX seconds, or a dict with the same keys (t may be an ISO 8601 string).
Fences
| Class | Geometry | Notes |
|---|---|---|
PolygonFence(id, polygons) |
GeoJSON MultiPolygon coordinates; PolygonFence.from_polygon(id, exterior, holes) for one polygon |
Edges are straight in lon/lat (RFC 7946). Any orientation; holes; concave and self-touching rings. |
CircleFence(id, lon, lat, radius_m) |
geodesic circle on WGS84 | A spherical distance settles clear cases; points within 1 % of the radius are re-measured with Vincenty on the ellipsoid. |
CorridorFence(id, lines, radius_m) |
buffered polyline(s) | radius_m is the half-width. Great-circle segments on the mean sphere. |
BBoxFence(id, west, south, east, north) |
lon/lat box | west > east crosses the antimeridian. |
Every fence takes the same keyword arguments: name, properties, schedule,
speed_limit, boundary="inclusive" | "exclusive" and tolerance_m.
fence.contains(lon, lat, t=None, inclusive=None, tolerance_m=None) # -> bool
fence.locate(lon, lat, margin_m=0) # 1 inside, 0 within margin of the boundary, -1 outside
fence.signed_distance(lon, lat) # metres to the boundary, positive inside
fence.contains_many(lons, lats) # vectorised with NumPy, plain loop without
The antimeridian and the poles
- Rings are unwrapped, so
[[170, -5], [-170, -5], [-170, 5], [170, 5]]is the 20-degree box over the date line, not a 340-degree one. Writing the same ring as170 ... 190also works, and point longitudes are wrapped before testing. - A ring whose longitudes go once around the globe (for example the 80th parallel) encloses the pole on the side of its mean latitude.
- Circles and corridors are computed with 3D unit vectors and ellipsoidal distances, so a circle centred at 89.9 N contains the pole and points on the far side of it, and a polar flight corridor is a corridor.
- The R-tree stores fences that cross the date line as two boxes; a point at longitude 180 or -180 finds fences on both sides.
Boundaries and tolerance
boundary="inclusive" (default) treats points on an edge or vertex as inside.
tolerance_m widens "on the boundary" to a band in metres (measured in a local
equirectangular frame around the point), which is handy for snapping noisy
fixes or for fences digitised with limited precision. Exact on-edge detection
uses exact arithmetic on the input coordinates, so vertices are always
boundary points.
Spatial index
FenceIndex holds any number of fences behind an STR-packed R-tree
(geofencing.STRTree, also usable on its own). Building the tree for 10^5 fences
takes about 0.5 s; fences can be added or removed (the tree is rebuilt lazily).
index.candidates(lon, lat, margin_m=0) # bounding-box hits
index.containing(lon, lat, t=None) # exact hits, schedules applied when t is given
index.speed_limit_at(lon, lat, t=None) # lowest limit of the containing fences
index.contains_many(lons, lats, t=None) # list of fence-id lists, vectorised with NumPy
Event engine
For each device and fence:
OUTSIDE --inside fix--> PENDING_ENTER --inside >= min_inside_s--> INSIDE (ENTER)
INSIDE --outside fix--> PENDING_EXIT --outside >= min_outside_s--> OUTSIDE (EXIT)
INSIDE --inside >= dwell_s (and every dwell_repeat_s)--> (DWELL)
- Hysteresis. A fix within
buffer_mof the boundary is uncertain: it neither confirms nor cancels a pending transition. Together with the minimum durations this is what stops GPS jitter at a boundary from producing a burst of ENTER/EXIT pairs. The test suite drives a device along a boundary with +/-8 m of noise for 10 minutes: without hysteresis that is over a hundred events; withbuffer_m=15, min_inside_s=5, min_outside_s=5it is exactly one ENTER and one EXIT. - Event times.
event.timeis when the transition happened (the first fix in the new state);event.detected_atis the fix that confirmed it. - GPS accuracy. Fixes whose reported accuracy is worse than
max_accuracy_mare dropped (poor_accuracy="ignore") or kept with the accuracy radius added to the margin ("uncertain"), so they can only cause a transition when they are unambiguous. - Out-of-order fixes.
out_of_order="drop"(default) ignores fixes older than the last processed one;"reorder"buffers fixes forreorder_window_sand processes them in time order.flush()drains the buffer. - Scheduled fences. Outside its schedule a fence counts as "outside", so a car parked in a school zone gets ENTER at 07:00 and EXIT at 09:00.
- Speed limits. Events carry the fence's
speed_limit;engine.speed_limit_at(lon, lat, t)returns the applicable limit. - State.
engine.state_dict()/load_state_dict()(orsave_state(path)/load_state(path)) snapshot every device, including pending transitions and the reorder buffer, as plain JSON. A run split in two with a save and load in the middle produces exactly the events of an uninterrupted run (tested). - Delivery.
process(fix)returns a list;process_many(fixes)is a generator;subscribe(callback, types=None)and the@engine.on("enter")decorator register callbacks. For asyncio,await engine.aprocess(fix)andasync for ev in engine.astream(async_source)await async callbacks.
async for event in engine.astream(mqtt_positions()):
await publish(event.to_dict())
GeoJSON, CSV and JSONL
from geofencing import load_fences, save_fences, read_positions, write_events
fences = load_fences("fences.geojson")
events = engine.process_many(read_positions("positions.csv"))
write_events(events, "events.jsonl")
GeoJSON features map to fences as follows:
| Geometry | Properties | Fence |
|---|---|---|
Polygon, MultiPolygon |
PolygonFence |
|
Point |
radius_m |
CircleFence |
LineString, MultiLineString |
radius_m / buffer_m (half-width) or width_m |
CorridorFence |
| any or none | "fence_type": "bbox", "bbox": [w, s, e, n] |
BBoxFence |
Optional properties: id (or the feature id), name, speed_limit (alias
maxspeed), boundary, tolerance_m and schedule, for example
{"timezone": "Europe/Istanbul", "rules": ["Mon-Fri 07:00-09:00", {"days": "Sat", "start": "22:00", "end": "06:00"}]}.
Timezones are UTC, fixed offsets such as +03:00, or IANA names (install the
tz extra on Windows).
Position files are CSV or JSONL with case-insensitive column names:
device_id/device/vehicle_id, lon/lng/longitude, lat/latitude,
t/time/timestamp (POSIX seconds or ISO 8601), optional accuracy and speed.
Command line
geofencing check fences.geojson --lat 41.0101 --lon 29.0201
# {"lon": 29.0201, "lat": 41.0101, "inside": ["depot"], "speed_limit": 20.0}
geofencing replay fences.geojson positions.csv -o events.jsonl \
--min-inside 10 --min-outside 10 --buffer 10 --dwell 300 \
--max-accuracy 50 --state-out state.json
geofencing bench --fences 10000 --points 50000
check also takes --time, --tolerance, --exclusive and --exit-code;
replay takes every engine option plus --state-in to resume.
The examples/ folder has demo fences, a synthetic track and quickstart.py.
Correctness
The test suite (pytest) checks:
- Point-in-polygon vs Shapely (when installed): 60 random concave polygons
with 3 to 60 vertices and holes, 24,000 random points, plus every vertex,
compared with
covers(inclusive) andcontains(exclusive): no mismatch. Self-touching cases (a hole touching the shell, an inverted shell) are checked on random points and on a grid that hits vertices and edges exactly. - Antimeridian vs Shapely: random star polygons with holes centred on longitude 180, written in wrapped longitudes, against Shapely on unwrapped ones.
- Tolerance: the metre band matches Shapely distances near the equator.
- Circle radius vs
pyproj.Geod(when installed): 400 circles from 1 m to 3,160 km, including centres within 0.01 degree of a pole and next to the antimeridian; points 2 mm (or 0.1 ppm) inside and outside the radius along random azimuths are classified correctly (4,000 checks) and distances agree withGeod.invto 1 mm. - R-tree against brute force for up to 12,000 boxes; the index against a linear scan over 3,000 fences; the NumPy batch path against the scalar one.
- Event engine scenarios: boundary jitter, debouncing, uncertain fixes, dwell repeat, accuracy policies, reordering, school-zone schedules, removed fences, callbacks, asyncio and save/resume equivalence.
Performance
geofencing bench on one core of an AMD Ryzen laptop CPU (Python 3.12, Windows),
50,000 random points. Synthetic fences are 75 % concave 24-vertex polygons and
25 % circles of 100-800 m, scattered at 10^4 fences per 85 x 55 km.
| Fences | Method | Points / s |
|---|---|---|
| 1 (256-vertex concave polygon) | fence.contains |
262,000 |
| 1 (256-vertex concave polygon) | index.containing |
188,000 |
| 1 (256-vertex concave polygon) | index.contains_many (NumPy) |
1,160,000 |
| 10,000 | index.containing |
37,000 |
| 10,000 | index.contains_many (NumPy) |
61,000 |
| 10,000 | engine.process (100 devices, hysteresis on) |
25,000 |
| 100,000 | index.containing |
21,000 |
| 100,000 | engine.process (100 devices, hysteresis on) |
17,000 |
Building the R-tree takes 0.04 s for 10^4 fences and 0.5 s for 10^5 (depth 4 and 5).
Run geofencing bench to measure your own hardware.
Development
pip install -e ".[dev]"
pytest --cov=geofencing
ruff check . && ruff format --check src tests && mypy src
Coverage is about 99 % with the optional packages installed; the suite also runs with the standard library alone (Shapely, pyproj and NumPy tests are skipped).
License
MIT (c) Yusuf Eminoğlu. See LICENSE and CITATION.cff.
Metadata
Release files for geofencing 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| geofencing-0.1.1.tar.gz | 63.4 kB | With provenance | Details for geofencing-0.1.1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| geofencing-0.1.1-py3-none-any.whl | Python 3 | none | any | Details for geofencing-0.1.1-py3-none-any.whl |
Total release size: 106.8 kB
Release files / geofencing-0.1.1.tar.gz
| Download URL | geofencing-0.1.1.tar.gz |
|---|---|
| Size | 63.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
648fa9fe6839753f7f6383ce7bedca1668eb45eaa8cd2097724385c951ae2dd9
|
|
BLAKE2b-256 checksum How to use checksums |
673c315d93a9051b3a0e0da9d40433c407ece1b251d43fdcb549b2fe25711849
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.
Transparency logRelease files / geofencing-0.1.1-py3-none-any.whl
| Download URL | geofencing-0.1.1-py3-none-any.whl |
|---|---|
| Size | 43.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
55982716a866e24009b641ffa8550cd9431a95c82a64b78ccd2189eb2c1ec81c
|
|
BLAKE2b-256 checksum How to use checksums |
bf7f61a0e21715a39db1031c7341208067eebb39e2849fa70413f17208b08813
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.
Transparency logSecurity for geofencing 0.1.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for geofencing-0.1.1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.