geofencing 0.1.0__py3-none-any.whl
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/__init__.py +72 -0
- geofencing/__main__.py +7 -0
- geofencing/_geodesy.py +172 -0
- geofencing/bench.py +191 -0
- geofencing/cli.py +141 -0
- geofencing/engine.py +504 -0
- geofencing/events.py +121 -0
- geofencing/fences.py +845 -0
- geofencing/index.py +234 -0
- geofencing/io.py +254 -0
- geofencing/py.typed +0 -0
- geofencing/rtree.py +112 -0
- geofencing/schedule.py +242 -0
- geofencing-0.1.0.dist-info/METADATA +324 -0
- geofencing-0.1.0.dist-info/RECORD +19 -0
- geofencing-0.1.0.dist-info/WHEEL +5 -0
- geofencing-0.1.0.dist-info/entry_points.txt +2 -0
- geofencing-0.1.0.dist-info/licenses/LICENSE +21 -0
- geofencing-0.1.0.dist-info/top_level.txt +1 -0
geofencing/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""geofencing: fast, dependency-free geofence checks and enter/exit/dwell events.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from geofencing import CircleFence, FenceIndex, GeofenceEngine
|
|
6
|
+
|
|
7
|
+
idx = FenceIndex([CircleFence("depot", 29.02, 41.01, radius_m=150)])
|
|
8
|
+
idx.containing_ids(29.021, 41.0105) # ['depot']
|
|
9
|
+
|
|
10
|
+
engine = GeofenceEngine(idx, min_inside_s=10, buffer_m=15, dwell_s=300)
|
|
11
|
+
for event in engine.process_many(positions):
|
|
12
|
+
print(event.to_dict())
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ._geodesy import geodesic_distance_m, haversine_m, normalize_lon
|
|
16
|
+
from .engine import EngineConfig, GeofenceEngine
|
|
17
|
+
from .events import Event, EventType, Position
|
|
18
|
+
from .fences import (
|
|
19
|
+
BOUNDARY,
|
|
20
|
+
INSIDE,
|
|
21
|
+
OUTSIDE,
|
|
22
|
+
BBoxFence,
|
|
23
|
+
CircleFence,
|
|
24
|
+
CorridorFence,
|
|
25
|
+
Fence,
|
|
26
|
+
PolygonFence,
|
|
27
|
+
)
|
|
28
|
+
from .index import FenceIndex
|
|
29
|
+
from .io import (
|
|
30
|
+
fence_from_feature,
|
|
31
|
+
fences_from_geojson,
|
|
32
|
+
fences_to_geojson,
|
|
33
|
+
load_fences,
|
|
34
|
+
read_positions,
|
|
35
|
+
save_fences,
|
|
36
|
+
write_events,
|
|
37
|
+
)
|
|
38
|
+
from .rtree import STRTree
|
|
39
|
+
from .schedule import Schedule, ScheduleRule
|
|
40
|
+
|
|
41
|
+
__version__ = "0.1.0"
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"BOUNDARY",
|
|
45
|
+
"INSIDE",
|
|
46
|
+
"OUTSIDE",
|
|
47
|
+
"BBoxFence",
|
|
48
|
+
"CircleFence",
|
|
49
|
+
"CorridorFence",
|
|
50
|
+
"EngineConfig",
|
|
51
|
+
"Event",
|
|
52
|
+
"EventType",
|
|
53
|
+
"Fence",
|
|
54
|
+
"FenceIndex",
|
|
55
|
+
"GeofenceEngine",
|
|
56
|
+
"PolygonFence",
|
|
57
|
+
"Position",
|
|
58
|
+
"STRTree",
|
|
59
|
+
"Schedule",
|
|
60
|
+
"ScheduleRule",
|
|
61
|
+
"__version__",
|
|
62
|
+
"fence_from_feature",
|
|
63
|
+
"fences_from_geojson",
|
|
64
|
+
"fences_to_geojson",
|
|
65
|
+
"geodesic_distance_m",
|
|
66
|
+
"haversine_m",
|
|
67
|
+
"load_fences",
|
|
68
|
+
"normalize_lon",
|
|
69
|
+
"read_positions",
|
|
70
|
+
"save_fences",
|
|
71
|
+
"write_events",
|
|
72
|
+
]
|
geofencing/__main__.py
ADDED
geofencing/_geodesy.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Geodesy helpers: distances, destinations and longitude arithmetic.
|
|
2
|
+
|
|
3
|
+
Everything here is pure Python (``math`` only). Coordinates are always
|
|
4
|
+
``(lon, lat)`` in decimal degrees on WGS84.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from typing import Optional, Tuple
|
|
11
|
+
|
|
12
|
+
#: Mean Earth radius (IUGG), metres.
|
|
13
|
+
EARTH_RADIUS_M = 6371008.8
|
|
14
|
+
#: WGS84 semi-major axis, metres.
|
|
15
|
+
WGS84_A = 6378137.0
|
|
16
|
+
#: WGS84 flattening.
|
|
17
|
+
WGS84_F = 1.0 / 298.257223563
|
|
18
|
+
#: Metres per degree of latitude on the mean sphere.
|
|
19
|
+
M_PER_DEG = EARTH_RADIUS_M * math.pi / 180.0
|
|
20
|
+
|
|
21
|
+
#: Bound on ``|ellipsoidal / spherical - 1|`` for any pair of points. The local
|
|
22
|
+
#: radii of curvature of WGS84 lie within -0.56 % / +0.46 % of the mean radius, so
|
|
23
|
+
#: 1 % is a safe band outside which the fast spherical answer is already decisive.
|
|
24
|
+
SPHERE_ELLIPSOID_BAND = 0.01
|
|
25
|
+
|
|
26
|
+
_DEG = math.pi / 180.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalize_lon(lon: float) -> float:
|
|
30
|
+
"""Wrap a longitude into ``[-180, 180)``."""
|
|
31
|
+
if -180.0 <= lon < 180.0:
|
|
32
|
+
return lon
|
|
33
|
+
return ((lon + 180.0) % 360.0) - 180.0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def wrap_delta(d: float) -> float:
|
|
37
|
+
"""Wrap a longitude difference into ``[-180, 180]``."""
|
|
38
|
+
if -180.0 <= d <= 180.0:
|
|
39
|
+
return d
|
|
40
|
+
d = ((d + 180.0) % 360.0) - 180.0
|
|
41
|
+
return d
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def metres_per_degree_lon(lat: float) -> float:
|
|
45
|
+
"""Metres per degree of longitude at ``lat`` (never exactly zero)."""
|
|
46
|
+
return max(math.cos(lat * _DEG) * M_PER_DEG, 1e-6)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def haversine_m(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
|
|
50
|
+
"""Great-circle distance on the mean sphere, metres."""
|
|
51
|
+
p1 = lat1 * _DEG
|
|
52
|
+
p2 = lat2 * _DEG
|
|
53
|
+
dp = p2 - p1
|
|
54
|
+
dl = (lon2 - lon1) * _DEG
|
|
55
|
+
h = math.sin(dp * 0.5) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl * 0.5) ** 2
|
|
56
|
+
if h > 1.0:
|
|
57
|
+
h = 1.0
|
|
58
|
+
return 2.0 * EARTH_RADIUS_M * math.asin(math.sqrt(h))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def vincenty_inverse_m(
|
|
62
|
+
lon1: float, lat1: float, lon2: float, lat2: float, max_iter: int = 200
|
|
63
|
+
) -> Optional[float]:
|
|
64
|
+
"""Ellipsoidal (WGS84) distance by Vincenty's inverse formula, metres.
|
|
65
|
+
|
|
66
|
+
Accurate to well below a millimetre. Returns ``None`` when the iteration
|
|
67
|
+
does not converge (nearly antipodal points).
|
|
68
|
+
"""
|
|
69
|
+
a = WGS84_A
|
|
70
|
+
f = WGS84_F
|
|
71
|
+
b = a * (1.0 - f)
|
|
72
|
+
big_l = wrap_delta(lon2 - lon1) * _DEG
|
|
73
|
+
phi1 = lat1 * _DEG
|
|
74
|
+
phi2 = lat2 * _DEG
|
|
75
|
+
u1 = math.atan2((1.0 - f) * math.sin(phi1), math.cos(phi1))
|
|
76
|
+
u2 = math.atan2((1.0 - f) * math.sin(phi2), math.cos(phi2))
|
|
77
|
+
sin_u1, cos_u1 = math.sin(u1), math.cos(u1)
|
|
78
|
+
sin_u2, cos_u2 = math.sin(u2), math.cos(u2)
|
|
79
|
+
lam = big_l
|
|
80
|
+
sin_sigma = cos_sigma = sigma = cos2_alpha = cos_2sm = 0.0
|
|
81
|
+
for _ in range(max_iter):
|
|
82
|
+
sin_lam = math.sin(lam)
|
|
83
|
+
cos_lam = math.cos(lam)
|
|
84
|
+
sin_sigma = math.hypot(cos_u2 * sin_lam, cos_u1 * sin_u2 - sin_u1 * cos_u2 * cos_lam)
|
|
85
|
+
if sin_sigma == 0.0:
|
|
86
|
+
return 0.0
|
|
87
|
+
cos_sigma = sin_u1 * sin_u2 + cos_u1 * cos_u2 * cos_lam
|
|
88
|
+
sigma = math.atan2(sin_sigma, cos_sigma)
|
|
89
|
+
sin_alpha = cos_u1 * cos_u2 * sin_lam / sin_sigma
|
|
90
|
+
cos2_alpha = 1.0 - sin_alpha * sin_alpha
|
|
91
|
+
cos_2sm = cos_sigma - 2.0 * sin_u1 * sin_u2 / cos2_alpha if cos2_alpha != 0.0 else 0.0
|
|
92
|
+
c = f / 16.0 * cos2_alpha * (4.0 + f * (4.0 - 3.0 * cos2_alpha))
|
|
93
|
+
lam_prev = lam
|
|
94
|
+
lam = big_l + (1.0 - c) * f * sin_alpha * (
|
|
95
|
+
sigma + c * sin_sigma * (cos_2sm + c * cos_sigma * (-1.0 + 2.0 * cos_2sm * cos_2sm))
|
|
96
|
+
)
|
|
97
|
+
if abs(lam - lam_prev) < 1e-12:
|
|
98
|
+
break
|
|
99
|
+
else:
|
|
100
|
+
return None
|
|
101
|
+
u_sq = cos2_alpha * (a * a - b * b) / (b * b)
|
|
102
|
+
big_a = 1.0 + u_sq / 16384.0 * (4096.0 + u_sq * (-768.0 + u_sq * (320.0 - 175.0 * u_sq)))
|
|
103
|
+
big_b = u_sq / 1024.0 * (256.0 + u_sq * (-128.0 + u_sq * (74.0 - 47.0 * u_sq)))
|
|
104
|
+
d_sigma = (
|
|
105
|
+
big_b
|
|
106
|
+
* sin_sigma
|
|
107
|
+
* (
|
|
108
|
+
cos_2sm
|
|
109
|
+
+ big_b
|
|
110
|
+
/ 4.0
|
|
111
|
+
* (
|
|
112
|
+
cos_sigma * (-1.0 + 2.0 * cos_2sm * cos_2sm)
|
|
113
|
+
- big_b
|
|
114
|
+
/ 6.0
|
|
115
|
+
* cos_2sm
|
|
116
|
+
* (-3.0 + 4.0 * sin_sigma * sin_sigma)
|
|
117
|
+
* (-3.0 + 4.0 * cos_2sm * cos_2sm)
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
return b * big_a * (sigma - d_sigma)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def geodesic_distance_m(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
|
|
125
|
+
"""Ellipsoidal distance, falling back to the sphere for antipodal pairs."""
|
|
126
|
+
d = vincenty_inverse_m(lon1, lat1, lon2, lat2)
|
|
127
|
+
if d is None:
|
|
128
|
+
return haversine_m(lon1, lat1, lon2, lat2)
|
|
129
|
+
return d
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def destination(
|
|
133
|
+
lon: float, lat: float, bearing_deg: float, distance_m: float
|
|
134
|
+
) -> Tuple[float, float]:
|
|
135
|
+
"""Point reached from ``(lon, lat)`` along a great circle (mean sphere)."""
|
|
136
|
+
delta = distance_m / EARTH_RADIUS_M
|
|
137
|
+
theta = bearing_deg * _DEG
|
|
138
|
+
phi1 = lat * _DEG
|
|
139
|
+
lam1 = lon * _DEG
|
|
140
|
+
sin_phi2 = math.sin(phi1) * math.cos(delta) + math.cos(phi1) * math.sin(delta) * math.cos(theta)
|
|
141
|
+
sin_phi2 = max(-1.0, min(1.0, sin_phi2))
|
|
142
|
+
phi2 = math.asin(sin_phi2)
|
|
143
|
+
y = math.sin(theta) * math.sin(delta) * math.cos(phi1)
|
|
144
|
+
x = math.cos(delta) - math.sin(phi1) * sin_phi2
|
|
145
|
+
lam2 = lam1 + math.atan2(y, x)
|
|
146
|
+
return normalize_lon(lam2 / _DEG), phi2 / _DEG
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def unit_vector(lon: float, lat: float) -> Tuple[float, float, float]:
|
|
150
|
+
"""Earth-centred unit vector of a point on the sphere."""
|
|
151
|
+
p = lat * _DEG
|
|
152
|
+
lam = lon * _DEG
|
|
153
|
+
cp = math.cos(p)
|
|
154
|
+
return (cp * math.cos(lam), cp * math.sin(lam), math.sin(p))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cap_bbox(lon: float, lat: float, angle_rad: float) -> Tuple[float, float, float, float]:
|
|
158
|
+
"""Lon/lat bounding box of a spherical cap (centre, angular radius).
|
|
159
|
+
|
|
160
|
+
Longitudes are *unwrapped* around ``lon`` (they may leave ``[-180, 180]``).
|
|
161
|
+
A cap that reaches a pole spans the full 360 degrees of longitude.
|
|
162
|
+
"""
|
|
163
|
+
r_deg = angle_rad / _DEG
|
|
164
|
+
south = lat - r_deg
|
|
165
|
+
north = lat + r_deg
|
|
166
|
+
if north >= 90.0 or south <= -90.0 or angle_rad >= math.pi / 2:
|
|
167
|
+
return (-180.0, max(south, -90.0), 180.0, min(north, 90.0))
|
|
168
|
+
s = math.sin(angle_rad) / math.cos(lat * _DEG)
|
|
169
|
+
if s >= 1.0: # pragma: no cover - rounding guard (implies north >= 90)
|
|
170
|
+
return (-180.0, south, 180.0, north)
|
|
171
|
+
dlon = math.asin(s) / _DEG
|
|
172
|
+
return (lon - dlon, south, lon + dlon, north)
|
geofencing/bench.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Synthetic benchmarks (``geofencing bench``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from .engine import GeofenceEngine
|
|
11
|
+
from .events import Position
|
|
12
|
+
from .fences import HAVE_NUMPY, CircleFence, Fence, PolygonFence
|
|
13
|
+
from .index import FenceIndex
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def star_polygon(
|
|
17
|
+
rng: random.Random, lon: float, lat: float, radius_deg: float, n: int, concavity: float = 0.5
|
|
18
|
+
) -> List[List[float]]:
|
|
19
|
+
"""A random star-shaped (generally concave) ring, closed, CCW."""
|
|
20
|
+
ring = []
|
|
21
|
+
for i in range(n):
|
|
22
|
+
a = 2.0 * math.pi * i / n
|
|
23
|
+
r = radius_deg * (1.0 - concavity * rng.random())
|
|
24
|
+
ring.append(
|
|
25
|
+
[lon + r * math.cos(a) / max(math.cos(math.radians(lat)), 0.05), lat + r * math.sin(a)]
|
|
26
|
+
)
|
|
27
|
+
ring.append(ring[0])
|
|
28
|
+
return ring
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def synthetic_fences(
|
|
32
|
+
n: int,
|
|
33
|
+
seed: int = 0,
|
|
34
|
+
region: tuple = (28.6, 40.8, 29.6, 41.3),
|
|
35
|
+
vertices: int = 24,
|
|
36
|
+
) -> List[Fence]:
|
|
37
|
+
"""``n`` fences (75 % concave polygons, 25 % circles) scattered over ``region``."""
|
|
38
|
+
rng = random.Random(seed)
|
|
39
|
+
w, s, e, nn = region
|
|
40
|
+
out: List[Fence] = []
|
|
41
|
+
for i in range(n):
|
|
42
|
+
lon = rng.uniform(w, e)
|
|
43
|
+
lat = rng.uniform(s, nn)
|
|
44
|
+
if i % 4 == 3:
|
|
45
|
+
out.append(CircleFence(f"c{i}", lon, lat, rng.uniform(100.0, 800.0)))
|
|
46
|
+
else:
|
|
47
|
+
r = rng.uniform(0.002, 0.008)
|
|
48
|
+
out.append(PolygonFence.from_polygon(f"p{i}", star_polygon(rng, lon, lat, r, vertices)))
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _rate(n: int, seconds: float) -> float:
|
|
53
|
+
return n / seconds if seconds > 0 else float("inf")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run_benchmarks(
|
|
57
|
+
n_fences: int = 10_000, n_points: int = 20_000, seed: int = 0, include_engine: bool = True
|
|
58
|
+
) -> List[Dict[str, Any]]:
|
|
59
|
+
"""Measure points/s for 1 and ``n_fences`` fences; returns result rows."""
|
|
60
|
+
rng = random.Random(seed)
|
|
61
|
+
results: List[Dict[str, Any]] = []
|
|
62
|
+
# Fence density is held at 10^4 fences per 1 x 0.5 degree (about 85 x 55 km)
|
|
63
|
+
# so larger runs measure index scaling rather than ever-deeper overlap.
|
|
64
|
+
k = max(1.0, math.sqrt(n_fences / 10_000))
|
|
65
|
+
region = (29.1 - 0.5 * k, 41.05 - 0.25 * k, 29.1 + 0.5 * k, 41.05 + 0.25 * k)
|
|
66
|
+
|
|
67
|
+
def points(k: int, box: tuple) -> tuple:
|
|
68
|
+
w, s, e, n = box
|
|
69
|
+
return [rng.uniform(w, e) for _ in range(k)], [rng.uniform(s, n) for _ in range(k)]
|
|
70
|
+
|
|
71
|
+
# 1 fence: a 256-vertex concave polygon, points in its bounding box.
|
|
72
|
+
single = PolygonFence.from_polygon("big", star_polygon(rng, 29.0, 41.0, 0.2, 256))
|
|
73
|
+
idx1 = FenceIndex([single])
|
|
74
|
+
w, s, e, n = single.bbox()
|
|
75
|
+
xs, ys = points(n_points, (w, s, e, n))
|
|
76
|
+
t0 = time.perf_counter()
|
|
77
|
+
hits = sum(1 for x, y in zip(xs, ys) if idx1.containing(x, y))
|
|
78
|
+
dt = time.perf_counter() - t0
|
|
79
|
+
results.append(
|
|
80
|
+
{
|
|
81
|
+
"scenario": "1 polygon (256 vertices)",
|
|
82
|
+
"method": "index.containing",
|
|
83
|
+
"points": n_points,
|
|
84
|
+
"seconds": dt,
|
|
85
|
+
"points_per_s": _rate(n_points, dt),
|
|
86
|
+
"hits": hits,
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
t0 = time.perf_counter()
|
|
90
|
+
hits = sum(1 for x, y in zip(xs, ys) if single.contains(x, y))
|
|
91
|
+
dt = time.perf_counter() - t0
|
|
92
|
+
results.append(
|
|
93
|
+
{
|
|
94
|
+
"scenario": "1 polygon (256 vertices)",
|
|
95
|
+
"method": "fence.contains",
|
|
96
|
+
"points": n_points,
|
|
97
|
+
"seconds": dt,
|
|
98
|
+
"points_per_s": _rate(n_points, dt),
|
|
99
|
+
"hits": hits,
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
if HAVE_NUMPY:
|
|
103
|
+
t0 = time.perf_counter()
|
|
104
|
+
res = idx1.contains_many(xs, ys)
|
|
105
|
+
dt = time.perf_counter() - t0
|
|
106
|
+
results.append(
|
|
107
|
+
{
|
|
108
|
+
"scenario": "1 polygon (256 vertices)",
|
|
109
|
+
"method": "index.contains_many (numpy)",
|
|
110
|
+
"points": n_points,
|
|
111
|
+
"seconds": dt,
|
|
112
|
+
"points_per_s": _rate(n_points, dt),
|
|
113
|
+
"hits": sum(1 for r in res if r),
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Many fences.
|
|
118
|
+
t0 = time.perf_counter()
|
|
119
|
+
fences = synthetic_fences(n_fences, seed=seed, region=region)
|
|
120
|
+
idx = FenceIndex(fences)
|
|
121
|
+
build = time.perf_counter() - t0
|
|
122
|
+
xs, ys = points(n_points, region)
|
|
123
|
+
t0 = time.perf_counter()
|
|
124
|
+
hits = sum(len(idx.containing(x, y)) for x, y in zip(xs, ys))
|
|
125
|
+
dt = time.perf_counter() - t0
|
|
126
|
+
label = f"{n_fences} fences"
|
|
127
|
+
results.append(
|
|
128
|
+
{
|
|
129
|
+
"scenario": label,
|
|
130
|
+
"method": "index.containing",
|
|
131
|
+
"points": n_points,
|
|
132
|
+
"seconds": dt,
|
|
133
|
+
"points_per_s": _rate(n_points, dt),
|
|
134
|
+
"hits": hits,
|
|
135
|
+
"build_s": build,
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
if HAVE_NUMPY:
|
|
139
|
+
t0 = time.perf_counter()
|
|
140
|
+
res = idx.contains_many(xs, ys)
|
|
141
|
+
dt = time.perf_counter() - t0
|
|
142
|
+
results.append(
|
|
143
|
+
{
|
|
144
|
+
"scenario": label,
|
|
145
|
+
"method": "index.contains_many (numpy)",
|
|
146
|
+
"points": n_points,
|
|
147
|
+
"seconds": dt,
|
|
148
|
+
"points_per_s": _rate(n_points, dt),
|
|
149
|
+
"hits": sum(len(r) for r in res),
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
if include_engine:
|
|
153
|
+
eng = GeofenceEngine(idx, min_inside_s=5, min_outside_s=5, buffer_m=10, dwell_s=60)
|
|
154
|
+
n_dev = 100
|
|
155
|
+
steps = max(1, n_points // n_dev)
|
|
156
|
+
lon = [rng.uniform(region[0], region[2]) for _ in range(n_dev)]
|
|
157
|
+
lat = [rng.uniform(region[1], region[3]) for _ in range(n_dev)]
|
|
158
|
+
fixes: List[Position] = []
|
|
159
|
+
for step in range(steps):
|
|
160
|
+
for d in range(n_dev):
|
|
161
|
+
lon[d] += rng.gauss(0, 0.0003)
|
|
162
|
+
lat[d] += rng.gauss(0, 0.0003)
|
|
163
|
+
fixes.append(Position(f"d{d}", lon[d], lat[d], float(step), 5.0))
|
|
164
|
+
t0 = time.perf_counter()
|
|
165
|
+
n_ev = sum(1 for _ in eng.process_many(fixes))
|
|
166
|
+
dt = time.perf_counter() - t0
|
|
167
|
+
results.append(
|
|
168
|
+
{
|
|
169
|
+
"scenario": label,
|
|
170
|
+
"method": "engine.process (100 devices)",
|
|
171
|
+
"points": len(fixes),
|
|
172
|
+
"seconds": dt,
|
|
173
|
+
"points_per_s": _rate(len(fixes), dt),
|
|
174
|
+
"hits": n_ev,
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
return results
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def format_results(rows: List[Dict[str, Any]], numpy: Optional[bool] = None) -> str:
|
|
181
|
+
"""Plain-text table of :func:`run_benchmarks` output."""
|
|
182
|
+
lines = [f"{'scenario':<28} {'method':<30} {'points':>8} {'points/s':>12}"]
|
|
183
|
+
lines.append("-" * len(lines[0]))
|
|
184
|
+
for r in rows:
|
|
185
|
+
lines.append(
|
|
186
|
+
f"{r['scenario']:<28} {r['method']:<30} {r['points']:>8} {r['points_per_s']:>12,.0f}"
|
|
187
|
+
)
|
|
188
|
+
if numpy is None:
|
|
189
|
+
numpy = HAVE_NUMPY
|
|
190
|
+
lines.append(f"(numpy {'available' if numpy else 'not installed'})")
|
|
191
|
+
return "\n".join(lines)
|
geofencing/cli.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Command line interface: ``geofencing check | replay | bench``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .engine import EngineConfig, GeofenceEngine
|
|
12
|
+
from .index import FenceIndex
|
|
13
|
+
from .io import load_fences, read_positions, write_events
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _cmd_check(args: argparse.Namespace) -> int:
|
|
17
|
+
idx = FenceIndex(load_fences(args.fences))
|
|
18
|
+
kw = {}
|
|
19
|
+
if args.tolerance is not None:
|
|
20
|
+
kw["tolerance_m"] = args.tolerance
|
|
21
|
+
if args.exclusive:
|
|
22
|
+
kw["inclusive"] = False
|
|
23
|
+
hits = idx.containing(args.lon, args.lat, args.time, **kw)
|
|
24
|
+
out = {
|
|
25
|
+
"lon": args.lon,
|
|
26
|
+
"lat": args.lat,
|
|
27
|
+
"inside": [f.id for f in hits],
|
|
28
|
+
"speed_limit": idx.speed_limit_at(args.lon, args.lat, args.time),
|
|
29
|
+
}
|
|
30
|
+
if args.time is not None:
|
|
31
|
+
out["time"] = args.time
|
|
32
|
+
print(json.dumps(out))
|
|
33
|
+
return 0 if hits or not args.exit_code else 1
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _cmd_replay(args: argparse.Namespace) -> int:
|
|
37
|
+
idx = FenceIndex(load_fences(args.fences))
|
|
38
|
+
cfg = EngineConfig(
|
|
39
|
+
min_inside_s=args.min_inside,
|
|
40
|
+
min_outside_s=args.min_outside,
|
|
41
|
+
buffer_m=args.buffer,
|
|
42
|
+
dwell_s=args.dwell,
|
|
43
|
+
dwell_repeat_s=args.dwell_repeat,
|
|
44
|
+
max_accuracy_m=args.max_accuracy,
|
|
45
|
+
poor_accuracy=args.poor_accuracy,
|
|
46
|
+
out_of_order=args.out_of_order,
|
|
47
|
+
reorder_window_s=args.reorder_window,
|
|
48
|
+
)
|
|
49
|
+
eng = GeofenceEngine(idx, cfg)
|
|
50
|
+
if args.state_in:
|
|
51
|
+
eng.load_state(args.state_in)
|
|
52
|
+
positions = read_positions(args.positions, fmt=args.format)
|
|
53
|
+
events = eng.process_many(positions)
|
|
54
|
+
if args.output in (None, "-"):
|
|
55
|
+
n = write_events(events, sys.stdout)
|
|
56
|
+
else:
|
|
57
|
+
n = write_events(events, args.output)
|
|
58
|
+
if args.state_out:
|
|
59
|
+
eng.save_state(args.state_out)
|
|
60
|
+
s = eng.stats
|
|
61
|
+
print(
|
|
62
|
+
f"{n} events from {s['fixes']} fixes "
|
|
63
|
+
f"({s['ignored_accuracy']} ignored for accuracy, "
|
|
64
|
+
f"{s['dropped_out_of_order']} out of order) against {len(idx)} fences",
|
|
65
|
+
file=sys.stderr,
|
|
66
|
+
)
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _cmd_bench(args: argparse.Namespace) -> int:
|
|
71
|
+
from .bench import format_results, run_benchmarks
|
|
72
|
+
|
|
73
|
+
rows = run_benchmarks(args.fences, args.points, args.seed, include_engine=not args.no_engine)
|
|
74
|
+
if args.json:
|
|
75
|
+
print(json.dumps(rows, indent=2))
|
|
76
|
+
else:
|
|
77
|
+
print(format_results(rows))
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
82
|
+
p = argparse.ArgumentParser(prog="geofencing", description="Fast geofence checks and events.")
|
|
83
|
+
p.add_argument("--version", action="version", version=f"geofencing {__version__}")
|
|
84
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
85
|
+
|
|
86
|
+
c = sub.add_parser("check", help="which fences contain a point")
|
|
87
|
+
c.add_argument("fences", help="GeoJSON file with fences")
|
|
88
|
+
c.add_argument("--lat", type=float, required=True)
|
|
89
|
+
c.add_argument("--lon", type=float, required=True)
|
|
90
|
+
c.add_argument("--time", help="ISO 8601 time or POSIX seconds (for scheduled fences)")
|
|
91
|
+
c.add_argument("--tolerance", type=float, help="boundary tolerance in metres")
|
|
92
|
+
c.add_argument("--exclusive", action="store_true", help="points on the boundary are outside")
|
|
93
|
+
c.add_argument(
|
|
94
|
+
"--exit-code", action="store_true", help="exit 1 when no fence contains the point"
|
|
95
|
+
)
|
|
96
|
+
c.set_defaults(func=_cmd_check)
|
|
97
|
+
|
|
98
|
+
r = sub.add_parser("replay", help="replay a position file through the event engine")
|
|
99
|
+
r.add_argument("fences", help="GeoJSON file with fences")
|
|
100
|
+
r.add_argument("positions", help="CSV or JSONL positions")
|
|
101
|
+
r.add_argument("-o", "--output", help="events JSONL (default: stdout)")
|
|
102
|
+
r.add_argument(
|
|
103
|
+
"--format", choices=["csv", "jsonl"], help="positions format (default: by extension)"
|
|
104
|
+
)
|
|
105
|
+
r.add_argument("--min-inside", type=float, default=0.0, metavar="S")
|
|
106
|
+
r.add_argument("--min-outside", type=float, default=0.0, metavar="S")
|
|
107
|
+
r.add_argument(
|
|
108
|
+
"--buffer", type=float, default=0.0, metavar="M", help="hysteresis band in metres"
|
|
109
|
+
)
|
|
110
|
+
r.add_argument("--dwell", type=float, metavar="S", help="emit DWELL after S seconds inside")
|
|
111
|
+
r.add_argument("--dwell-repeat", type=float, metavar="S")
|
|
112
|
+
r.add_argument("--max-accuracy", type=float, metavar="M")
|
|
113
|
+
r.add_argument("--poor-accuracy", choices=["ignore", "uncertain"], default="ignore")
|
|
114
|
+
r.add_argument("--out-of-order", choices=["drop", "reorder"], default="drop")
|
|
115
|
+
r.add_argument("--reorder-window", type=float, default=0.0, metavar="S")
|
|
116
|
+
r.add_argument("--state-in", help="resume from a saved engine state (JSON)")
|
|
117
|
+
r.add_argument("--state-out", help="save the engine state (JSON) at the end")
|
|
118
|
+
r.set_defaults(func=_cmd_replay)
|
|
119
|
+
|
|
120
|
+
b = sub.add_parser("bench", help="synthetic throughput benchmark")
|
|
121
|
+
b.add_argument("--fences", type=int, default=10_000)
|
|
122
|
+
b.add_argument("--points", type=int, default=20_000)
|
|
123
|
+
b.add_argument("--seed", type=int, default=0)
|
|
124
|
+
b.add_argument("--no-engine", action="store_true")
|
|
125
|
+
b.add_argument("--json", action="store_true")
|
|
126
|
+
b.set_defaults(func=_cmd_bench)
|
|
127
|
+
return p
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
131
|
+
parser = build_parser()
|
|
132
|
+
args = parser.parse_args(argv)
|
|
133
|
+
try:
|
|
134
|
+
return int(args.func(args))
|
|
135
|
+
except (OSError, ValueError) as exc:
|
|
136
|
+
print(f"geofencing: error: {exc}", file=sys.stderr)
|
|
137
|
+
return 2
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__": # pragma: no cover
|
|
141
|
+
sys.exit(main())
|