geofencing 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. geofencing-0.1.0/CHANGELOG.md +40 -0
  2. geofencing-0.1.0/CITATION.cff +21 -0
  3. geofencing-0.1.0/LICENSE +21 -0
  4. geofencing-0.1.0/MANIFEST.in +9 -0
  5. geofencing-0.1.0/PKG-INFO +324 -0
  6. geofencing-0.1.0/README.md +276 -0
  7. geofencing-0.1.0/examples/fences.geojson +42 -0
  8. geofencing-0.1.0/examples/positions.csv +53 -0
  9. geofencing-0.1.0/examples/quickstart.py +28 -0
  10. geofencing-0.1.0/pyproject.toml +137 -0
  11. geofencing-0.1.0/setup.cfg +4 -0
  12. geofencing-0.1.0/src/geofencing/__init__.py +72 -0
  13. geofencing-0.1.0/src/geofencing/__main__.py +7 -0
  14. geofencing-0.1.0/src/geofencing/_geodesy.py +172 -0
  15. geofencing-0.1.0/src/geofencing/bench.py +191 -0
  16. geofencing-0.1.0/src/geofencing/cli.py +141 -0
  17. geofencing-0.1.0/src/geofencing/engine.py +504 -0
  18. geofencing-0.1.0/src/geofencing/events.py +121 -0
  19. geofencing-0.1.0/src/geofencing/fences.py +845 -0
  20. geofencing-0.1.0/src/geofencing/index.py +234 -0
  21. geofencing-0.1.0/src/geofencing/io.py +254 -0
  22. geofencing-0.1.0/src/geofencing/py.typed +0 -0
  23. geofencing-0.1.0/src/geofencing/rtree.py +112 -0
  24. geofencing-0.1.0/src/geofencing/schedule.py +242 -0
  25. geofencing-0.1.0/src/geofencing.egg-info/PKG-INFO +324 -0
  26. geofencing-0.1.0/src/geofencing.egg-info/SOURCES.txt +37 -0
  27. geofencing-0.1.0/src/geofencing.egg-info/dependency_links.txt +1 -0
  28. geofencing-0.1.0/src/geofencing.egg-info/entry_points.txt +2 -0
  29. geofencing-0.1.0/src/geofencing.egg-info/requires.txt +20 -0
  30. geofencing-0.1.0/src/geofencing.egg-info/top_level.txt +1 -0
  31. geofencing-0.1.0/tests/conftest.py +25 -0
  32. geofencing-0.1.0/tests/test_antimeridian_polar.py +154 -0
  33. geofencing-0.1.0/tests/test_circle_corridor.py +124 -0
  34. geofencing-0.1.0/tests/test_engine.py +353 -0
  35. geofencing-0.1.0/tests/test_geodesy.py +77 -0
  36. geofencing-0.1.0/tests/test_index.py +146 -0
  37. geofencing-0.1.0/tests/test_io_cli.py +314 -0
  38. geofencing-0.1.0/tests/test_polygon.py +241 -0
  39. geofencing-0.1.0/tests/test_schedule.py +110 -0
@@ -0,0 +1,40 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-11-09
11
+ ### Added
12
+ - **Fences**: `PolygonFence` (polygons and multipolygons with holes, any ring
13
+ orientation), `CircleFence` (geodesic radius on WGS84: spherical pre-check plus
14
+ Vincenty near the boundary), `CorridorFence` (buffered polylines on great-circle
15
+ segments) and `BBoxFence`. Common API: `contains`, `locate`, `signed_distance`,
16
+ `contains_many`, `to_feature`.
17
+ - **Whole-globe geometry**: rings crossing the antimeridian are unwrapped, rings
18
+ that wind around a pole are closed over it, circles and corridors are exact at
19
+ polar latitudes; the index splits date-line boxes.
20
+ - **Point-in-polygon**: winding number with exact on-edge detection and a
21
+ latitude-band edge index per fence; boundary policy `inclusive`/`exclusive` and
22
+ `tolerance_m` in metres.
23
+ - **Spatial index**: `STRTree`, a pure-Python Sort-Tile-Recursive packed R-tree,
24
+ and `FenceIndex` with `candidates`, `containing`, `speed_limit_at` and a
25
+ NumPy-vectorised `contains_many`.
26
+ - **Schedules**: weekly time windows in UTC, fixed offsets or IANA timezones,
27
+ including overnight windows (`Schedule`, `ScheduleRule`).
28
+ - **Event engine**: `GeofenceEngine` with ENTER/EXIT/DWELL, hysteresis (minimum
29
+ inside/outside time, buffer distance), GPS accuracy policies, out-of-order
30
+ dropping or reordering, scheduled and speed-limit fences, JSON state
31
+ save/resume, callbacks, generators and an asyncio API (`aprocess`, `astream`).
32
+ - **I/O**: GeoJSON fences in and out, CSV/JSONL position readers with column
33
+ aliases, JSONL event writer.
34
+ - **CLI**: `geofencing check`, `geofencing replay` and `geofencing bench`.
35
+ - Tests against Shapely (random concave, holed, self-touching and antimeridian
36
+ polygons) and `pyproj.Geod` (circle radius accuracy), event-engine scenarios;
37
+ coverage above 95 %.
38
+
39
+ [Unreleased]: https://github.com/YusufEminoglu/geofencing_sdk/compare/v0.1.0...HEAD
40
+ [0.1.0]: https://github.com/YusufEminoglu/geofencing_sdk/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use this software, please cite it as below."
3
+ authors:
4
+ - family-names: "Eminoğlu"
5
+ given-names: "Yusuf"
6
+ email: "yusufeminoglu@gmail.com"
7
+ orcid: "https://orcid.org/0009-0005-6000-2934"
8
+ title: "geofencing"
9
+ version: "0.1.0"
10
+ date-released: "2026-11-09"
11
+ license: "MIT"
12
+ repository-code: "https://github.com/YusufEminoglu/geofencing_sdk"
13
+ url: "https://pypi.org/project/geofencing/"
14
+ abstract: "Fast, dependency-free geofencing for Python: point-in-fence checks for polygons, geodesic circles, corridors and boxes, an STR-packed R-tree over tens of thousands of fences, and a stateful engine that turns GPS position streams into enter, exit and dwell events with hysteresis."
15
+ keywords:
16
+ - geofence
17
+ - gps
18
+ - iot
19
+ - fleet
20
+ - point-in-polygon
21
+ - r-tree
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yusuf Eminoğlu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,9 @@
1
+ include LICENSE
2
+ include README.md
3
+ include CHANGELOG.md
4
+ include CITATION.cff
5
+ include pyproject.toml
6
+ include src/geofencing/py.typed
7
+ recursive-include src/geofencing *.py
8
+ recursive-include tests *.py
9
+ recursive-include examples *.geojson *.csv *.py
@@ -0,0 +1,324 @@
1
+ Metadata-Version: 2.4
2
+ Name: geofencing
3
+ Version: 0.1.0
4
+ 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.
5
+ Author-email: Yusuf Eminoğlu <yusufeminoglu@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/YusufEminoglu/geofencing_sdk
8
+ Project-URL: Repository, https://github.com/YusufEminoglu/geofencing_sdk
9
+ Project-URL: Issues, https://github.com/YusufEminoglu/geofencing_sdk/issues
10
+ Project-URL: Changelog, https://github.com/YusufEminoglu/geofencing_sdk/blob/main/CHANGELOG.md
11
+ Keywords: geofence,geofencing,gis,gps,iot,fleet,telematics,mobility,point-in-polygon,r-tree,spatial-index,geojson,antimeridian,speed-limit,event-stream
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Framework :: AsyncIO
22
+ Classifier: Topic :: Scientific/Engineering :: GIS
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Topic :: System :: Networking :: Monitoring
25
+ Classifier: Intended Audience :: Developers
26
+ Classifier: Intended Audience :: Science/Research
27
+ Classifier: Typing :: Typed
28
+ Requires-Python: >=3.9
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Provides-Extra: numpy
32
+ Requires-Dist: numpy>=1.20.0; extra == "numpy"
33
+ Provides-Extra: tz
34
+ Requires-Dist: tzdata>=2023.3; extra == "tz"
35
+ Provides-Extra: test
36
+ Requires-Dist: pytest>=7.0.0; extra == "test"
37
+ Requires-Dist: pytest-cov>=4.0.0; extra == "test"
38
+ Provides-Extra: dev
39
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
40
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
41
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
42
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
43
+ Requires-Dist: numpy>=1.20.0; extra == "dev"
44
+ Requires-Dist: shapely>=2.0.0; extra == "dev"
45
+ Requires-Dist: pyproj>=3.3.0; extra == "dev"
46
+ Requires-Dist: tzdata>=2023.3; extra == "dev"
47
+ Dynamic: license-file
48
+
49
+ # geofencing
50
+
51
+ [![PyPI](https://img.shields.io/pypi/v/geofencing.svg)](https://pypi.org/project/geofencing/)
52
+ [![Python](https://img.shields.io/pypi/pyversions/geofencing.svg)](https://pypi.org/project/geofencing/)
53
+ [![CI](https://github.com/YusufEminoglu/geofencing_sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/YusufEminoglu/geofencing_sdk/actions/workflows/ci.yml)
54
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
55
+
56
+ Fast, dependency-free geofencing for Python. Check which fences contain a point,
57
+ index tens of thousands of fences, and turn streams of GPS fixes into
58
+ **ENTER / EXIT / DWELL** events that do not flap when a vehicle sits on a boundary.
59
+
60
+ Built for IoT, fleet, telematics and mobility back ends:
61
+
62
+ - **Pure standard library.** NumPy is optional and only speeds up batch checks.
63
+ - **Fence types:** polygons and multipolygons with holes, geodesic circles
64
+ (WGS84), corridors (buffered polylines) and lon/lat boxes.
65
+ - **Correct on the whole globe:** fences across the antimeridian, rings around a
66
+ pole, circles and corridors at polar latitudes.
67
+ - **Fast:** winding-number point-in-polygon with a latitude-band edge index per
68
+ fence, plus an STR-packed R-tree (pure Python) over all fences.
69
+ - **Robust boundaries:** inclusive or exclusive, with a tolerance in metres.
70
+ - **Event engine:** a state machine per device and fence with hysteresis (minimum
71
+ inside/outside time, buffer distance), GPS-accuracy handling, out-of-order
72
+ fixes, scheduled fences (school zones Mon-Fri 07:00-09:00 in a timezone),
73
+ speed-limit zones, state you can save and resume, callbacks, iterators and
74
+ asyncio.
75
+ - **I/O and CLI:** GeoJSON fences, CSV/JSONL positions, JSONL events;
76
+ `geofencing check | replay | bench`.
77
+
78
+ ```bash
79
+ pip install geofencing # stdlib only
80
+ pip install "geofencing[numpy]" # vectorised batch checks
81
+ pip install "geofencing[tz]" # IANA timezone data (needed on Windows)
82
+ ```
83
+
84
+ Python 3.9 to 3.13.
85
+
86
+ ## Quick start
87
+
88
+ ```python
89
+ from geofencing import CircleFence, CorridorFence, FenceIndex, GeofenceEngine, PolygonFence
90
+
91
+ fences = [
92
+ CircleFence("depot", 29.020, 41.010, radius_m=150, speed_limit=20),
93
+ PolygonFence.from_polygon(
94
+ "school-zone",
95
+ [[29.030, 41.010], [29.036, 41.010], [29.036, 41.014], [29.030, 41.014], [29.030, 41.010]],
96
+ speed_limit=30,
97
+ schedule={"timezone": "Europe/Istanbul", "rules": ["Mon-Fri 07:00-09:00"]},
98
+ ),
99
+ CorridorFence("main-road", [[[29.01, 41.012], [29.05, 41.012]]], radius_m=30, speed_limit=50),
100
+ ]
101
+ index = FenceIndex(fences)
102
+
103
+ index.containing_ids(29.0201, 41.0101) # ['depot']
104
+ index.speed_limit_at(29.033, 41.012, "2026-11-09T07:30:00+03:00") # 30.0 (school in session)
105
+ index.speed_limit_at(29.033, 41.012, "2026-11-09T12:00:00+03:00") # 50.0 (road only)
106
+
107
+ engine = GeofenceEngine(
108
+ index,
109
+ min_inside_s=10, # ENTER only after 10 s inside
110
+ min_outside_s=10, # EXIT only after 10 s outside
111
+ buffer_m=10, # fixes within 10 m of a boundary are "uncertain"
112
+ dwell_s=300, # DWELL after 5 minutes inside
113
+ max_accuracy_m=50, # fixes reported worse than 50 m ...
114
+ poor_accuracy="ignore",
115
+ )
116
+ engine.subscribe(lambda ev: print(ev.type, ev.fence_id), types=["enter", "exit"])
117
+
118
+ for event in engine.process_many(positions): # Position objects or dicts
119
+ send_to_queue(event.to_dict())
120
+ ```
121
+
122
+ A position is `Position(device_id, lon, lat, t, accuracy=None, speed=None)` with
123
+ `t` in POSIX seconds, or a dict with the same keys (`t` may be an ISO 8601 string).
124
+
125
+ ## Fences
126
+
127
+ | Class | Geometry | Notes |
128
+ |---|---|---|
129
+ | `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. |
130
+ | `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. |
131
+ | `CorridorFence(id, lines, radius_m)` | buffered polyline(s) | `radius_m` is the half-width. Great-circle segments on the mean sphere. |
132
+ | `BBoxFence(id, west, south, east, north)` | lon/lat box | `west > east` crosses the antimeridian. |
133
+
134
+ Every fence takes the same keyword arguments: `name`, `properties`, `schedule`,
135
+ `speed_limit`, `boundary="inclusive" | "exclusive"` and `tolerance_m`.
136
+
137
+ ```python
138
+ fence.contains(lon, lat, t=None, inclusive=None, tolerance_m=None) # -> bool
139
+ fence.locate(lon, lat, margin_m=0) # 1 inside, 0 within margin of the boundary, -1 outside
140
+ fence.signed_distance(lon, lat) # metres to the boundary, positive inside
141
+ fence.contains_many(lons, lats) # vectorised with NumPy, plain loop without
142
+ ```
143
+
144
+ ### The antimeridian and the poles
145
+
146
+ - Rings are unwrapped, so `[[170, -5], [-170, -5], [-170, 5], [170, 5]]` is the
147
+ 20-degree box over the date line, not a 340-degree one. Writing the same ring
148
+ as `170 ... 190` also works, and point longitudes are wrapped before testing.
149
+ - A ring whose longitudes go once around the globe (for example the 80th parallel)
150
+ encloses the pole on the side of its mean latitude.
151
+ - Circles and corridors are computed with 3D unit vectors and ellipsoidal
152
+ distances, so a circle centred at 89.9 N contains the pole and points on the
153
+ far side of it, and a polar flight corridor is a corridor.
154
+ - The R-tree stores fences that cross the date line as two boxes; a point at
155
+ longitude 180 or -180 finds fences on both sides.
156
+
157
+ ### Boundaries and tolerance
158
+
159
+ `boundary="inclusive"` (default) treats points on an edge or vertex as inside.
160
+ `tolerance_m` widens "on the boundary" to a band in metres (measured in a local
161
+ equirectangular frame around the point), which is handy for snapping noisy
162
+ fixes or for fences digitised with limited precision. Exact on-edge detection
163
+ uses exact arithmetic on the input coordinates, so vertices are always
164
+ boundary points.
165
+
166
+ ## Spatial index
167
+
168
+ `FenceIndex` holds any number of fences behind an STR-packed R-tree
169
+ (`geofencing.STRTree`, also usable on its own). Building the tree for 10^5 fences
170
+ takes about 0.5 s; fences can be added or removed (the tree is rebuilt lazily).
171
+
172
+ ```python
173
+ index.candidates(lon, lat, margin_m=0) # bounding-box hits
174
+ index.containing(lon, lat, t=None) # exact hits, schedules applied when t is given
175
+ index.speed_limit_at(lon, lat, t=None) # lowest limit of the containing fences
176
+ index.contains_many(lons, lats, t=None) # list of fence-id lists, vectorised with NumPy
177
+ ```
178
+
179
+ ## Event engine
180
+
181
+ For each device and fence:
182
+
183
+ ```
184
+ OUTSIDE --inside fix--> PENDING_ENTER --inside >= min_inside_s--> INSIDE (ENTER)
185
+ INSIDE --outside fix--> PENDING_EXIT --outside >= min_outside_s--> OUTSIDE (EXIT)
186
+ INSIDE --inside >= dwell_s (and every dwell_repeat_s)--> (DWELL)
187
+ ```
188
+
189
+ - **Hysteresis.** A fix within `buffer_m` of the boundary is *uncertain*: it
190
+ neither confirms nor cancels a pending transition. Together with the minimum
191
+ durations this is what stops GPS jitter at a boundary from producing a burst of
192
+ ENTER/EXIT pairs. The test suite drives a device along a boundary with +/-8 m of
193
+ noise for 10 minutes: without hysteresis that is over a hundred events; with
194
+ `buffer_m=15, min_inside_s=5, min_outside_s=5` it is exactly one ENTER and one EXIT.
195
+ - **Event times.** `event.time` is when the transition happened (the first fix in
196
+ the new state); `event.detected_at` is the fix that confirmed it.
197
+ - **GPS accuracy.** Fixes whose reported accuracy is worse than `max_accuracy_m`
198
+ are dropped (`poor_accuracy="ignore"`) or kept with the accuracy radius added to
199
+ the margin (`"uncertain"`), so they can only cause a transition when they are
200
+ unambiguous.
201
+ - **Out-of-order fixes.** `out_of_order="drop"` (default) ignores fixes older than
202
+ the last processed one; `"reorder"` buffers fixes for `reorder_window_s` and
203
+ processes them in time order. `flush()` drains the buffer.
204
+ - **Scheduled fences.** Outside its schedule a fence counts as "outside", so a car
205
+ parked in a school zone gets ENTER at 07:00 and EXIT at 09:00.
206
+ - **Speed limits.** Events carry the fence's `speed_limit`;
207
+ `engine.speed_limit_at(lon, lat, t)` returns the applicable limit.
208
+ - **State.** `engine.state_dict()` / `load_state_dict()` (or `save_state(path)` /
209
+ `load_state(path)`) snapshot every device, including pending transitions and the
210
+ reorder buffer, as plain JSON. A run split in two with a save and load in the
211
+ middle produces exactly the events of an uninterrupted run (tested).
212
+ - **Delivery.** `process(fix)` returns a list; `process_many(fixes)` is a generator;
213
+ `subscribe(callback, types=None)` and the `@engine.on("enter")` decorator register
214
+ callbacks. For asyncio, `await engine.aprocess(fix)` and
215
+ `async for ev in engine.astream(async_source)` await async callbacks.
216
+
217
+ ```python
218
+ async for event in engine.astream(mqtt_positions()):
219
+ await publish(event.to_dict())
220
+ ```
221
+
222
+ ## GeoJSON, CSV and JSONL
223
+
224
+ ```python
225
+ from geofencing import load_fences, save_fences, read_positions, write_events
226
+
227
+ fences = load_fences("fences.geojson")
228
+ events = engine.process_many(read_positions("positions.csv"))
229
+ write_events(events, "events.jsonl")
230
+ ```
231
+
232
+ GeoJSON features map to fences as follows:
233
+
234
+ | Geometry | Properties | Fence |
235
+ |---|---|---|
236
+ | `Polygon`, `MultiPolygon` | | `PolygonFence` |
237
+ | `Point` | `radius_m` | `CircleFence` |
238
+ | `LineString`, `MultiLineString` | `radius_m` / `buffer_m` (half-width) or `width_m` | `CorridorFence` |
239
+ | any or none | `"fence_type": "bbox"`, `"bbox": [w, s, e, n]` | `BBoxFence` |
240
+
241
+ Optional properties: `id` (or the feature `id`), `name`, `speed_limit` (alias
242
+ `maxspeed`), `boundary`, `tolerance_m` and `schedule`, for example
243
+ `{"timezone": "Europe/Istanbul", "rules": ["Mon-Fri 07:00-09:00", {"days": "Sat", "start": "22:00", "end": "06:00"}]}`.
244
+ Timezones are `UTC`, fixed offsets such as `+03:00`, or IANA names (install the
245
+ `tz` extra on Windows).
246
+
247
+ Position files are CSV or JSONL with case-insensitive column names:
248
+ `device_id`/`device`/`vehicle_id`, `lon`/`lng`/`longitude`, `lat`/`latitude`,
249
+ `t`/`time`/`timestamp` (POSIX seconds or ISO 8601), optional `accuracy` and `speed`.
250
+
251
+ ## Command line
252
+
253
+ ```bash
254
+ geofencing check fences.geojson --lat 41.0101 --lon 29.0201
255
+ # {"lon": 29.0201, "lat": 41.0101, "inside": ["depot"], "speed_limit": 20.0}
256
+
257
+ geofencing replay fences.geojson positions.csv -o events.jsonl \
258
+ --min-inside 10 --min-outside 10 --buffer 10 --dwell 300 \
259
+ --max-accuracy 50 --state-out state.json
260
+
261
+ geofencing bench --fences 10000 --points 50000
262
+ ```
263
+
264
+ `check` also takes `--time`, `--tolerance`, `--exclusive` and `--exit-code`;
265
+ `replay` takes every engine option plus `--state-in` to resume.
266
+ The `examples/` folder has demo fences, a synthetic track and `quickstart.py`.
267
+
268
+ ## Correctness
269
+
270
+ The test suite (`pytest`) checks:
271
+
272
+ - **Point-in-polygon vs Shapely** (when installed): 60 random concave polygons
273
+ with 3 to 60 vertices and holes, 24,000 random points, plus every vertex,
274
+ compared with `covers` (inclusive) and `contains` (exclusive): no mismatch.
275
+ Self-touching cases (a hole touching the shell, an inverted shell) are checked
276
+ on random points and on a grid that hits vertices and edges exactly.
277
+ - **Antimeridian vs Shapely:** random star polygons with holes centred on
278
+ longitude 180, written in wrapped longitudes, against Shapely on unwrapped ones.
279
+ - **Tolerance:** the metre band matches Shapely distances near the equator.
280
+ - **Circle radius vs `pyproj.Geod`** (when installed): 400 circles from 1 m to
281
+ 3,160 km, including centres within 0.01 degree of a pole and next to the
282
+ antimeridian; points 2 mm (or 0.1 ppm) inside and outside the radius along
283
+ random azimuths are classified correctly (4,000 checks) and distances agree
284
+ with `Geod.inv` to 1 mm.
285
+ - **R-tree** against brute force for up to 12,000 boxes; the index against a
286
+ linear scan over 3,000 fences; the NumPy batch path against the scalar one.
287
+ - **Event engine scenarios:** boundary jitter, debouncing, uncertain fixes, dwell
288
+ repeat, accuracy policies, reordering, school-zone schedules, removed fences,
289
+ callbacks, asyncio and save/resume equivalence.
290
+
291
+ ## Performance
292
+
293
+ `geofencing bench` on one core of an AMD Ryzen laptop CPU (Python 3.12, Windows),
294
+ 50,000 random points. Synthetic fences are 75 % concave 24-vertex polygons and
295
+ 25 % circles of 100-800 m, scattered at 10^4 fences per 85 x 55 km.
296
+
297
+ | Fences | Method | Points / s |
298
+ |---|---|---:|
299
+ | 1 (256-vertex concave polygon) | `fence.contains` | 262,000 |
300
+ | 1 (256-vertex concave polygon) | `index.containing` | 188,000 |
301
+ | 1 (256-vertex concave polygon) | `index.contains_many` (NumPy) | 1,160,000 |
302
+ | 10,000 | `index.containing` | 37,000 |
303
+ | 10,000 | `index.contains_many` (NumPy) | 61,000 |
304
+ | 10,000 | `engine.process` (100 devices, hysteresis on) | 25,000 |
305
+ | 100,000 | `index.containing` | 21,000 |
306
+ | 100,000 | `engine.process` (100 devices, hysteresis on) | 17,000 |
307
+
308
+ Building the R-tree takes 0.04 s for 10^4 fences and 0.5 s for 10^5 (depth 4 and 5).
309
+ Run `geofencing bench` to measure your own hardware.
310
+
311
+ ## Development
312
+
313
+ ```bash
314
+ pip install -e ".[dev]"
315
+ pytest --cov=geofencing
316
+ ruff check . && ruff format --check src tests && mypy src
317
+ ```
318
+
319
+ Coverage is about 99 % with the optional packages installed; the suite also runs
320
+ with the standard library alone (Shapely, pyproj and NumPy tests are skipped).
321
+
322
+ ## License
323
+
324
+ MIT (c) Yusuf Eminoğlu. See [LICENSE](LICENSE) and [CITATION.cff](CITATION.cff).