Skip to content

Library API

The public Python API mirrors the web API: three entry points — render, analyze, validate — that take the same names and arguments (but run synchronously). Import everything from the top-level gpxsheet package:

import gpxsheet

# render a map (portrait PDF is the default; layouts and formats below)
gpxsheet.render("route.gpx", "route.pdf")
gpxsheet.render("route.gpx", "overview.png", layout="preview", format="png")

# structured analysis
route = gpxsheet.analyze("route.gpx", fuel_range=180)

# validation report (route + findings)
report = gpxsheet.validate("route.gpx", fuel_range=180)
for f in report.findings:
    print(f.level, f.code, f.message)

OSM enrichment runs as part of analysis, falling back to the geometry baseline when a route is too sparse to sample or the Overpass query fails.

Render

layout (portrait · landscape · preview · strip) and format (pdf · png) are independent; portrait PDF is the default.

gpxsheet.render

render(gpx_file: str, output_file: str | None = None, *, profile: str = DEFAULT_PROFILE, fuel_range: float | None = None, layout: str = 'portrait', format: str = 'pdf', turn_style: str = 'stylized', paper: str = 'letter', lanes_per_page: int = 4, decisions_per_lane: int | None = None) -> str

Render a GPX route to a tank-bag navigation map.

Parameters:

Name Type Description Default
gpx_file str

Path to the input .gpx route or track.

required
output_file str | None

Output path; defaults to route.<format>. Its extension should match format.

None
profile str

One of minimalist, sport-touring, rally.

DEFAULT_PROFILE
fuel_range float | None

Rider fuel range in miles, used for fuel-gap analysis.

None
layout str

"portrait" (stacked roadbook lanes, the default), "landscape" (one big strip per page), "preview" (the whole route as one continuous image), or "strip" (a single schematic strip).

'portrait'
format str

"pdf" or "png". Paginated layouts (portrait / landscape) become a multi-page PDF or one tall stacked PNG.

'pdf'
turn_style str

Strip bend style, "stylized" or "faithful".

'stylized'
paper str

Page size for paginated PDF layouts, "letter" or "a4".

'letter'
lanes_per_page int

portrait only -- strip lanes per page.

4
decisions_per_lane int | None

max decisions per page/lane for the paginated layouts (portrait / landscape / preview). The default (None; also 0) auto-fits as many as fit each lane without overlap; pass a positive number to force a fixed cap.

None

Returns:

Type Description
str

The path to the written file.

Source code in src/gpxsheet/__init__.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
def render(
    gpx_file: str,
    output_file: str | None = None,
    *,
    profile: str = DEFAULT_PROFILE,
    fuel_range: float | None = None,
    layout: str = "portrait",
    format: str = "pdf",
    turn_style: str = "stylized",
    paper: str = "letter",
    lanes_per_page: int = 4,
    decisions_per_lane: int | None = None,
) -> str:
    """Render a GPX route to a tank-bag navigation map.

    Args:
        gpx_file: Path to the input ``.gpx`` route or track.
        output_file: Output path; defaults to ``route.<format>``. Its extension
            should match ``format``.
        profile: One of ``minimalist``, ``sport-touring``, ``rally``.
        fuel_range: Rider fuel range in miles, used for fuel-gap analysis.
        layout: ``"portrait"`` (stacked roadbook lanes, the default),
            ``"landscape"`` (one big strip per page), ``"preview"`` (the whole
            route as one continuous image), or ``"strip"`` (a single schematic
            strip).
        format: ``"pdf"`` or ``"png"``. Paginated layouts (``portrait`` /
            ``landscape``) become a multi-page PDF or one tall stacked PNG.
        turn_style: Strip bend style, ``"stylized"`` or ``"faithful"``.
        paper: Page size for paginated PDF layouts, ``"letter"`` or ``"a4"``.
        lanes_per_page: ``portrait`` only -- strip lanes per page.
        decisions_per_lane: max decisions per page/lane for the paginated layouts
            (``portrait`` / ``landscape`` / ``preview``). The default (``None``;
            also ``0``) auto-fits as many as fit each lane without overlap; pass a
            positive number to force a fixed cap.

    Returns:
        The path to the written file.
    """
    from .pdf import render_layout

    if output_file is None:
        output_file = f"route.{format}"
    route = analyze(gpx_file, profile=profile, fuel_range=fuel_range)
    render_layout(
        route,
        output_file,
        layout=layout,
        fmt=format,
        turn_style=turn_style,
        paper=paper,
        lanes_per_page=lanes_per_page,
        decisions_per_lane=decisions_per_lane,
    )
    return str(output_file)

Analyze

gpxsheet.analyze

analyze(gpx_file: str, *, profile: str = DEFAULT_PROFILE, fuel_range: float | None = None, reassurance_interval: float | None = None, include_hazards: bool = False) -> Route

Run the route analysis engine on a GPX file.

Loads the GPX, runs decision-point detection, reassurance-marker placement, fuel analysis and segmentation, and returns the populated :class:Route. include_hazards adds OSM hazard data for validation. See the analyze output mode in docs/product.md.

Source code in src/gpxsheet/__init__.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
def analyze(
    gpx_file: str,
    *,
    profile: str = DEFAULT_PROFILE,
    fuel_range: float | None = None,
    reassurance_interval: float | None = None,
    include_hazards: bool = False,
) -> Route:
    """Run the route analysis engine on a GPX file.

    Loads the GPX, runs decision-point detection, reassurance-marker placement,
    fuel analysis and segmentation, and returns the populated :class:`Route`.
    ``include_hazards`` adds OSM hazard data for validation. See the ``analyze``
    output mode in ``docs/product.md``.
    """
    from .analysis import analyze_route as _analyze_route
    from .gpx import load_route as _load_route

    route = _load_route(gpx_file)
    return _analyze_route(
        route,
        profile=profile,
        fuel_range=fuel_range,
        reassurance_interval=reassurance_interval,
        include_hazards=include_hazards,
    )

Validate

gpxsheet.validate

Route validation (the validate output mode in docs/product.md).

Reports hazards/warnings for a route: fuel gaps exceeding the rider's range, unpaved stretches, and ferry crossings. Operates on an already-analyzed :class:~gpxsheet.models.Route; the unpaved/ferry checks need OSM data (analyze(..., include_hazards=True)), and degrade to a "skipped" note when OSM data isn't available (osmnx missing, sparse route, or Overpass failure).

ValidationReport dataclass

An analyzed route plus its validation findings (returned by validate).

Source code in src/gpxsheet/validate.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@dataclass(frozen=True, slots=True)
class ValidationReport:
    """An analyzed route plus its validation findings (returned by ``validate``)."""

    route: Route
    findings: list[Finding]

    @property
    def name(self) -> str:
        return self.route.name

    @property
    def length_miles(self) -> float:
        return self.route.length_miles

format_findings

format_findings(route: Route, findings: list[Finding]) -> str

Human-readable validation report.

Source code in src/gpxsheet/validate.py
104
105
106
107
108
109
110
111
112
113
114
115
116
def format_findings(route: Route, findings: list[Finding]) -> str:
    """Human-readable validation report."""
    lines = [f"Validate: {route.name}  ({route.length_miles:.1f} mi)", ""]
    warnings = [f for f in findings if f.level == WARNING]
    if warnings:
        lines += [f"⚠ {f.message}" for f in warnings]
    else:
        lines.append("✓ No warnings.")
    notes = [f for f in findings if f.level == INFO]
    if notes:
        lines.append("")
        lines += [f{f.message}" for f in notes]
    return "\n".join(lines)

validate_route

validate_route(route: Route, *, fuel_range: float | None = None) -> list[Finding]

Return validation findings for an analyzed route.

Source code in src/gpxsheet/validate.py
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def validate_route(route: Route, *, fuel_range: float | None = None) -> list[Finding]:
    """Return validation findings for an analyzed route."""
    findings: list[Finding] = []

    # --- Fuel range -------------------------------------------------------
    report = route.fuel_report
    if fuel_range is None:
        findings.append(Finding(INFO, "fuel", "Fuel not checked (no --fuel-range given)."))
    elif report is None:
        findings.append(Finding(INFO, "fuel", "Fuel not analyzed for this profile."))
    elif not report.exceeds_range:
        pass  # within range — a short route needs no fuel stops
    elif not route.fuel_stops:
        findings.append(
            Finding(
                WARNING,
                "fuel",
                f"No fuel stops found; the whole {report.longest_gap_miles:.0f} mi route "
                f"exceeds the {fuel_range:.0f} mi range.",
            )
        )
    else:
        findings.append(
            Finding(
                WARNING,
                "fuel",
                f"Longest fuel gap {report.longest_gap_miles:.0f} mi exceeds the "
                f"{fuel_range:.0f} mi range.",
            )
        )

    # --- Unpaved surface (needs OSM) -------------------------------------
    if route.unpaved_miles is None:
        findings.append(Finding(INFO, "unpaved", "Unpaved check skipped (no OSM data)."))
    elif route.unpaved_miles >= UNPAVED_WARN_MILES:
        findings.append(
            Finding(
                WARNING,
                "unpaved",
                f"Route includes ~{route.unpaved_miles:.1f} mi of unpaved/track surface.",
            )
        )

    # --- Ferry crossings (needs OSM) ------------------------------------
    if route.ferry_crossings is None:
        findings.append(Finding(INFO, "ferry", "Ferry check skipped (no OSM data)."))
    elif route.ferry_crossings:
        names = ", ".join(route.ferry_crossings)
        findings.append(Finding(WARNING, "ferry", f"Ferry crossing present: {names}."))

    # --- Seasonal closure (not yet implemented) -------------------------
    findings.append(
        Finding(INFO, "seasonal", "Seasonal-closure risk is not checked yet (see TODO.md).")
    )

    return findings

gpxsheet.ValidationReport dataclass

An analyzed route plus its validation findings (returned by validate).

Source code in src/gpxsheet/validate.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@dataclass(frozen=True, slots=True)
class ValidationReport:
    """An analyzed route plus its validation findings (returned by ``validate``)."""

    route: Route
    findings: list[Finding]

    @property
    def name(self) -> str:
        return self.route.name

    @property
    def length_miles(self) -> float:
        return self.route.length_miles

gpxsheet.Finding dataclass

Source code in src/gpxsheet/validate.py
23
24
25
26
27
@dataclass(frozen=True, slots=True)
class Finding:
    level: str  # WARNING | INFO
    code: str
    message: str

Lower-level helpers

gpxsheet.load_route

load_route(gpx_file: str, *, name: str | None = None) -> Route

Load a GPX file into a :class:Route without running analysis.

Source code in src/gpxsheet/__init__.py
135
136
137
138
139
def load_route(gpx_file: str, *, name: str | None = None) -> Route:
    """Load a GPX file into a :class:`Route` without running analysis."""
    from .gpx import load_route as _load_route

    return _load_route(gpx_file, name=name)

gpxsheet.analyze_route

analyze_route(route: Route, **kwargs) -> Route

Run analysis on an already-loaded :class:Route (see :mod:gpxsheet.analysis).

Source code in src/gpxsheet/__init__.py
142
143
144
145
146
def analyze_route(route: Route, **kwargs) -> Route:
    """Run analysis on an already-loaded :class:`Route` (see :mod:`gpxsheet.analysis`)."""
    from .analysis import analyze_route as _analyze_route

    return _analyze_route(route, **kwargs)

Route model

The analysis returns a populated Route. These are the dataclasses you'll read off it.

gpxsheet.models.Route dataclass

The full analyzed route graph.

Source code in src/gpxsheet/models.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
@dataclass(slots=True)
class Route:
    """The full analyzed route graph."""

    name: str
    points: list[GeoPoint]
    distances_m: list[float]  # cumulative meters, parallel to points
    waypoints: list[Waypoint] = field(default_factory=list)
    decision_points: list[DecisionPoint] = field(default_factory=list)
    reassurance_markers: list[ReassuranceMarker] = field(default_factory=list)
    fuel_stops: list[FuelStop] = field(default_factory=list)
    pois: list[POI] = field(default_factory=list)
    segments: list[Segment] = field(default_factory=list)
    spans: list[RouteSpan] = field(default_factory=list)
    fuel_report: FuelReport | None = None
    # Hazard data from OSM enrichment; None means "not assessed" (no OSM run).
    unpaved_miles: float | None = None
    ferry_crossings: list[str] | None = None

    @property
    def length_m(self) -> float:
        return self.distances_m[-1] if self.distances_m else 0.0

    @property
    def length_miles(self) -> float:
        return meters_to_miles(self.length_m)

gpxsheet.models.DecisionPoint dataclass

A navigation decision along the route.

Source code in src/gpxsheet/models.py
62
63
64
65
66
67
68
69
70
71
72
73
74
@dataclass(frozen=True, slots=True)
class DecisionPoint:
    """A navigation decision along the route."""

    mile: float
    instruction: str
    significance: int
    lat: float
    lon: float
    kind: str = DecisionKind.CRITICAL_TURN
    turn_angle: float | None = None  # signed degrees; +right / -left
    branches: tuple[Branch, ...] = ()  # roads NOT taken at this junction
    roundabout_exit: int | None = None  # Nth exit, when kind == ROUNDABOUT

gpxsheet.models.Segment dataclass

A named stretch of road between transitions.

Source code in src/gpxsheet/models.py
142
143
144
145
146
147
148
149
150
151
152
@dataclass(frozen=True, slots=True)
class Segment:
    """A named stretch of road between transitions."""

    name: str
    start_mile: float
    end_mile: float

    @property
    def length_miles(self) -> float:
        return self.end_mile - self.start_mile

gpxsheet.models.FuelStop dataclass

A fuel opportunity on or near the route.

Source code in src/gpxsheet/models.py
88
89
90
91
92
93
94
95
@dataclass(frozen=True, slots=True)
class FuelStop:
    """A fuel opportunity on or near the route."""

    mile: float
    name: str
    lat: float
    lon: float

gpxsheet.models.GeoPoint dataclass

A single route vertex.

Source code in src/gpxsheet/models.py
20
21
22
23
24
25
26
@dataclass(frozen=True, slots=True)
class GeoPoint:
    """A single route vertex."""

    lat: float
    lon: float
    ele: float | None = None