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.
- geofencing-0.1.0/CHANGELOG.md +40 -0
- geofencing-0.1.0/CITATION.cff +21 -0
- geofencing-0.1.0/LICENSE +21 -0
- geofencing-0.1.0/MANIFEST.in +9 -0
- geofencing-0.1.0/PKG-INFO +324 -0
- geofencing-0.1.0/README.md +276 -0
- geofencing-0.1.0/examples/fences.geojson +42 -0
- geofencing-0.1.0/examples/positions.csv +53 -0
- geofencing-0.1.0/examples/quickstart.py +28 -0
- geofencing-0.1.0/pyproject.toml +137 -0
- geofencing-0.1.0/setup.cfg +4 -0
- geofencing-0.1.0/src/geofencing/__init__.py +72 -0
- geofencing-0.1.0/src/geofencing/__main__.py +7 -0
- geofencing-0.1.0/src/geofencing/_geodesy.py +172 -0
- geofencing-0.1.0/src/geofencing/bench.py +191 -0
- geofencing-0.1.0/src/geofencing/cli.py +141 -0
- geofencing-0.1.0/src/geofencing/engine.py +504 -0
- geofencing-0.1.0/src/geofencing/events.py +121 -0
- geofencing-0.1.0/src/geofencing/fences.py +845 -0
- geofencing-0.1.0/src/geofencing/index.py +234 -0
- geofencing-0.1.0/src/geofencing/io.py +254 -0
- geofencing-0.1.0/src/geofencing/py.typed +0 -0
- geofencing-0.1.0/src/geofencing/rtree.py +112 -0
- geofencing-0.1.0/src/geofencing/schedule.py +242 -0
- geofencing-0.1.0/src/geofencing.egg-info/PKG-INFO +324 -0
- geofencing-0.1.0/src/geofencing.egg-info/SOURCES.txt +37 -0
- geofencing-0.1.0/src/geofencing.egg-info/dependency_links.txt +1 -0
- geofencing-0.1.0/src/geofencing.egg-info/entry_points.txt +2 -0
- geofencing-0.1.0/src/geofencing.egg-info/requires.txt +20 -0
- geofencing-0.1.0/src/geofencing.egg-info/top_level.txt +1 -0
- geofencing-0.1.0/tests/conftest.py +25 -0
- geofencing-0.1.0/tests/test_antimeridian_polar.py +154 -0
- geofencing-0.1.0/tests/test_circle_corridor.py +124 -0
- geofencing-0.1.0/tests/test_engine.py +353 -0
- geofencing-0.1.0/tests/test_geodesy.py +77 -0
- geofencing-0.1.0/tests/test_index.py +146 -0
- geofencing-0.1.0/tests/test_io_cli.py +314 -0
- geofencing-0.1.0/tests/test_polygon.py +241 -0
- 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
|
geofencing-0.1.0/LICENSE
ADDED
|
@@ -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,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
|
+
[](https://pypi.org/project/geofencing/)
|
|
52
|
+
[](https://pypi.org/project/geofencing/)
|
|
53
|
+
[](https://github.com/YusufEminoglu/geofencing_sdk/actions/workflows/ci.yml)
|
|
54
|
+
[](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).
|