Metadata-Version: 2.4
Name: geofencing
Version: 0.1.0
Summary: Fast, dependency-free geofencing: point-in-fence checks, an R-tree over thousands of fences and enter/exit/dwell events from GPS position streams.
Author-email: Yusuf Eminoğlu <yusufeminoglu@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/YusufEminoglu/geofencing_sdk
Project-URL: Repository, https://github.com/YusufEminoglu/geofencing_sdk
Project-URL: Issues, https://github.com/YusufEminoglu/geofencing_sdk/issues
Project-URL: Changelog, https://github.com/YusufEminoglu/geofencing_sdk/blob/main/CHANGELOG.md
Keywords: geofence,geofencing,gis,gps,iot,fleet,telematics,mobility,point-in-polygon,r-tree,spatial-index,geojson,antimeridian,speed-limit,event-stream
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Classifier: Framework :: AsyncIO
Classifier: Topic :: Scientific/Engineering :: GIS
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Networking :: Monitoring
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: numpy
Requires-Dist: numpy>=1.20.0; extra == "numpy"
Provides-Extra: tz
Requires-Dist: tzdata>=2023.3; extra == "tz"
Provides-Extra: test
Requires-Dist: pytest>=7.0.0; extra == "test"
Requires-Dist: pytest-cov>=4.0.0; extra == "test"
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
Requires-Dist: ruff>=0.1.0; extra == "dev"
Requires-Dist: mypy>=1.0.0; extra == "dev"
Requires-Dist: numpy>=1.20.0; extra == "dev"
Requires-Dist: shapely>=2.0.0; extra == "dev"
Requires-Dist: pyproj>=3.3.0; extra == "dev"
Requires-Dist: tzdata>=2023.3; extra == "dev"
Dynamic: license-file

# geofencing

[![PyPI](https://img.shields.io/pypi/v/geofencing.svg)](https://pypi.org/project/geofencing/)
[![Python](https://img.shields.io/pypi/pyversions/geofencing.svg)](https://pypi.org/project/geofencing/)
[![CI](https://github.com/YusufEminoglu/geofencing_sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/YusufEminoglu/geofencing_sdk/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

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

```bash
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

```python
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`.

```python
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).

```python
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.

```python
async for event in engine.astream(mqtt_positions()):
    await publish(event.to_dict())
```

## GeoJSON, CSV and JSONL

```python
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

```bash
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

```bash
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](LICENSE) and [CITATION.cff](CITATION.cff).
