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 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