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 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
@@ -0,0 +1,7 @@
1
+ """``python -m geofencing``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
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())