GPXSheet Product Design Specification¶
Version 1.0¶
Motorcycle Sport-Touring Route Awareness Generator¶
Executive Summary¶
GPXSheet is a Python-based command-line application and reusable library that converts GPX routes into highly glanceable, map-centric motorcycle navigation PDFs optimized for tank-bag use.
Unlike rally roadbooks, GPS turn-by-turn navigation, or printed map exports, GPXSheet is designed specifically for motorcycle sport-touring riders traveling long distances on back roads where:
- Navigation decisions are infrequent
- Situational awareness is more important than detailed navigation
- Riders need to understand where they are in the route at a glance
- Printed navigation remains valuable as a primary or backup navigation tool
Examples include roads such as:
- Skaggs Springs Road
- Mines Road
- CA-36
- Sierra Foothill routes
- Trinity County routes
- Backcountry paved touring routes
- Mixed-surface touring routes
The product's primary goal is:
Allow a rider to glance at a tank-bag navigation sheet for less than one second and immediately understand:
- What road they are on
- What their next navigation decision is
- How far away it is
- What comes after that
- Where they are within the overall route
Design Philosophy¶
Not a Rally Roadbook¶
GPXSheet is intentionally not optimized for:
- Rally navigation
- Tulip diagrams
- Off-road rally racing
- Constant navigation prompts
Those use cases are already well served by existing roadbook software.
Not a GPS Replacement¶
GPXSheet is not intended to replace:
- Garmin
- CarPlay
- Android Auto
- Smartphone navigation
Instead, it provides:
- Route context
- Route awareness
- Backup navigation
- Enhanced situational understanding
Primary Design Principle¶
The rider should never need to ask:
- Am I still on the route?
- What road am I on?
- What is my next decision?
- How far away is it?
- How much farther is the ride?
Supported Inputs¶
Initial Support¶
GPX Track¶
<trk>
GPX Route¶
<rte>
GPX Track + Waypoints¶
<trk>
<wpt>
Future Support¶
- Kurviger exports
- Garmin BaseCamp exports
- REVER exports
- Combined route/track files
System Architecture¶
GPX
↓
Geometry Cleanup
↓
OSM Enrichment
↓
Decision Point Detection
↓
Reassurance Marker Detection
↓
Fuel Analysis
↓
Route Simplification
↓
Route Graph Construction
↓
Schematic Layout Engine
↓
PDF Rendering
Product Components¶
Command Line Interface¶
gpxsheet route.gpx
Default behavior:
gpxsheet route.gpx \
--profile sport-touring
Advanced example:
gpxsheet route.gpx \
--profile sport-touring \
--fuel-range 180 \
--reassurance-interval 15 \
--output route.pdf
Library API¶
import gpxsheet
# Three entry points mirror the web API (render / analyze / validate).
gpxsheet.render(
"route.gpx",
"route.pdf",
layout="portrait", # portrait | landscape | preview | strip
format="pdf", # pdf | png
profile="sport-touring",
fuel_range=180,
)
route = gpxsheet.analyze("route.gpx", fuel_range=180)
report = gpxsheet.validate("route.gpx", fuel_range=180)
Web Service (built)¶
Implemented as a FastAPI app (the service extra). Each operation is an async job
created by a typed POST — /v1/render (a map), /v1/analyze and /v1/validate
(JSON reports) — with the GPX and parameters as multipart form fields. Submit
returns a job (202 + Location, or 200 if already cached); poll
GET /v1/jobs/{id} and fetch GET /v1/jobs/{id}/result. See Milestone 5 in
the Implementation Status section for the full as-built description.
User Profiles¶
Minimalist¶
Purpose:
Maximum simplicity.
Includes:
- Critical navigation decisions only
Excludes:
- Fuel information
- Reassurance markers
- Supplemental annotations
Sport-Touring (Default)¶
Primary target profile.
Includes:
- Critical navigation decisions
- Fuel opportunities
- Reassurance markers
- Town labels
- Road name ribbon
Suppresses:
- Navigation noise
- Unnecessary intersections
Rally¶
Includes:
- All sport-touring features
- Confirmation points
- Additional route annotations
- More frequent markers
OSM Enrichment¶
OpenStreetMap data is used to enrich the GPX.
Collected information:
- Road names
- Road classifications
- Intersections
- Junction geometry
- Towns
- Fuel stations
- Geographic features
Decision Point Engine¶
The Decision Point Engine is the core intelligence layer.
Significant Intersections¶
Significance is determined by three rule sets.
Rule Set 1: Road Name Changes¶
Example:
Skaggs Springs Rd
→ Annapolis Rd
Even if physically straight.
Rule Set 2: Major Road Crossings¶
Examples:
- State highways
- County highways
- Major collectors
- Arterial roads
Rule Set 3: Route Ambiguity¶
Examples:
Y intersection
T intersection
Fork
Roundabouts and Rotarys
Situations where a rider could easily choose the wrong path.
Significance Scoring¶
Every route event receives a score.
Example scoring:
| Event | Score |
|---|---|
| Road name change | +40 |
| State highway junction | +50 |
| County road junction | +30 |
| Roundabout intersection | +60 |
| Y intersection | +60 |
| T intersection | +60 |
| Fuel opportunity | +20 |
| Town center | +20 |
Profile settings determine display thresholds.
Decision Point Types¶
Critical Turn¶
Examples:
Right on Skaggs Springs Rd
Left on CA-1
Continue onto Mines Rd
Highest priority.
Confirmation Point¶
Displayed only in Rally profile.
Examples:
Continue through stop sign
Continue across bridge
Fuel Point¶
Examples:
Fuel Available
Gualala
Reassurance Markers¶
Purpose:
Provide confidence between major navigation decisions.
Placement Rules¶
Markers are generated when:
- Entering towns
- Crossing major geographic features
- Passing major landmarks
- Exceeding distance intervals
Default Interval¶
15 miles
Examples¶
Continue CA-36
Wildwood
15 mi
Platina
31 mi
South Fork Summit
44 mi
Fuel Intelligence¶
User configurable.
Example:
--fuel-range 120
Analysis Performed¶
- Fuel stations near route
- Longest fuel gap
- Recommended fuel locations
- Fuel-range warnings
Example Output¶
Longest Fuel Gap:
104 miles
Recommended Fuel:
Gualala
Fort Bragg
Leggett
Warning Example¶
⚠ Fuel gap exceeds configured range
Route Simplification¶
Aggressive simplification is required.
Goals¶
Preserve:
- Major curves
- Route character
- Junction geometry
Remove:
- GPS noise
- Redundant points
- Excessive detail
Expected Reduction¶
Target:
95%+ point reduction
for recorded tracks.
Route Graph Model¶
Internal representation:
Route
├─ Segments
├─ DecisionPoints
├─ ReassuranceMarkers
├─ FuelStops
└─ Pages
Example segment:
Segment(
name="Skaggs Springs Rd",
length_miles=35.7
)
Example decision:
DecisionPoint(
mile=35.7,
instruction="Left onto CA-1",
significance=95
)
Hybrid Schematic Map System¶
Core Philosophy¶
Do not render the GPX directly.
Instead:
Route Graph
↓
Schematic Layout Engine
↓
Map Strip
Hybrid Schematic Design¶
Characteristics:
- Preserves route character
- Preserves major bends
- Compresses low-information stretches
- Exaggerates important junctions
- Improves readability
Inspiration:
- AAA TripTik
- Aviation charts
- Transit maps
Not:
- GIS maps
- Garmin maps
- Google Maps printouts
Segment Compression¶
Visual size is determined by information density.
Example:
| Segment | Real Distance | Visual Length |
|---|---|---|
| Long uninterrupted road | 40 mi | Medium |
| Complex junction | 2 mi | Large |
| Town center | 1 mi | Large |
| Fuel stop | 0 mi | Medium |
Road Name Ribbon¶
Displayed on every page.
Example:
Skaggs Springs Rd
========================
Annapolis Rd
============
CA-1
====================
Fort Bragg
Purpose:
Allow navigation by road names.
Page Layout¶
US Letter or A4 (--paper, default Letter), optimized for color printing,
tank-bag viewing, and sunlight readability. Two layouts (CLI default is
portrait):
Header (both layouts): route name (left) · page mileage start / total
in green (right of center) · Page X of Y (right).
Landscape — one page per route-aware page: a framed Map Zone that hugs the schematic strip (decisions, fuel, reassurance/town markers, road-name ribbon), plus a horizontal progress bar with a YOU marker for at-a-glance route position.
Portrait (roadbook / TripTik) — several stacked strip "lanes" per page,
clearly separated, each a framed strip over its absolute mile range (shown in
green) with its own road ribbon. --lanes N / --lane-decisions M.
As-built note: the original spec had a separate large-text Cue Zone (NEXT / AFTER / FUEL / TOTAL). In practice that duplicated information already on the strip (each decision is labeled "
<mi> <turn> onto <road>") and the ribbon, so it was dropped: the TOTAL moved to the header (green mileage) and the rest is read directly off the map strip. The progress bar is landscape-only; portrait conveys position via the per-lane mile ranges.
Route-Aware Pagination¶
Pages are not split solely by mileage.
Page breaks should occur near:
- Major decisions
- Road transitions
- Significant route changes
This preserves continuity.
Rendering Priorities¶
Level 1¶
Next navigation decision.
Level 2¶
Upcoming navigation decision.
Level 3¶
Map strip.
Level 4¶
Reassurance markers.
Level 5¶
Metadata.
Output Modes¶
Analyze¶
gpxsheet analyze route.gpx
Produces route analysis.
Example:
Route Length: 217.3 mi
Decision Points:
35.7 L onto CA-1
78.2 R onto CA-128
Fuel:
Gualala
Fort Bragg
Longest Fuel Gap:
104 mi
Road Segments:
Skaggs Springs Rd
CA-1
CA-128
Generate PDF¶
gpxsheet route.gpx
Produces:
route.pdf
Validate¶
gpxsheet validate route.gpx
Detects:
⚠ Fuel gap exceeds configured range
⚠ Route contains unpaved segment
⚠ Seasonal closure risk
⚠ Ferry crossing present
(Fuel-gap, unpaved, and ferry checks are implemented; the seasonal-closure check is not yet implemented — it currently emits an informational "not checked yet" note. See TODO.md.)
Technology Stack (as built)¶
| Concern | Library | Notes |
|---|---|---|
| GPX parsing | gpxpy |
core |
| Mapping / PDF | matplotlib |
core; strip + PDF (Agg, PdfPages) |
| CLI | typer |
core |
| OSM integration | osmnx |
core; OSM enrichment |
| Geometry | shapely |
core; used by enrich |
Notes: the strip and PDF are rendered with matplotlib, not reportlab (the
single matplotlib stack keeps the strip and page composition consistent and
vector). networkx is pulled in transitively by osmnx; GPXSheet reads the osmnx
graph through it (node degree, edges, bearings) for junction topology. The geo
stack also brings geopandas/pyproj/pyogrio.
Success Criteria¶
The product succeeds if a rider can glance at the printed document for less than one second and reliably determine:
- Current road
- Next navigation decision
- Distance to that decision
- Upcoming fuel opportunities
- Overall route progress
- Confidence that they remain on the intended route
without needing to interpret a traditional map, tulip diagram, or turn-by-turn GPS interface.
Implementation Status & Engineering Notes¶
This section records what is actually built and the engineering decisions made while implementing the spec above. The sections above are the design intent; this section is the as-built reality. Last updated: Phase 1 (milestones 1–5) complete, plus post-Phase-1 rendering/analysis polish and OSM-enrichment robustness, released as v0.2.0. Planned/queued work is tracked in TODO.md.
Milestone progress¶
- Milestone 1 — Route analysis engine: ✅ complete.
GPX loading (track/route/waypoint), geometry cleanup (RDP), decision-point
detection, reassurance markers, fuel analysis, segmentation,
analyzetext output, and OSM enrichment — all implemented and tuned against real tracks. - Milestone 2 — Schematic map-strip renderer: ✅ complete.
gpxsheet.layout(pure Schematic Layout Engine) turns the route graph into a stylized strip: a ribbon that jogs at each decision, with segment length compressed sub-linearly (sqrt) in real distance. Two turn styles:stylized(default — quantized/exaggerated bends, reads like a transit map) andfaithful(bend by the real turn angle, capped).gpxsheet.striprenders toroute_strip.png(matplotlib) with markers for decisions, fuel, food/rest and generic waypoints, town reassurance, and roundabouts; fuel/food/ ferry markers carry a symbol glyph (font-coverage-aware, with a plain fallback) plus mileage. Unpaved (brown-dashed) and ferry (blue-dashed) stretches draw as styled ribbon overlays with labeled ends. Labels are collision-placed with alternating above/below sides and dashed leaders to their dots; road names wrap to two lines. A marker landing on mile 0 / the route end is nudged clear of the START/END marker. CLI:gpxsheet strip <gpx> [-o out.png] [--turns stylized|faithful]. Validated on real OSM tracks. - Milestone 3 — PDF generation: ✅ complete.
gpxsheet.pdfcomposes a US-Letter/A4 document with route-aware pagination (gpxsheet.paginate, breaks at decisions, never mid-road). Lanes auto-fit by default — as many decisions per lane as fit without label overlap (strip.fit_pages); pass--lane-decisions Nfor a fixed cap. Header shows the (truncated) route name, page mileage in green, and page counter. Two layouts: - portrait (CLI default): stacked strip "lanes" per page (roadbook/TripTik),
each a framed strip over its absolute mile range with its own road ribbon.
Tunable via
--lanes N/--lane-decisions M; partial pages top-aligned. - landscape (
--landscape): one strip per page in a framed Map Zone that hugs the strip, with the road ribbon and a progress (YOU) bar; page breaks honor--lane-decisionstoo.
render (the library/web entry point) and gpxsheet generate <gpx> -o route.pdf
drive this. (The earlier fuel-at-mile-0/START label overlap is fixed.) Remaining
rendering tuning is in TODO.md.
* Decisions/segments come from OSM; orientation defaults to portrait. The
OSM-derived navigation structure is the product. Enrichment falls back to the
geometry baseline automatically (with a warning) when the route looks_sparse
(waypoint-only <rte>) or the live Overpass query fails, so analysis still
produces output. Monster tracks are enriched in chunks. Orientation defaults to
portrait (--landscape for one strip/page).
* Milestone 4 — Packaged + published: ✅ complete. pyproject.toml builds a
clean sdist + wheel (PEP 639 license, PEP 561 py.typed, version from
gpxsheet.__version__). Core deps are gpxpy + matplotlib + typer + osmnx +
shapely (OSM is integral, not an optional extra); extras: service, dev,
docs. twine check passes. Releases are automated: conventional commits →
release-please PR → merging it tags vX.Y.Z → publish.yml publishes to
TestPyPI then PyPI via OIDC trusted publishing (v0.2.0 shipped this way).
* Milestone 5 — Web service: ✅ complete (verified live). gpxsheet.service
is a FastAPI app (the service extra) exposing the engine over REST. Every
operation is an async job created via a typed POST: /v1/render (a map),
/v1/analyze and /v1/validate (JSON reports). All three return a job polled at
GET /v1/jobs/{id} (status + result content_type) and fetched at .../result
(streams the artifact or 303 → presigned URL); plus /healthz and /docs.
/v1/render takes layout ∈ {portrait, landscape, preview, strip} ×
format ∈ {pdf, png} (paginated layouts as PNG stack their pages into one
tall image; see pdf.render_layout/render_pages_png). An internal op string
(set per endpoint, not client-facing) routes the worker.
process_job is shared by an EagerRunner (dev/sync, in-memory + local dir) and
a DramatiqRunner (worker). Self-hosted via docker-compose.yml
(api/worker/redis/minio; python:3.13-slim, geo wheels, no system GDAL).
Hardening: per-client rate limiting (with Retry-After/RateLimit-* headers),
upload-size cap, result caching (by GPX + op + params + identity hash),
per-identity job ownership (others get 404), and presigned download URLs signed
against a host-reachable public endpoint (region pinned to avoid a
GetBucketLocation round-trip). HTTP conventions: 202 + Location on submit
(200 for an already-finished/cached job), 425 while a result isn't ready,
409 on failure, immutable ETag/Cache-Control on results, and a /readyz
probe (Redis/MinIO reachable) alongside /healthz.
Verified live via docker compose up: async submit → worker render →
status done → external PDF download through the presigned URL, plus cache
hits. Tests: dev path end-to-end via TestClient (incl. cache/cap/limit); the
Redis/MinIO prod path has a gated integration test (GPXSHEET_SERVICE_IT=1).
validate (CLI, library gpxsheet.validate, and /v1/validate) reports fuel-gap,
unpaved, and ferry findings as a ValidationReport; the seasonal-closure check is
still a placeholder ("not checked yet"). Planned work — including the seasonal
check and future service hardening (auth/API keys, metrics, distributed rate
limiting) — is in TODO.md.
Decision Point Engine — as built¶
The engine is two-tier, because pure geometry cannot tell a curving road from a junction (this is the central lesson from testing on real tracks — a recorded track of Mount Hamilton Road produced 100+ false "turns" from curvature alone):
-
Geometry baseline (no OSM). Detects localized heading changes on the RDP-cleaned track: same-direction deltas confined to a short arc are grouped into one turn (
TURN_ANGLE_THRESHOLD_DEG=35,MAX_TURN_ARC_M=90). Honest but over-detects on twisty roads — it has no way to know you stayed on the same road. Used as a fallback and to supply turn direction. -
OSM enrichment. Implements Rule Set 1 (road-name changes), which is what the significance table is really about. The route is sampled for OSM road names (~60 m spacing); a name that does not persist for at least
MIN_ROAD_RUN_MILES=0.3is discarded as nearest-edge "flapping" at junctions (tuned via a threshold sweep on real tracks). Each surviving road-name change becomes a decision: "Left/Right onto" (or "Continue onto " when the heading change is < CONTINUE_MAX_ANGLE_DEG=25), with the turn direction measured from the track geometry at that point. Segments become the durable named roads (the road ribbon).
Cluster merging (merge_close_decisions, MERGE_MIN_SEPARATION_MILES=0.2)
collapses decisions that are closer together than the threshold into a single
representative (highest significance), applied on both tiers — real recorded
tracks produce tight clusters of firings at complex intersections.
Significance currently uses a subset of the spec table: road-name change
= 40, highway-like name (regex over "Freeway"/"Highway"/"CA-1" etc.) raises it
to 50, and a sharp turn (≥60°) adds 10. A straight "Continue onto" change onto a
minor cul-de-sac road (unambiguous suffixes only) is penalized below the display
threshold as residential-grid noise, and promoted nameless forks (below) score by
turn angle. Y/T-intersection and explicit junction-geometry scoring are not yet
implemented (the corresponding SCORE_* constants were removed rather than
advertise unused scoring) — see TODO.md.
Junction topology (OSM mode). enrich._apply_junction_topology (best-effort;
any failure degrades to plain turns) reads the osmnx graph to enrich decisions:
- Roads not taken — at a fork/multi-way (node degree ≥ 3), the other branches
are recorded on
DecisionPoint.branches(name + relative angle/direction, excluding the road arrived on and the road taken). The strip draws each as a ghosted dashed stub so the rider can see which road to ignore. - Roundabouts —
junction=roundabout/circularways are reconstructed into ordered rings; the route's entry/exit nodes give the exit number (counted from outgoing spurs only, so one-way feeder roads don't inflate it), emitted as aDecisionKind.ROUNDABOUTdecision ("Take the Nth exit onto …", drawn with a ring glyph). The roundabout's other exits (spurs you don't take) populate the samebranchesfield, so they render as ghosted stubs like any junction. - Nameless forks — a high-degree node where the route turns off a named, differently-named through-road (so no road-name change flags it) is promoted to its own decision ("Left/Right at the fork"), with that through-road as a ghosted branch. Gated (turn angle + named non-minor through-road, dropping the same-named road you stay on) so switchbacks and service-stub junctions aren't flagged.
The pure counting/geometry lives in gpxsheet.junctions and is unit-tested with
synthetic inputs; the graph-reading is tested with hand-built networkx graphs.
Known limitations (decision detection)¶
- Heuristic thresholds, not a labeled fit. Nameless-fork promotion and the residential "Continue onto" down-weighting use conservative thresholds validated by inspecting real tracks (Mt Hamilton, a Riverbank roundabout, SF arterials), not a labeled ground-truth set; edge cases may still slip through.
- Residential areas show more decisions (~0.6/mi) than highways (~0.24/mi)
or mountain roads (~0.13/mi). These are real street-name changes (correctly
surfaced in
sport-touring; theminimalistthreshold filters them), not noise — but worth knowing. - No ground-truth dataset. Tuning constants were validated by inspecting known Bay Area routes, not against labeled correct answers.
OSM enrichment — operational notes¶
- Backed by
osmnx2.x + the geopandas stack (a core dependency; installs cleanly on Python 3.14). - Queries the live Overpass API: needs network, slower than the geometry
path (≈3 s rural, but tens of seconds to minutes for dense urban areas).
osmnxcaches responses, so repeat runs over the same area are fast. - Graph is built from a buffered route polygon (not the whole bbox), in chunks for
long routes. Each chunk graph is built through a network fallback
(
drive→drive_service→ wider buffer); a chunk that still yields no drivable network is skipped rather than aborting enrichment for the whole route. Remote roads (e.g. Mt Hamilton Rd) only resolve underdrive_service. - Helper functions are unit-tested; the end-to-end enrichment path is tested
deterministically against committed Overpass responses
(
tests/fixtures/osm_cache, replayed viaconftest), so CI/offline never hit the network. Re-record fixtures withGPXSHEET_RECORD_OSM=1.
Real-world data quirks handled¶
- OSM edge
namemay be a string, a list (a way with several names), or NaN — normalized in_edge_name. features_from_polygonraisesInsufficientResponseError(not empty) when a region has no matching features — caught in_add_fuel.- OSM string cells are often NaN, and
float('nan')is truthy, soor-fallback chains silently keep NaN — use_clean_str.
Tech stack (as installed)¶
See the "Technology Stack (as built)" table above. Verified on Python 3.14: core
= gpxpy + matplotlib + typer + osmnx 2.1 + shapely (+ geopandas/pyproj/pyogrio via
osmnx); [dev] = pytest + ruff + mypy + build + twine.
Rendering backend — matplotlib vs. SVG (analysis)¶
We evaluated whether matplotlib is the right rendering backend, given how much
hand-written machinery the renderer carries (label de-collision, transform
juggling, the analytic fit_pages estimate, font-coverage probing, hand-composed
PDF pages). A throwaway SVG-emit spike for the strip layout (pure-Python SVG +
cairosvg, wired behind a render_layout(engine="svg") hook) informed these
conclusions. The spike was discarded; the findings are kept here.
- matplotlib is being used as a vector canvas + ad-hoc layout engine, not as a
plotting library — there are no plots/axes/scales. That mismatch is the source of
most incidental complexity (data↔pixel round-trips, draw-then-measure after
set_aspect, the glyph-coverage fallback that exists only because matplotlib doesn't do font fallback, and the hand-rolled page composition inpdf.py). - Label de-collision is intrinsic, not a backend artifact. Automatic map labeling is heuristic in any backend; the spike's simpler single-pass placement visibly collided where matplotlib's force-directed pass did not. Switching backends does not make this cheaper — it must be ported and improved either way. This is the renderer's real complexity.
- The SVG architecture is clean and slots in trivially (
layout.pyis already backend-agnostic; theengine=hook was ~10 lines): pure-Python emission, direct coordinates (no transforms), one document → SVG/PNG/PDF, and output that is unit-testable as XML. ~250 LOC covered the wholestrip. - The font/glyph win depends on the rasterizer, not on SVG itself.
cairosvgrendered the ⚓/☕ marker glyphs as tofu (worse than matplotlib); the "real glyphs for free" benefit needs resvg or a browser, plus a runtime dependency cost (libcairo / resvg) that matters for the service container.
Decision: keep matplotlib for now. Migrating the strip alone is not worth it — the label algorithm doesn't get simpler and the glyph win needs a heavier rasterizer. The larger payoff, if revisited, is CSS Paged Media (e.g. WeasyPrint) for the portrait roadbook pagination, where matplotlib's hand-composed pages cost the most; the per-lane strip could be embedded SVG. Triggers to reconsider: wanting reliable symbol glyphs, the label/transform code becoming a maintenance sink, or needing finer typographic / pagination control.