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.
- taglogger_sdk/__init__.py +55 -0
- taglogger_sdk/analytics/__init__.py +110 -0
- taglogger_sdk/analytics/battery.py +102 -0
- taglogger_sdk/analytics/conditioning.py +80 -0
- taglogger_sdk/analytics/geometry.py +260 -0
- taglogger_sdk/analytics/segmentation.py +262 -0
- taglogger_sdk/analytics/shaping.py +89 -0
- taglogger_sdk/analytics/types.py +137 -0
- taglogger_sdk/client.py +72 -0
- taglogger_sdk/errors.py +52 -0
- taglogger_sdk/http.py +490 -0
- taglogger_sdk/pagination.py +88 -0
- taglogger_sdk/py.typed +0 -0
- taglogger_sdk/resources/__init__.py +17 -0
- taglogger_sdk/resources/account.py +22 -0
- taglogger_sdk/resources/fleet.py +45 -0
- taglogger_sdk/resources/geofences.py +56 -0
- taglogger_sdk/resources/share_links.py +61 -0
- taglogger_sdk/resources/shared.py +41 -0
- taglogger_sdk/resources/tags.py +82 -0
- taglogger_sdk/resources/webhooks.py +102 -0
- taglogger_sdk/types.py +366 -0
- taglogger_sdk/webhooks.py +96 -0
- taglogger_sdk-1.0.0.dist-info/METADATA +298 -0
- taglogger_sdk-1.0.0.dist-info/RECORD +27 -0
- taglogger_sdk-1.0.0.dist-info/WHEEL +4 -0
- taglogger_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|