Skip to main content

geofencing

PyPI Python CI License: MIT

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 as 170 ... 190 also 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_m of 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; with buffer_m=15, min_inside_s=5, min_outside_s=5 it is exactly one ENTER and one EXIT.
  • Event times. event.time is when the transition happened (the first fix in the new state); event.detected_at is the fix that confirmed it.
  • GPS accuracy. Fixes whose reported accuracy is worse than max_accuracy_m are 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 for reorder_window_s and 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() (or save_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) and async 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) and contains (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 with Geod.inv to 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)

Source distribution for geofencing 0.1.1
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)

Table of built distributions (wheels) for geofencing 0.1.1
File Interpreter ABI Platform Actions
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 log

Release 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 log

Security 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.

Select from 2 files

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 log

A 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.

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page