taglogger-sdk 1.0.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.
@@ -0,0 +1,55 @@
1
+ """Official Python SDK for the TagLogger API.
2
+
3
+ Quick start::
4
+
5
+ from taglogger_sdk import TagLoggerClient
6
+
7
+ client = TagLoggerClient(api_key="tl_live_...")
8
+ for tag in client.tags.iterate():
9
+ print(tag["id"], tag.get("name"))
10
+
11
+ The :mod:`taglogger_sdk.analytics` subpackage bundles a pure-math, client-side
12
+ location-analytics toolkit (conditioning, stops/trips, dwell, clustering,
13
+ GeoJSON/polyline shaping, battery projection) that runs entirely in-process.
14
+ """
15
+
16
+ from . import analytics
17
+ from .client import TagLoggerClient
18
+ from .errors import TagLoggerError
19
+ from .http import (
20
+ DEFAULT_BASE_URL,
21
+ DEFAULT_MAX_RESPONSE_BYTES,
22
+ SDK_VERSION,
23
+ HttpClient,
24
+ HttpResponse,
25
+ Transport,
26
+ make_default_transport,
27
+ )
28
+ from .pagination import Paginator
29
+ from .webhooks import (
30
+ SIGNATURE_HEADER,
31
+ compute_webhook_signature,
32
+ construct_webhook_event,
33
+ verify_webhook_signature,
34
+ )
35
+
36
+ __version__ = SDK_VERSION
37
+
38
+ __all__ = [
39
+ "__version__",
40
+ "TagLoggerClient",
41
+ "TagLoggerError",
42
+ "HttpClient",
43
+ "HttpResponse",
44
+ "Transport",
45
+ "make_default_transport",
46
+ "DEFAULT_BASE_URL",
47
+ "DEFAULT_MAX_RESPONSE_BYTES",
48
+ "SDK_VERSION",
49
+ "Paginator",
50
+ "compute_webhook_signature",
51
+ "verify_webhook_signature",
52
+ "construct_webhook_event",
53
+ "SIGNATURE_HEADER",
54
+ "analytics",
55
+ ]
@@ -0,0 +1,110 @@
1
+ """Bundled client-side analytics for TagLogger location data.
2
+
3
+ Pure-math helpers that run entirely in-process over the location data you
4
+ already hold — no network calls, no provider dependencies. The typical pipeline
5
+ is: condition the raw fixes, then segment, cluster, or shape them.
6
+
7
+ Example::
8
+
9
+ from taglogger_sdk.analytics import (
10
+ condition_fixes,
11
+ detect_stops,
12
+ detect_trips,
13
+ fixes_from_location_points,
14
+ )
15
+
16
+ page = client.tags.history("tag-1")
17
+ fixes = fixes_from_location_points(page["data"])
18
+ clean = condition_fixes(fixes, max_accuracy_meters=100)
19
+ stops = detect_stops(clean, radius_meters=50, min_duration_ms=300000)
20
+ trips = detect_trips(clean, stops)
21
+ """
22
+
23
+ from .battery import BatteryEstimate, estimate_battery_days_remaining
24
+ from .conditioning import condition_fixes, dedupe_by_timestamp, sort_by_time
25
+ from .geometry import (
26
+ EARTH_RADIUS_METERS,
27
+ bearing_degrees,
28
+ bounding_box,
29
+ centroid,
30
+ decode_polyline,
31
+ douglas_peucker,
32
+ encode_polyline,
33
+ haversine_meters,
34
+ is_within_radius,
35
+ path_length_meters,
36
+ point_in_polygon,
37
+ )
38
+ from .segmentation import (
39
+ cluster_fixes,
40
+ compute_dwell,
41
+ detect_stops,
42
+ detect_trips,
43
+ )
44
+ from .shaping import (
45
+ fixes_to_feature,
46
+ stop_to_feature,
47
+ to_feature_collection,
48
+ to_line_string,
49
+ to_point,
50
+ to_polyline,
51
+ trip_to_feature,
52
+ )
53
+ from .types import (
54
+ BatterySample,
55
+ BoundingBox,
56
+ Cluster,
57
+ DwellInterval,
58
+ Fix,
59
+ LatLng,
60
+ Stop,
61
+ Trip,
62
+ fix_from_location_point,
63
+ fixes_from_location_points,
64
+ )
65
+
66
+ __all__ = [
67
+ # types
68
+ "LatLng",
69
+ "Fix",
70
+ "Stop",
71
+ "Trip",
72
+ "Cluster",
73
+ "DwellInterval",
74
+ "BoundingBox",
75
+ "BatterySample",
76
+ "BatteryEstimate",
77
+ "fix_from_location_point",
78
+ "fixes_from_location_points",
79
+ # geometry
80
+ "EARTH_RADIUS_METERS",
81
+ "haversine_meters",
82
+ "bearing_degrees",
83
+ "centroid",
84
+ "bounding_box",
85
+ "path_length_meters",
86
+ "point_in_polygon",
87
+ "is_within_radius",
88
+ "douglas_peucker",
89
+ "encode_polyline",
90
+ "decode_polyline",
91
+ # conditioning
92
+ "sort_by_time",
93
+ "dedupe_by_timestamp",
94
+ "condition_fixes",
95
+ # segmentation
96
+ "detect_stops",
97
+ "detect_trips",
98
+ "compute_dwell",
99
+ "cluster_fixes",
100
+ # shaping
101
+ "to_line_string",
102
+ "to_point",
103
+ "to_polyline",
104
+ "trip_to_feature",
105
+ "stop_to_feature",
106
+ "to_feature_collection",
107
+ "fixes_to_feature",
108
+ # battery
109
+ "estimate_battery_days_remaining",
110
+ ]
@@ -0,0 +1,102 @@
1
+ """Battery trend estimation. Given a history of timestamped battery levels, fit
2
+ a simple least-squares line and project when the level reaches zero.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import math
8
+ from collections.abc import Sequence
9
+ from dataclasses import dataclass
10
+
11
+ from .types import BatterySample
12
+
13
+ _MS_PER_DAY = 24 * 60 * 60 * 1000
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class BatteryEstimate:
18
+ """The outcome of a battery projection."""
19
+
20
+ #: Estimated days until the level reaches zero, or None if undetermined.
21
+ days_remaining: float | None
22
+ #: Modeled drain in level-units per day (positive means discharging).
23
+ drain_per_day: float | None
24
+ #: The most recent level the estimate is anchored to, normalized to [0, 1].
25
+ current_level: float | None
26
+ #: Why a projection could not be made, when days_remaining is None:
27
+ #: "insufficient_data", "not_discharging", or "degenerate_time".
28
+ reason: str | None = None
29
+
30
+
31
+ def _normalize_level(level: float) -> float:
32
+ """Normalize a level given as a 0–1 fraction or a 0–100 percentage to [0, 1]."""
33
+ v = level / 100 if level > 1 else level
34
+ return max(0.0, min(1.0, v))
35
+
36
+
37
+ def estimate_battery_days_remaining(
38
+ samples: Sequence[BatterySample],
39
+ ) -> BatteryEstimate:
40
+ """Estimate remaining battery life by least-squares fit of level against time.
41
+
42
+ Levels may be supplied as fractions (0–1) or percentages (0–100); the two
43
+ are detected per-sample and normalized. At least two readings at distinct
44
+ times are required. When the trend is flat or charging, ``days_remaining`` is
45
+ None with an explanatory ``reason``.
46
+ """
47
+ clean = sorted(
48
+ (
49
+ {"level": _normalize_level(s.level), "t": s.timestamp_ms}
50
+ for s in samples
51
+ if math.isfinite(s.level) and math.isfinite(s.timestamp_ms)
52
+ ),
53
+ key=lambda s: s["t"],
54
+ )
55
+
56
+ if len(clean) < 2:
57
+ return BatteryEstimate(
58
+ days_remaining=None,
59
+ drain_per_day=None,
60
+ current_level=clean[-1]["level"] if clean else None,
61
+ reason="insufficient_data",
62
+ )
63
+
64
+ t0 = clean[0]["t"]
65
+ # Regress level on elapsed days for numerically stable slope units.
66
+ xs: list[float] = [(s["t"] - t0) / _MS_PER_DAY for s in clean]
67
+ ys: list[float] = [s["level"] for s in clean]
68
+ n = len(clean)
69
+ mean_x = sum(xs) / n
70
+ mean_y = sum(ys) / n
71
+ sxx = 0.0
72
+ sxy = 0.0
73
+ for i in range(n):
74
+ dx = xs[i] - mean_x
75
+ sxx += dx * dx
76
+ sxy += dx * (ys[i] - mean_y)
77
+
78
+ current_level = ys[n - 1]
79
+ if sxx == 0:
80
+ return BatteryEstimate(
81
+ days_remaining=None,
82
+ drain_per_day=None,
83
+ current_level=current_level,
84
+ reason="degenerate_time",
85
+ )
86
+
87
+ slope_per_day = sxy / sxx # level change per day; negative while draining
88
+ drain_per_day = -slope_per_day
89
+ if drain_per_day <= 0:
90
+ return BatteryEstimate(
91
+ days_remaining=None,
92
+ drain_per_day=drain_per_day,
93
+ current_level=current_level,
94
+ reason="not_discharging",
95
+ )
96
+
97
+ days_remaining = current_level / drain_per_day
98
+ return BatteryEstimate(
99
+ days_remaining=days_remaining,
100
+ drain_per_day=drain_per_day,
101
+ current_level=current_level,
102
+ )
@@ -0,0 +1,80 @@
1
+ """Conditioning: turn a raw series of reported positions into a clean, ordered
2
+ series suitable for segmentation and clustering.
3
+
4
+ Reported positions arrive on an irregular, roughly five-minute cadence and may
5
+ be out of order, share timestamps, or carry coarse accuracy. Every other
6
+ analytics function assumes its input has already passed through here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from collections.abc import Sequence
13
+
14
+ from .types import Fix
15
+
16
+
17
+ def sort_by_time(fixes: Sequence[Fix]) -> list[Fix]:
18
+ """Stable sort of fixes by ascending timestamp. Does not mutate the input."""
19
+ # Python's sort is guaranteed stable, so equal timestamps keep input order.
20
+ return sorted(fixes, key=lambda fix: fix.timestamp_ms)
21
+
22
+
23
+ def _accuracy_of(fix: Fix) -> float:
24
+ accuracy = fix.accuracy
25
+ if isinstance(accuracy, (int, float)) and math.isfinite(accuracy):
26
+ return float(accuracy)
27
+ return math.inf
28
+
29
+
30
+ def dedupe_by_timestamp(fixes: Sequence[Fix]) -> list[Fix]:
31
+ """Collapse fixes that share a timestamp, keeping the most accurate one.
32
+
33
+ Ties resolve to the earliest in input order. Assumes the input is already
34
+ time-sorted.
35
+ """
36
+ out: list[Fix] = []
37
+ for fix in fixes:
38
+ last = out[-1] if out else None
39
+ if last is not None and last.timestamp_ms == fix.timestamp_ms:
40
+ if _accuracy_of(fix) < _accuracy_of(last):
41
+ out[-1] = fix
42
+ continue
43
+ out.append(fix)
44
+ return out
45
+
46
+
47
+ def condition_fixes(
48
+ fixes: Sequence[Fix],
49
+ *,
50
+ max_accuracy_meters: float = math.inf,
51
+ drop_inaccurate: bool = False,
52
+ drop_old: bool = False,
53
+ dedupe: bool = True,
54
+ ) -> list[Fix]:
55
+ """Filter, dedupe, and time-order a raw series in one pass.
56
+
57
+ Coordinates that are not finite numbers are always dropped. Returns a fresh
58
+ list.
59
+
60
+ A fix whose ``accuracy`` is unknown (missing or non-finite) is treated as
61
+ infinitely coarse, so any finite ``max_accuracy_meters`` drops it. This is
62
+ intentional: the bound keeps only fixes known to be accurate enough. Leave
63
+ ``max_accuracy_meters`` at its default (no bound) to keep fixes regardless
64
+ of reported accuracy.
65
+ """
66
+ kept: list[Fix] = []
67
+ for fix in fixes:
68
+ if not math.isfinite(fix.lat) or not math.isfinite(fix.lng):
69
+ continue
70
+ if not math.isfinite(fix.timestamp_ms):
71
+ continue
72
+ if _accuracy_of(fix) > max_accuracy_meters:
73
+ continue
74
+ if drop_inaccurate and fix.is_inaccurate:
75
+ continue
76
+ if drop_old and fix.is_old:
77
+ continue
78
+ kept.append(fix)
79
+ ordered = sort_by_time(kept)
80
+ return dedupe_by_timestamp(ordered) if dedupe else ordered
@@ -0,0 +1,260 @@
1
+ """Geographic primitives: distances, bearings, simplification, and the polyline
2
+ codec. All distances are in meters on a spherical-earth model.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import math
8
+ from collections.abc import Sequence
9
+ from typing import TypeVar
10
+
11
+ from .types import BoundingBox, LatLng
12
+
13
+ #: Mean earth radius in meters (spherical model).
14
+ EARTH_RADIUS_METERS = 6371000
15
+
16
+ _DEG_TO_RAD = math.pi / 180
17
+
18
+ T = TypeVar("T", bound=LatLng)
19
+
20
+
21
+ def _round_half_up(value: float) -> int:
22
+ """Round half away from +infinity, matching JavaScript ``Math.round``.
23
+
24
+ Python's built-in ``round`` uses banker's rounding, which would diverge from
25
+ the reference implementation on ``.5`` boundaries; ``floor(x + 0.5)`` matches.
26
+ """
27
+ return math.floor(value + 0.5)
28
+
29
+
30
+ def haversine_meters(a: LatLng, b: LatLng) -> float:
31
+ """Great-circle distance between two coordinates, in meters."""
32
+ lat1 = a.lat * _DEG_TO_RAD
33
+ lat2 = b.lat * _DEG_TO_RAD
34
+ d_lat = (b.lat - a.lat) * _DEG_TO_RAD
35
+ d_lng = (b.lng - a.lng) * _DEG_TO_RAD
36
+ sin_lat = math.sin(d_lat / 2)
37
+ sin_lng = math.sin(d_lng / 2)
38
+ h = sin_lat * sin_lat + math.cos(lat1) * math.cos(lat2) * sin_lng * sin_lng
39
+ return 2 * EARTH_RADIUS_METERS * math.asin(min(1.0, math.sqrt(h)))
40
+
41
+
42
+ def bearing_degrees(a: LatLng, b: LatLng) -> float:
43
+ """Initial bearing from ``a`` to ``b``, in degrees clockwise from north [0, 360)."""
44
+ lat1 = a.lat * _DEG_TO_RAD
45
+ lat2 = b.lat * _DEG_TO_RAD
46
+ d_lng = (b.lng - a.lng) * _DEG_TO_RAD
47
+ y = math.sin(d_lng) * math.cos(lat2)
48
+ x = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(d_lng)
49
+ deg = math.atan2(y, x) / _DEG_TO_RAD
50
+ return (deg + 360) % 360
51
+
52
+
53
+ def centroid(points: Sequence[LatLng]) -> LatLng:
54
+ """Component-wise mean of a set of coordinates. Raises on an empty input.
55
+
56
+ This is a plain arithmetic mean of latitude and longitude. For points within
57
+ a single region (the normal case for one tag's track) it is the expected
58
+ center; it is not the true spherical centroid for sets that straddle the
59
+ +/-180 degree antimeridian or sit at a pole.
60
+ """
61
+ if len(points) == 0:
62
+ raise ValueError("centroid requires at least one point")
63
+ sum_lat = 0.0
64
+ sum_lng = 0.0
65
+ for p in points:
66
+ sum_lat += p.lat
67
+ sum_lng += p.lng
68
+ return LatLng(lat=sum_lat / len(points), lng=sum_lng / len(points))
69
+
70
+
71
+ def bounding_box(points: Sequence[LatLng]) -> BoundingBox:
72
+ """Smallest axis-aligned box containing every point. Raises on an empty input."""
73
+ if len(points) == 0:
74
+ raise ValueError("bounding_box requires at least one point")
75
+ min_lat = math.inf
76
+ min_lng = math.inf
77
+ max_lat = -math.inf
78
+ max_lng = -math.inf
79
+ for p in points:
80
+ if p.lat < min_lat:
81
+ min_lat = p.lat
82
+ if p.lat > max_lat:
83
+ max_lat = p.lat
84
+ if p.lng < min_lng:
85
+ min_lng = p.lng
86
+ if p.lng > max_lng:
87
+ max_lng = p.lng
88
+ return BoundingBox(min_lat=min_lat, min_lng=min_lng, max_lat=max_lat, max_lng=max_lng)
89
+
90
+
91
+ def path_length_meters(points: Sequence[LatLng]) -> float:
92
+ """Cumulative great-circle length along an ordered path, in meters."""
93
+ total = 0.0
94
+ for i in range(1, len(points)):
95
+ total += haversine_meters(points[i - 1], points[i])
96
+ return total
97
+
98
+
99
+ def point_in_polygon(point: LatLng, polygon: Sequence[LatLng]) -> bool:
100
+ """Even-odd ray-casting point-in-polygon test.
101
+
102
+ The polygon is a ring of coordinates; it need not repeat its first point.
103
+ Points exactly on an edge are reported deterministically but their
104
+ classification is not guaranteed.
105
+ """
106
+ if len(polygon) < 3:
107
+ return False
108
+ y = point.lat
109
+ x = point.lng
110
+ inside = False
111
+ j = len(polygon) - 1
112
+ for i in range(len(polygon)):
113
+ xi = polygon[i].lng
114
+ yi = polygon[i].lat
115
+ xj = polygon[j].lng
116
+ yj = polygon[j].lat
117
+ intersects = (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / (yj - yi) + xi
118
+ if intersects:
119
+ inside = not inside
120
+ j = i
121
+ return inside
122
+
123
+
124
+ def is_within_radius(point: LatLng, center: LatLng, radius_meters: float) -> bool:
125
+ """Whether a coordinate lies within ``radius_meters`` of a center."""
126
+ return haversine_meters(point, center) <= radius_meters
127
+
128
+
129
+ def _perpendicular_distance_meters(p: LatLng, a: LatLng, b: LatLng) -> float:
130
+ """Perpendicular distance from ``p`` to segment ``a``–``b``, in meters.
131
+
132
+ Uses a local equirectangular projection, accurate for the short segments
133
+ simplification works on.
134
+ """
135
+ lat_ref = (a.lat + b.lat) / 2 * _DEG_TO_RAD
136
+ m_per_deg_lat = (math.pi / 180) * EARTH_RADIUS_METERS
137
+ m_per_deg_lng = m_per_deg_lat * math.cos(lat_ref)
138
+ ax = a.lng * m_per_deg_lng
139
+ ay = a.lat * m_per_deg_lat
140
+ bx = b.lng * m_per_deg_lng
141
+ by = b.lat * m_per_deg_lat
142
+ px = p.lng * m_per_deg_lng
143
+ py = p.lat * m_per_deg_lat
144
+ dx = bx - ax
145
+ dy = by - ay
146
+ seg_len_sq = dx * dx + dy * dy
147
+ if seg_len_sq == 0:
148
+ return math.hypot(px - ax, py - ay)
149
+ t = ((px - ax) * dx + (py - ay) * dy) / seg_len_sq
150
+ clamped = max(0.0, min(1.0, t))
151
+ proj_x = ax + clamped * dx
152
+ proj_y = ay + clamped * dy
153
+ return math.hypot(px - proj_x, py - proj_y)
154
+
155
+
156
+ def douglas_peucker(points: Sequence[T], epsilon_meters: float) -> list[T]:
157
+ """Ramer–Douglas–Peucker path simplification.
158
+
159
+ Keeps the endpoints and any vertex farther than ``epsilon_meters`` from the
160
+ running approximation. Returns the input unchanged when it has two or fewer
161
+ points.
162
+
163
+ Uses an explicit work stack rather than recursion, so a long path that
164
+ retains most of its vertices cannot exhaust Python's recursion limit.
165
+
166
+ Cost is ``O(n log n)`` on typical tracks but degrades to ``O(n**2)`` in the
167
+ worst case -- a path that keeps nearly every vertex, such as a dense zig-zag.
168
+ For very large inputs, down-sample first or raise ``epsilon_meters`` to bound
169
+ the work.
170
+ """
171
+ n = len(points)
172
+ if n <= 2:
173
+ return list(points)
174
+ keep = [False] * n
175
+ keep[0] = True
176
+ keep[n - 1] = True
177
+ # Each stack entry is an inclusive [lo, hi] index range whose interior is
178
+ # yet to be tested against the lo-hi chord.
179
+ stack: list[tuple[int, int]] = [(0, n - 1)]
180
+ while stack:
181
+ lo, hi = stack.pop()
182
+ max_dist = 0.0
183
+ index = -1
184
+ for i in range(lo + 1, hi):
185
+ dist = _perpendicular_distance_meters(points[i], points[lo], points[hi])
186
+ if dist > max_dist:
187
+ max_dist = dist
188
+ index = i
189
+ if index != -1 and max_dist > epsilon_meters:
190
+ keep[index] = True
191
+ stack.append((lo, index))
192
+ stack.append((index, hi))
193
+ return [points[i] for i in range(n) if keep[i]]
194
+
195
+
196
+ def encode_polyline(points: Sequence[LatLng], precision: int = 5) -> str:
197
+ """Encode an ordered path using the standard polyline algorithm.
198
+
199
+ ``precision`` is the number of decimal digits preserved (5 is the common
200
+ default).
201
+
202
+ Raises ``ValueError`` if any coordinate is not finite (``nan``/``inf``),
203
+ since the codec cannot represent it and would otherwise emit corrupt output.
204
+ """
205
+ factor = 10**precision
206
+ last_lat = 0
207
+ last_lng = 0
208
+ out: list[str] = []
209
+
210
+ def encode(value: int) -> str:
211
+ v = ~(value << 1) if value < 0 else (value << 1)
212
+ chunk = ""
213
+ while v >= 0x20:
214
+ chunk += chr((0x20 | (v & 0x1F)) + 63)
215
+ v >>= 5
216
+ chunk += chr(v + 63)
217
+ return chunk
218
+
219
+ for p in points:
220
+ if not math.isfinite(p.lat) or not math.isfinite(p.lng):
221
+ raise ValueError("encode_polyline requires finite lat/lng coordinates")
222
+ lat = _round_half_up(p.lat * factor)
223
+ lng = _round_half_up(p.lng * factor)
224
+ out.append(encode(lat - last_lat))
225
+ out.append(encode(lng - last_lng))
226
+ last_lat = lat
227
+ last_lng = lng
228
+ return "".join(out)
229
+
230
+
231
+ def decode_polyline(encoded: str, precision: int = 5) -> list[LatLng]:
232
+ """Decode a polyline string back into coordinates. Inverse of :func:`encode_polyline`."""
233
+ factor = 10**precision
234
+ points: list[LatLng] = []
235
+ index = 0
236
+ lat = 0
237
+ lng = 0
238
+ length = len(encoded)
239
+
240
+ def read(start: int) -> tuple[int, int]:
241
+ result = 0
242
+ shift = 0
243
+ i = start
244
+ while True:
245
+ byte = ord(encoded[i]) - 63
246
+ i += 1
247
+ result |= (byte & 0x1F) << shift
248
+ shift += 5
249
+ if byte < 0x20:
250
+ break
251
+ delta = ~(result >> 1) if (result & 1) else (result >> 1)
252
+ return delta, i
253
+
254
+ while index < length:
255
+ d_lat, index = read(index)
256
+ lat += d_lat
257
+ d_lng, index = read(index)
258
+ lng += d_lng
259
+ points.append(LatLng(lat=lat / factor, lng=lng / factor))
260
+ return points