gpxsheet 0.1.1__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.
- gpxsheet/__init__.py +137 -0
- gpxsheet/analysis.py +436 -0
- gpxsheet/cli.py +217 -0
- gpxsheet/enrich.py +611 -0
- gpxsheet/geo.py +56 -0
- gpxsheet/gpx.py +78 -0
- gpxsheet/junctions.py +102 -0
- gpxsheet/layout.py +200 -0
- gpxsheet/models.py +144 -0
- gpxsheet/paginate.py +92 -0
- gpxsheet/pdf.py +369 -0
- gpxsheet/profiles.py +66 -0
- gpxsheet/py.typed +0 -0
- gpxsheet/report.py +51 -0
- gpxsheet/service/__init__.py +21 -0
- gpxsheet/service/app.py +247 -0
- gpxsheet/service/asgi.py +13 -0
- gpxsheet/service/jobs.py +181 -0
- gpxsheet/service/models.py +27 -0
- gpxsheet/service/render.py +91 -0
- gpxsheet/service/settings.py +71 -0
- gpxsheet/service/storage.py +127 -0
- gpxsheet/simplify.py +70 -0
- gpxsheet/strip.py +322 -0
- gpxsheet/validate.py +100 -0
- gpxsheet-0.1.1.dist-info/METADATA +174 -0
- gpxsheet-0.1.1.dist-info/RECORD +31 -0
- gpxsheet-0.1.1.dist-info/WHEEL +5 -0
- gpxsheet-0.1.1.dist-info/entry_points.txt +2 -0
- gpxsheet-0.1.1.dist-info/licenses/LICENSE +661 -0
- gpxsheet-0.1.1.dist-info/top_level.txt +1 -0
gpxsheet/__init__.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""GPXSheet — motorcycle sport-touring route awareness generator.
|
|
2
|
+
|
|
3
|
+
Public library API. See ``PRODUCT.md`` for the full design specification.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .models import Route
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.1" # x-release-please-version
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"__version__",
|
|
17
|
+
"generate_pdf",
|
|
18
|
+
"generate_strip",
|
|
19
|
+
"analyze",
|
|
20
|
+
"analyze_route",
|
|
21
|
+
"load_route",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
DEFAULT_PROFILE = "sport-touring"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def generate_pdf(
|
|
28
|
+
gpx_file: str,
|
|
29
|
+
output_file: str = "route.pdf",
|
|
30
|
+
*,
|
|
31
|
+
profile: str = DEFAULT_PROFILE,
|
|
32
|
+
fuel_range: float | None = None,
|
|
33
|
+
use_osm: bool = False,
|
|
34
|
+
turn_style: str = "stylized",
|
|
35
|
+
orientation: str = "landscape",
|
|
36
|
+
paper: str = "letter",
|
|
37
|
+
lanes_per_page: int = 4,
|
|
38
|
+
decisions_per_lane: int = 4,
|
|
39
|
+
) -> str:
|
|
40
|
+
"""Generate a tank-bag navigation PDF from a GPX file.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
gpx_file: Path to the input ``.gpx`` route or track.
|
|
44
|
+
output_file: Path to write the rendered PDF to.
|
|
45
|
+
profile: One of ``minimalist``, ``sport-touring``, ``rally``.
|
|
46
|
+
fuel_range: Rider fuel range in miles, used for fuel-gap analysis.
|
|
47
|
+
use_osm: Enrich with OpenStreetMap road names/fuel (needs the osm extra).
|
|
48
|
+
turn_style: Strip bend style, ``"stylized"`` or ``"faithful"``.
|
|
49
|
+
orientation: ``"landscape"`` (one big strip per page) or ``"portrait"``
|
|
50
|
+
(several stacked strip lanes per page, roadbook-style).
|
|
51
|
+
paper: Page size, ``"letter"`` or ``"a4"``.
|
|
52
|
+
lanes_per_page: Portrait only -- number of strip lanes per page.
|
|
53
|
+
decisions_per_lane: Portrait only -- max decisions per lane.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The path to the written PDF.
|
|
57
|
+
"""
|
|
58
|
+
from .pdf import generate_pdf as _generate_pdf
|
|
59
|
+
|
|
60
|
+
return _generate_pdf(
|
|
61
|
+
gpx_file,
|
|
62
|
+
output_file,
|
|
63
|
+
profile=profile,
|
|
64
|
+
fuel_range=fuel_range,
|
|
65
|
+
use_osm=use_osm,
|
|
66
|
+
turn_style=turn_style,
|
|
67
|
+
orientation=orientation,
|
|
68
|
+
paper=paper,
|
|
69
|
+
lanes_per_page=lanes_per_page,
|
|
70
|
+
decisions_per_lane=decisions_per_lane,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def analyze(
|
|
75
|
+
gpx_file: str,
|
|
76
|
+
*,
|
|
77
|
+
profile: str = DEFAULT_PROFILE,
|
|
78
|
+
fuel_range: float | None = None,
|
|
79
|
+
reassurance_interval: float | None = None,
|
|
80
|
+
use_osm: bool = False,
|
|
81
|
+
include_hazards: bool = False,
|
|
82
|
+
) -> Route:
|
|
83
|
+
"""Run the route analysis engine on a GPX file.
|
|
84
|
+
|
|
85
|
+
Loads the GPX, runs decision-point detection, reassurance-marker placement,
|
|
86
|
+
fuel analysis and segmentation, and returns the populated :class:`Route`.
|
|
87
|
+
``include_hazards`` adds OSM hazard data for validation. See the ``analyze``
|
|
88
|
+
output mode in ``PRODUCT.md``.
|
|
89
|
+
"""
|
|
90
|
+
from .analysis import analyze_route as _analyze_route
|
|
91
|
+
from .gpx import load_route as _load_route
|
|
92
|
+
|
|
93
|
+
route = _load_route(gpx_file)
|
|
94
|
+
return _analyze_route(
|
|
95
|
+
route,
|
|
96
|
+
profile=profile,
|
|
97
|
+
fuel_range=fuel_range,
|
|
98
|
+
reassurance_interval=reassurance_interval,
|
|
99
|
+
use_osm=use_osm,
|
|
100
|
+
include_hazards=include_hazards,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load_route(gpx_file: str, *, name: str | None = None) -> Route:
|
|
105
|
+
"""Load a GPX file into a :class:`Route` without running analysis."""
|
|
106
|
+
from .gpx import load_route as _load_route
|
|
107
|
+
|
|
108
|
+
return _load_route(gpx_file, name=name)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def analyze_route(route: Route, **kwargs) -> Route:
|
|
112
|
+
"""Run analysis on an already-loaded :class:`Route` (see :mod:`gpxsheet.analysis`)."""
|
|
113
|
+
from .analysis import analyze_route as _analyze_route
|
|
114
|
+
|
|
115
|
+
return _analyze_route(route, **kwargs)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def generate_strip(
|
|
119
|
+
gpx_file: str,
|
|
120
|
+
output_file: str = "route_strip.png",
|
|
121
|
+
*,
|
|
122
|
+
profile: str = DEFAULT_PROFILE,
|
|
123
|
+
fuel_range: float | None = None,
|
|
124
|
+
use_osm: bool = False,
|
|
125
|
+
turn_style: str = "stylized",
|
|
126
|
+
) -> str:
|
|
127
|
+
"""Render a route to a schematic map-strip image."""
|
|
128
|
+
from .strip import generate_strip as _generate_strip
|
|
129
|
+
|
|
130
|
+
return _generate_strip(
|
|
131
|
+
gpx_file,
|
|
132
|
+
output_file,
|
|
133
|
+
profile=profile,
|
|
134
|
+
fuel_range=fuel_range,
|
|
135
|
+
use_osm=use_osm,
|
|
136
|
+
turn_style=turn_style,
|
|
137
|
+
)
|
gpxsheet/analysis.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""The route analysis engine.
|
|
2
|
+
|
|
3
|
+
Geometry-only baseline for the pipeline stages in PRODUCT.md:
|
|
4
|
+
|
|
5
|
+
Geometry Cleanup -> Decision Point Detection
|
|
6
|
+
-> Reassurance Marker Detection
|
|
7
|
+
-> Fuel Analysis
|
|
8
|
+
-> (light) Segmentation
|
|
9
|
+
|
|
10
|
+
Road names, intersection classification and fuel-station discovery come from OSM
|
|
11
|
+
enrichment (see :mod:`gpxsheet.enrich`), which is optional. Without it, decision
|
|
12
|
+
points are detected purely from track geometry (localized heading changes) and
|
|
13
|
+
fuel comes from GPX waypoints that look like fuel stops.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import warnings
|
|
19
|
+
from dataclasses import replace
|
|
20
|
+
|
|
21
|
+
from .geo import bearing, bearing_delta, meters_to_miles, miles_to_meters
|
|
22
|
+
from .models import (
|
|
23
|
+
DecisionKind,
|
|
24
|
+
DecisionPoint,
|
|
25
|
+
FuelReport,
|
|
26
|
+
FuelStop,
|
|
27
|
+
GeoPoint,
|
|
28
|
+
ReassuranceMarker,
|
|
29
|
+
Route,
|
|
30
|
+
Segment,
|
|
31
|
+
)
|
|
32
|
+
from .profiles import Profile, get_profile
|
|
33
|
+
from .simplify import rdp
|
|
34
|
+
|
|
35
|
+
# Cleanup tolerance: strip GPS jitter before bearing analysis.
|
|
36
|
+
CLEANUP_TOLERANCE_M = 10.0
|
|
37
|
+
# A turn must accumulate at least this much heading change to count.
|
|
38
|
+
TURN_ANGLE_THRESHOLD_DEG = 35.0
|
|
39
|
+
# ...and do so within this arc length, so sweeping curves (large radius) are
|
|
40
|
+
# excluded while junction-style turns (tight radius) are kept.
|
|
41
|
+
MAX_TURN_ARC_M = 90.0
|
|
42
|
+
# Heading change below this at a vertex is treated as "straight" (breaks a run).
|
|
43
|
+
STRAIGHT_EPS_DEG = 8.0
|
|
44
|
+
# Suppress a reassurance marker only if it falls this close to the route end
|
|
45
|
+
# (where it would be redundant with arrival). Small and fixed so a marker that
|
|
46
|
+
# is genuinely far from the end is never dropped.
|
|
47
|
+
END_MARKER_BUFFER_MILES = 0.5
|
|
48
|
+
# Decision points closer than this do not each start a new segment (avoids
|
|
49
|
+
# degenerate zero-length legs from clustered turns on noisy recorded tracks).
|
|
50
|
+
MIN_SEGMENT_MILES = 0.1
|
|
51
|
+
# Decision points within this distance of each other are collapsed into one
|
|
52
|
+
# (real recorded tracks produce tight clusters at complex intersections).
|
|
53
|
+
MERGE_MIN_SEPARATION_MILES = 0.2
|
|
54
|
+
# OSM decision detection: a road name must hold for at least this distance to
|
|
55
|
+
# count as a real road (filters nearest-edge flapping at junctions). Tuned
|
|
56
|
+
# against real tracks (see git history / tests).
|
|
57
|
+
MIN_ROAD_RUN_MILES = 0.3
|
|
58
|
+
# A road-name change with a heading change below this reads as "Continue onto",
|
|
59
|
+
# not "Left/Right onto".
|
|
60
|
+
CONTINUE_MAX_ANGLE_DEG = 25.0
|
|
61
|
+
# Below this point density the geometry is too sparse to follow roads (e.g. a
|
|
62
|
+
# waypoint-only <rte> with long straight legs); OSM road-name sampling along such
|
|
63
|
+
# straight lines snaps to whatever streets it crosses, so we skip enrichment.
|
|
64
|
+
MIN_POINTS_PER_MILE_FOR_OSM = 1.0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def looks_sparse(route: Route) -> bool:
|
|
68
|
+
"""True if the route geometry is too sparse for reliable OSM enrichment.
|
|
69
|
+
|
|
70
|
+
Sparse routes (few points over long distances, e.g. device ``<rte>`` exports)
|
|
71
|
+
are drawn as straight lines between waypoints that do not follow real roads,
|
|
72
|
+
so sampling OSM road names along them is meaningless.
|
|
73
|
+
"""
|
|
74
|
+
if route.length_miles <= 0:
|
|
75
|
+
return False
|
|
76
|
+
return len(route.points) / route.length_miles < MIN_POINTS_PER_MILE_FOR_OSM
|
|
77
|
+
|
|
78
|
+
# Fuel-stop detection from waypoint names/symbols when OSM is unavailable.
|
|
79
|
+
_FUEL_HINTS = ("fuel", "gas", "petrol", "station", "shell", "chevron", "76", "arco")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _turn_word(total_angle: float) -> str:
|
|
83
|
+
direction = "Right" if total_angle > 0 else "Left"
|
|
84
|
+
if abs(total_angle) >= 100.0:
|
|
85
|
+
return f"Sharp {direction.lower()}"
|
|
86
|
+
return direction
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _significance_for_turn(total_angle: float) -> int:
|
|
90
|
+
mag = abs(total_angle)
|
|
91
|
+
if mag >= 100.0:
|
|
92
|
+
return 80
|
|
93
|
+
if mag >= 60.0:
|
|
94
|
+
return 60
|
|
95
|
+
return 45
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def coord_at_meters(route: Route, meters: float) -> tuple[float, float]:
|
|
99
|
+
"""(lat, lon) interpolated along the route at a given along-track distance.
|
|
100
|
+
|
|
101
|
+
Linear interpolation between the two bracketing vertices, so results are
|
|
102
|
+
accurate even on sparsely sampled routes (important for measuring turn
|
|
103
|
+
angles around a point).
|
|
104
|
+
"""
|
|
105
|
+
dist = route.distances_m
|
|
106
|
+
target = max(0.0, min(meters, dist[-1]))
|
|
107
|
+
hi = 1
|
|
108
|
+
lo_b, hi_b = 1, len(dist) - 1
|
|
109
|
+
while lo_b < hi_b:
|
|
110
|
+
mid = (lo_b + hi_b) // 2
|
|
111
|
+
if dist[mid] < target:
|
|
112
|
+
lo_b = mid + 1
|
|
113
|
+
else:
|
|
114
|
+
hi_b = mid
|
|
115
|
+
hi = lo_b
|
|
116
|
+
lo = hi - 1
|
|
117
|
+
span = dist[hi] - dist[lo]
|
|
118
|
+
f = 0.0 if span <= 0 else (target - dist[lo]) / span
|
|
119
|
+
a, b = route.points[lo], route.points[hi]
|
|
120
|
+
return a.lat + f * (b.lat - a.lat), a.lon + f * (b.lon - a.lon)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def turn_angle_at_mile(route: Route, mile: float, window_m: float = 50.0) -> float:
|
|
124
|
+
"""Signed heading change of the route across a point (+right / -left).
|
|
125
|
+
|
|
126
|
+
Compares the bearing approaching ``mile`` with the bearing departing it,
|
|
127
|
+
sampled ``window_m`` either side. Used to give an OSM road-name-change
|
|
128
|
+
decision its turn direction.
|
|
129
|
+
"""
|
|
130
|
+
center_m = miles_to_meters(mile)
|
|
131
|
+
before = coord_at_meters(route, center_m - window_m)
|
|
132
|
+
at = coord_at_meters(route, center_m)
|
|
133
|
+
after = coord_at_meters(route, center_m + window_m)
|
|
134
|
+
approach = bearing(before[0], before[1], at[0], at[1])
|
|
135
|
+
depart = bearing(at[0], at[1], after[0], after[1])
|
|
136
|
+
return bearing_delta(approach, depart)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def merge_close_decisions(
|
|
140
|
+
decisions: list[DecisionPoint], min_separation_miles: float = MERGE_MIN_SEPARATION_MILES
|
|
141
|
+
) -> list[DecisionPoint]:
|
|
142
|
+
"""Collapse decisions closer than ``min_separation_miles`` into one each.
|
|
143
|
+
|
|
144
|
+
The representative of a cluster is its highest-significance member (ties
|
|
145
|
+
broken by sharpest turn), so the rider gets one prompt for a complex
|
|
146
|
+
intersection instead of several.
|
|
147
|
+
"""
|
|
148
|
+
if min_separation_miles <= 0 or len(decisions) < 2:
|
|
149
|
+
return list(decisions)
|
|
150
|
+
ordered = sorted(decisions, key=lambda d: d.mile)
|
|
151
|
+
merged: list[DecisionPoint] = []
|
|
152
|
+
cluster: list[DecisionPoint] = [ordered[0]]
|
|
153
|
+
for d in ordered[1:]:
|
|
154
|
+
if d.mile - cluster[-1].mile <= min_separation_miles:
|
|
155
|
+
cluster.append(d)
|
|
156
|
+
else:
|
|
157
|
+
merged.append(_pick_representative(cluster))
|
|
158
|
+
cluster = [d]
|
|
159
|
+
merged.append(_pick_representative(cluster))
|
|
160
|
+
return merged
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _pick_representative(cluster: list[DecisionPoint]) -> DecisionPoint:
|
|
164
|
+
return max(cluster, key=lambda d: (d.significance, abs(d.turn_angle or 0.0)))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def detect_decision_points(points: list[GeoPoint]) -> list[DecisionPoint]:
|
|
168
|
+
"""Detect localized turns from cleaned geometry.
|
|
169
|
+
|
|
170
|
+
Consecutive same-direction heading changes are grouped into a single turn;
|
|
171
|
+
a group qualifies as a decision when its total heading change exceeds
|
|
172
|
+
:data:`TURN_ANGLE_THRESHOLD_DEG` within :data:`MAX_TURN_ARC_M`.
|
|
173
|
+
"""
|
|
174
|
+
clean = rdp(points, CLEANUP_TOLERANCE_M)
|
|
175
|
+
if len(clean) < 3:
|
|
176
|
+
return []
|
|
177
|
+
|
|
178
|
+
bearings = [
|
|
179
|
+
bearing(clean[i].lat, clean[i].lon, clean[i + 1].lat, clean[i + 1].lon)
|
|
180
|
+
for i in range(len(clean) - 1)
|
|
181
|
+
]
|
|
182
|
+
# Turn angle at interior vertex i (1..len-2) is delta between leg i-1 and leg i.
|
|
183
|
+
deltas = [bearing_delta(bearings[i - 1], bearings[i]) for i in range(1, len(bearings))]
|
|
184
|
+
|
|
185
|
+
# Recompute cumulative distance on the cleaned geometry.
|
|
186
|
+
from .geo import cumulative_distances
|
|
187
|
+
|
|
188
|
+
clean_dist = cumulative_distances([(p.lat, p.lon) for p in clean])
|
|
189
|
+
|
|
190
|
+
decisions: list[DecisionPoint] = []
|
|
191
|
+
run: list[int] = [] # indices into `clean` (vertex index = delta index + 1)
|
|
192
|
+
|
|
193
|
+
def flush(run_idx: list[int]) -> None:
|
|
194
|
+
if not run_idx:
|
|
195
|
+
return
|
|
196
|
+
total = sum(deltas[v - 1] for v in run_idx)
|
|
197
|
+
if abs(total) >= TURN_ANGLE_THRESHOLD_DEG:
|
|
198
|
+
apex = max(run_idx, key=lambda v: abs(deltas[v - 1]))
|
|
199
|
+
decisions.append(
|
|
200
|
+
DecisionPoint(
|
|
201
|
+
mile=meters_to_miles(clean_dist[apex]),
|
|
202
|
+
instruction=_turn_word(total),
|
|
203
|
+
significance=_significance_for_turn(total),
|
|
204
|
+
lat=clean[apex].lat,
|
|
205
|
+
lon=clean[apex].lon,
|
|
206
|
+
kind=DecisionKind.CRITICAL_TURN,
|
|
207
|
+
turn_angle=round(total, 1),
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Group consecutive heading changes that are part of the *same* corner: same
|
|
212
|
+
# turn direction and confined to a short arc. After geometry cleanup, a long
|
|
213
|
+
# straight (or a separate corner farther along) appears as a far-apart vertex,
|
|
214
|
+
# which breaks the run so each corner is detected independently. A genuine
|
|
215
|
+
# sweeping curve spreads its heading change over a long arc, so its per-vertex
|
|
216
|
+
# deltas stay below the threshold and it is not flagged.
|
|
217
|
+
sign = 0
|
|
218
|
+
for vtx in range(1, len(clean) - 1):
|
|
219
|
+
d = deltas[vtx - 1]
|
|
220
|
+
if abs(d) < STRAIGHT_EPS_DEG:
|
|
221
|
+
flush(run)
|
|
222
|
+
run, sign = [], 0
|
|
223
|
+
continue
|
|
224
|
+
d_sign = 1 if d > 0 else -1
|
|
225
|
+
too_far = bool(run) and (clean_dist[vtx] - clean_dist[run[0]]) > MAX_TURN_ARC_M
|
|
226
|
+
if run and (d_sign != sign or too_far):
|
|
227
|
+
flush(run)
|
|
228
|
+
run = []
|
|
229
|
+
sign = d_sign
|
|
230
|
+
run.append(vtx)
|
|
231
|
+
flush(run)
|
|
232
|
+
|
|
233
|
+
decisions.sort(key=lambda d: d.mile)
|
|
234
|
+
return decisions
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def generate_reassurance_markers(
|
|
238
|
+
route: Route, interval_miles: float
|
|
239
|
+
) -> list[ReassuranceMarker]:
|
|
240
|
+
"""Place a marker every ``interval_miles``, labeled by the nearest waypoint."""
|
|
241
|
+
if interval_miles <= 0 or route.length_miles <= interval_miles:
|
|
242
|
+
return []
|
|
243
|
+
|
|
244
|
+
markers: list[ReassuranceMarker] = []
|
|
245
|
+
mile = interval_miles
|
|
246
|
+
while mile < route.length_miles - END_MARKER_BUFFER_MILES:
|
|
247
|
+
idx = _index_at_mile(route, mile)
|
|
248
|
+
pt = route.points[idx]
|
|
249
|
+
label, reason = _label_near(route, idx)
|
|
250
|
+
markers.append(
|
|
251
|
+
ReassuranceMarker(
|
|
252
|
+
mile=round(mile, 1), label=label, lat=pt.lat, lon=pt.lon, reason=reason
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
mile += interval_miles
|
|
256
|
+
return markers
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _index_at_mile(route: Route, mile: float) -> int:
|
|
260
|
+
target_m = miles_to_meters(mile)
|
|
261
|
+
# distances_m is sorted ascending.
|
|
262
|
+
lo, hi = 0, len(route.distances_m) - 1
|
|
263
|
+
while lo < hi:
|
|
264
|
+
mid = (lo + hi) // 2
|
|
265
|
+
if route.distances_m[mid] < target_m:
|
|
266
|
+
lo = mid + 1
|
|
267
|
+
else:
|
|
268
|
+
hi = mid
|
|
269
|
+
return lo
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _label_near(route: Route, idx: int, max_miles: float = 1.0) -> tuple[str, str]:
|
|
273
|
+
"""Best label for a point: nearest named waypoint, else the mileage."""
|
|
274
|
+
pt = route.points[idx]
|
|
275
|
+
best_name, best_d = None, miles_to_meters(max_miles)
|
|
276
|
+
from .geo import haversine
|
|
277
|
+
|
|
278
|
+
for wp in route.waypoints:
|
|
279
|
+
if not wp.name:
|
|
280
|
+
continue
|
|
281
|
+
d = haversine(pt.lat, pt.lon, wp.lat, wp.lon)
|
|
282
|
+
if d < best_d:
|
|
283
|
+
best_name, best_d = wp.name, d
|
|
284
|
+
if best_name:
|
|
285
|
+
return best_name, "landmark"
|
|
286
|
+
return f"{meters_to_miles(route.distances_m[idx]):.0f} mi", "interval"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _looks_like_fuel(wp_name: str | None, wp_symbol: str | None) -> bool:
|
|
290
|
+
haystack = f"{wp_name or ''} {wp_symbol or ''}".lower()
|
|
291
|
+
return any(h in haystack for h in _FUEL_HINTS)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def detect_fuel_stops(route: Route) -> list[FuelStop]:
|
|
295
|
+
"""Find fuel stops from GPX waypoints (OSM enrichment supersedes this)."""
|
|
296
|
+
stops: list[FuelStop] = []
|
|
297
|
+
from .geo import haversine
|
|
298
|
+
|
|
299
|
+
for wp in route.waypoints:
|
|
300
|
+
if not _looks_like_fuel(wp.name, wp.symbol):
|
|
301
|
+
continue
|
|
302
|
+
# Project waypoint onto route by nearest vertex for a mileage estimate.
|
|
303
|
+
nearest = min(
|
|
304
|
+
range(len(route.points)),
|
|
305
|
+
key=lambda i: haversine(route.points[i].lat, route.points[i].lon, wp.lat, wp.lon),
|
|
306
|
+
)
|
|
307
|
+
stops.append(
|
|
308
|
+
FuelStop(
|
|
309
|
+
mile=round(meters_to_miles(route.distances_m[nearest]), 1),
|
|
310
|
+
name=wp.name or "Fuel",
|
|
311
|
+
lat=wp.lat,
|
|
312
|
+
lon=wp.lon,
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
stops.sort(key=lambda s: s.mile)
|
|
316
|
+
return stops
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def analyze_fuel(route: Route, fuel_range: float | None) -> FuelReport:
|
|
320
|
+
"""Longest fuel gap and range warning, given detected fuel stops."""
|
|
321
|
+
stops = route.fuel_stops
|
|
322
|
+
miles = [0.0] + [s.mile for s in stops] + [route.length_miles]
|
|
323
|
+
gaps = [b - a for a, b in zip(miles, miles[1:], strict=False)]
|
|
324
|
+
longest = max(gaps) if gaps else route.length_miles
|
|
325
|
+
return FuelReport(
|
|
326
|
+
longest_gap_miles=round(longest, 1),
|
|
327
|
+
recommended=[s.name for s in stops],
|
|
328
|
+
exceeds_range=bool(fuel_range and longest > fuel_range),
|
|
329
|
+
fuel_range_miles=fuel_range,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def build_segments(route: Route) -> list[Segment]:
|
|
334
|
+
"""Split the route into legs between decision points.
|
|
335
|
+
|
|
336
|
+
Decision points closer together than :data:`MIN_SEGMENT_MILES` (e.g. the
|
|
337
|
+
tight clusters real recorded tracks produce at complex intersections) do not
|
|
338
|
+
each start a new leg, so no degenerate zero-length segments are emitted.
|
|
339
|
+
Legs are numbered sequentially ("Leg N"); enrichment renames them to the
|
|
340
|
+
dominant road name for each leg.
|
|
341
|
+
"""
|
|
342
|
+
boundaries = [0.0] + [d.mile for d in route.decision_points] + [route.length_miles]
|
|
343
|
+
boundaries = sorted({round(b, 3) for b in boundaries})
|
|
344
|
+
segments: list[Segment] = []
|
|
345
|
+
start = boundaries[0]
|
|
346
|
+
for end in boundaries[1:]:
|
|
347
|
+
if end - start < MIN_SEGMENT_MILES:
|
|
348
|
+
continue # too short to be its own leg; fold into the next boundary
|
|
349
|
+
segments.append(
|
|
350
|
+
Segment(
|
|
351
|
+
name=f"Leg {len(segments) + 1}",
|
|
352
|
+
start_mile=round(start, 1),
|
|
353
|
+
end_mile=round(end, 1),
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
start = end
|
|
357
|
+
# Extend the final leg to the route end if a trailing sliver was folded in.
|
|
358
|
+
if segments and segments[-1].end_mile < round(route.length_miles, 1):
|
|
359
|
+
segments[-1] = replace(segments[-1], end_mile=round(route.length_miles, 1))
|
|
360
|
+
return segments
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def analyze_route(
|
|
364
|
+
route: Route,
|
|
365
|
+
*,
|
|
366
|
+
profile: str | Profile = "sport-touring",
|
|
367
|
+
fuel_range: float | None = None,
|
|
368
|
+
reassurance_interval: float | None = None,
|
|
369
|
+
use_osm: bool = False,
|
|
370
|
+
include_hazards: bool = False,
|
|
371
|
+
) -> Route:
|
|
372
|
+
"""Run the full analysis, populating ``route`` in place.
|
|
373
|
+
|
|
374
|
+
``include_hazards`` adds OSM hazard data (ferry crossings; unpaved mileage is
|
|
375
|
+
always captured when OSM runs) for :func:`gpxsheet.validate.validate_route`.
|
|
376
|
+
Returns the same :class:`Route` for convenience.
|
|
377
|
+
"""
|
|
378
|
+
prof = profile if isinstance(profile, Profile) else get_profile(profile)
|
|
379
|
+
interval = (
|
|
380
|
+
reassurance_interval
|
|
381
|
+
if reassurance_interval is not None
|
|
382
|
+
else prof.reassurance_interval_miles
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# 1. Geometry baseline: localized turns, clustered firings collapsed. On
|
|
386
|
+
# twisty roads this over-detects (curves look like turns) -- OSM in step 2
|
|
387
|
+
# replaces these with junction/road-name decisions when available.
|
|
388
|
+
route.decision_points = merge_close_decisions(detect_decision_points(route.points))
|
|
389
|
+
route.fuel_stops = detect_fuel_stops(route) if prof.include_fuel else []
|
|
390
|
+
route.segments = build_segments(route)
|
|
391
|
+
|
|
392
|
+
# 2. OSM enrichment: replaces decisions with durable road-name changes,
|
|
393
|
+
# segments with the named roads, and adds OSM fuel. Must run after step 1.
|
|
394
|
+
# Degrades to geometry-only (with a warning) when the extra is missing, the
|
|
395
|
+
# route is too sparse, or the live Overpass query fails -- so OSM can be the
|
|
396
|
+
# default without breaking core installs or offline use.
|
|
397
|
+
if use_osm:
|
|
398
|
+
from .enrich import osm_available
|
|
399
|
+
|
|
400
|
+
if not osm_available():
|
|
401
|
+
warnings.warn(
|
|
402
|
+
"OSM enrichment requested but the 'osm' extra is not installed "
|
|
403
|
+
"(pip install 'gpxsheet[osm]'); using geometry-only analysis.",
|
|
404
|
+
stacklevel=2,
|
|
405
|
+
)
|
|
406
|
+
elif looks_sparse(route):
|
|
407
|
+
warnings.warn(
|
|
408
|
+
"Route geometry is sparse (likely a waypoint-only <rte>); skipping "
|
|
409
|
+
"OSM enrichment, which would sample road names along straight lines "
|
|
410
|
+
"that do not follow roads. Using geometry-only analysis.",
|
|
411
|
+
stacklevel=2,
|
|
412
|
+
)
|
|
413
|
+
else:
|
|
414
|
+
from .enrich import enrich_route
|
|
415
|
+
|
|
416
|
+
try:
|
|
417
|
+
enrich_route(
|
|
418
|
+
route, include_fuel=prof.include_fuel, include_hazards=include_hazards
|
|
419
|
+
)
|
|
420
|
+
except Exception as exc: # network/Overpass/data failure -> fall back
|
|
421
|
+
warnings.warn(
|
|
422
|
+
f"OSM enrichment failed ({type(exc).__name__}: {exc}); "
|
|
423
|
+
"using geometry-only analysis.",
|
|
424
|
+
stacklevel=2,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# 3. Apply the profile's display threshold to whatever decisions step 1/2
|
|
428
|
+
# produced, then derive products that depend on the final fuel stops.
|
|
429
|
+
route.decision_points = [
|
|
430
|
+
d for d in route.decision_points if d.significance >= prof.decision_threshold
|
|
431
|
+
]
|
|
432
|
+
route.fuel_report = analyze_fuel(route, fuel_range) if prof.include_fuel else None
|
|
433
|
+
route.reassurance_markers = (
|
|
434
|
+
generate_reassurance_markers(route, interval) if prof.include_reassurance else []
|
|
435
|
+
)
|
|
436
|
+
return route
|