Skip to content

Library API

The public Python API mirrors the web API: the entry points — render, analyze, validate — take the same names and arguments (but run synchronously, returning values directly instead of a job). Import them from the top-level gpxsheet package. The route table lives in gpxsheet.routetable (see Route table below).

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.

What each entry point returns

function returns notes
render(gpx, out, …) str path to the written file (the out you gave, or route.<format>); the artifact itself is a PDF/PNG on disk
analyze(gpx, …) Route the fully analyzed route graph — decisions, segments, fuel, hazards
validate(gpx, …) ValidationReport the analyzed Route plus a list of Findings
load_route(gpx) Route geometry only — points/distances_m/waypoints populated, all analysis lists empty
analyze_route(route, …) Route analyzes an already-loaded Route in place of analyze's load step
routetable.render_table(gpx, out, …) pathlib.Path path to the written .html/.md/.json table
routetable.build_table_markdown(route, …) str the table markdown for an already-analyzed Route
routetable.build_table_json(route, …) str the structured table as a JSON string
routetable.build_table_data(route, …) TableDocument the structured table as typed dataclasses

Everything analytical hangs off the Route object, so that section is the bulk of this reference.

Render

layout (portrait · landscape · preview · strip) and format (pdf · png) are independent; portrait PDF is the default. format=None (the default) infers the format from the output filename extension (.png"png", anything else → "pdf"). Returns the path to the written file (a str); the map is written to disk as a side effect.

gpxsheet.render

render(gpx_file: str, output_file: str | None = None, *, profile: str = defaults.DEFAULT_PROFILE, fuel_range: float | None = None, layout: str = defaults.DEFAULT_LAYOUT, format: str | None = None, turn_style: str = defaults.TURN_STYLE, paper: str = defaults.PAPER, lanes_per_page: int = defaults.LANES_PER_PAGE, decisions_per_lane: int = defaults.DECISIONS_PER_LANE, show_branches: bool = defaults.SHOW_BRANCHES) -> 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).

DEFAULT_LAYOUT
format str | None

"pdf" or "png", or None to infer from output_file's extension (.png"png", anything else → "pdf"). Paginated layouts (portrait / landscape) become a multi-page PDF or one tall stacked PNG.

None
turn_style str

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

TURN_STYLE
paper str

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

PAPER
lanes_per_page int

portrait only -- strip lanes per page.

LANES_PER_PAGE
decisions_per_lane int

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

DECISIONS_PER_LANE
show_branches bool

draw the ghosted "roads not taken" stubs at each junction (off by default); set True to show them.

SHOW_BRANCHES

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
87
88
89
90
91
92
93
94
95
def render(
    gpx_file: str,
    output_file: str | None = None,
    *,
    profile: str = defaults.DEFAULT_PROFILE,
    fuel_range: float | None = None,
    layout: str = defaults.DEFAULT_LAYOUT,
    format: str | None = None,
    turn_style: str = defaults.TURN_STYLE,
    paper: str = defaults.PAPER,
    lanes_per_page: int = defaults.LANES_PER_PAGE,
    decisions_per_lane: int = defaults.DECISIONS_PER_LANE,
    show_branches: bool = defaults.SHOW_BRANCHES,
) -> 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"``, or ``None`` to infer from ``output_file``'s
            extension (``.png`` → ``"png"``, anything else → ``"pdf"``).
            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``). Default ``0`` auto-fits
            as many decisions as fit each lane without overlap; pass a positive
            number to force a fixed cap.
        show_branches: draw the ghosted "roads not taken" stubs at each junction
            (off by default); set True to show them.

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

    if format is None:
        ext = str(output_file).lower() if output_file is not None else ""
        format = "png" if ext.endswith(".png") else "pdf"
    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,
        show_branches=show_branches,
    )
    return str(output_file)

Analyze

Returns a populated Route (see the full structure below). With osm=True (the default) the decisions, road segments and fuel come from OpenStreetMap; osm=False forces a fast, fully offline geometry-only analysis (coarser, no auto-fuel). include_hazards=True additionally populates the hazard fields (unpaved_miles, ferry_crossings, spans) — validate sets this for you. profile and fuel_range gate the per-profile products (decision threshold, fuel report) but do not change the OSM core.

gpxsheet.analyze

analyze(gpx_file: str, *, profile: str = defaults.DEFAULT_PROFILE, fuel_range: float | None = None, include_hazards: bool = False, osm: bool = True) -> 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; osm=False forces a fast, fully offline geometry-only analysis. See the analyze output mode in docs/product.md.

Source code in src/gpxsheet/__init__.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
def analyze(
    gpx_file: str,
    *,
    profile: str = defaults.DEFAULT_PROFILE,
    fuel_range: float | None = None,
    include_hazards: bool = False,
    osm: bool = True,
) -> 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; ``osm=False`` forces
    a fast, fully offline geometry-only analysis. 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,
        include_hazards=include_hazards,
        osm=osm,
    )

Validate

Returns a ValidationReport: the analyzed Route plus a list[Finding]. A Finding has level ("warning" | "info"), code ("fuel" | "unpaved" | "ferry" | "seasonal") and a human-readable message. Warnings never raise — a route with warnings is still a valid result; read the findings. See ValidationReport and Finding.

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, ferry crossings, and seasonal-closure risk. Operates on an already-analyzed :class:~gpxsheet.models.Route; the unpaved/ferry/seasonal 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@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
113
114
115
116
117
118
119
120
121
122
123
124
125
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
 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
102
103
104
105
106
107
108
109
110
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 (needs OSM) -----------------------------------
    if route.seasonal_closures is None:
        findings.append(Finding(INFO, "seasonal", "Seasonal-closure check skipped (no OSM data)."))
    elif route.seasonal_closures:
        names = "; ".join(route.seasonal_closures)
        findings.append(
            Finding(
                WARNING,
                "seasonal",
                f"Seasonal closure risk: {names}. Verify the road is open before riding.",
            )
        )

    return findings

gpxsheet.ValidationReport dataclass

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

Source code in src/gpxsheet/validate.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@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
24
25
26
27
28
@dataclass(frozen=True, slots=True)
class Finding:
    level: str  # WARNING | INFO
    code: str
    message: str

Route table

A markdown/HTML/JSON route table (waypoints, distances, fuel/lunch markers, ETAs, sunrise/sunset) rendered from the same analysis graph, so it inherits OSM enrichment (auto-discovered fuel, road names, road-snapped distance) and adds a timing layer. It lives in gpxsheet.routetable (not the top-level namespace):

from gpxsheet.routetable import render_table, parse_departure

depart, tz = parse_departure("9:00 AM", "US/Pacific")  # natural-language or ISO
render_table("route.gpx", "route.md", fmt="markdown", departure=depart, tz=tz)
# OSM is on by default; pass osm=False for a fast, fully offline table.
# ETAs need a departure; show_cue=True appends a turn-by-turn cue sheet.

# Structured data (always imperial; lat/lon + cue always present):
from gpxsheet.routetable import build_table_data, build_table_json
from gpxsheet import analyze

route = analyze("route.gpx")
doc = build_table_data(route, departure=depart, tz=tz)   # typed TableDocument
print(doc.sections[0].rows[0].mile, doc.sections[0].rows[0].eta)
json_str = build_table_json(route, departure=depart, tz=tz)  # JSON string

Return values:

  • render_table(gpx_source, output_path, …)pathlib.Path — analyzes gpx_source and writes an html, markdown or json file, returning its path.
  • build_table_markdown(route, …)str — renders an already-analyzed Route to the table markdown (multi-track routes get one section per day). This is the string render_table writes (and wraps for HTML).
  • build_table_data(route, …)TableDocument — the structured table for an already-analyzed Route: route name/units plus a sections list (one per day), each with rows, cue, speed and sun. Always imperial (miles/mph, 1-decimal); lat/lon and the cue are always included.
  • build_table_json(route, …)strbuild_table_data serialized to a JSON string (datetimes as ISO 8601 with offset; null for absent optionals).
  • markdown_to_html(md)str — wraps table markdown in the gpxtable CSS class for styling.
  • parse_departure(departure, timezone)tuple[datetime | None, tzinfo | None] — parses a natural-language/ISO time string and an IANA zone for the ETA column; raises ValueError on unparseable input. Both elements are None when departure is None (no ETA column).

gpxsheet.routetable.render_table

render_table(gpx_source: str | Path, output_path: str | Path, *, fmt: str = 'html', imperial: bool = True, speed: float = 0.0, departure: datetime | None = None, tz: tzinfo | None = None, display_coordinates: bool = False, show_cue: bool = False, osm: bool = True) -> Path

Analyze gpx_source and write its native route table to output_path.

Runs the full analysis (OSM on by default, osm=False for a fast offline table) under the default sport-touring profile so waypoints and fuel are populated, then renders html, markdown or json. The json output is always imperial (imperial/display_coordinates/show_cue do not apply -- lat/lon and the cue are always present).

Source code in src/gpxsheet/routetable.py
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
def render_table(
    gpx_source: str | Path,
    output_path: str | Path,
    *,
    fmt: str = "html",
    imperial: bool = True,
    speed: float = 0.0,
    departure: datetime | None = None,
    tz: tzinfo | None = None,
    display_coordinates: bool = False,
    show_cue: bool = False,
    osm: bool = True,
) -> Path:
    """Analyze ``gpx_source`` and write its native route table to ``output_path``.

    Runs the full analysis (OSM on by default, ``osm=False`` for a fast offline
    table) under the default sport-touring profile so waypoints and fuel are
    populated, then renders ``html``, ``markdown`` or ``json``. The ``json``
    output is always imperial (``imperial``/``display_coordinates``/``show_cue``
    do not apply -- lat/lon and the cue are always present).
    """
    if fmt not in TABLE_FORMATS:
        raise ValueError(f"fmt must be one of {TABLE_FORMATS}, got {fmt!r}")
    from . import analyze

    route = analyze(str(gpx_source), osm=osm)
    if fmt == "json":
        text = build_table_json(route, speed=speed, departure=departure, tz=tz)
    else:
        md = build_table_markdown(
            route,
            imperial=imperial,
            speed=speed,
            departure=departure,
            tz=tz,
            display_coordinates=display_coordinates,
            show_cue=show_cue,
        )
        text = markdown_to_html(md) if fmt == "html" else md
    out = Path(output_path)
    out.write_text(text, encoding="utf-8")
    return out

TableDocument

The structured route table returned by build_table_data. All numbers are imperial (miles / mph), rounded to one decimal; datetimes are datetime objects in the display timezone (serialized to ISO 8601 with offset by to_dict() / build_table_json).

  • TableDocumentname: str, units: str (always "imperial"), sections: list[TableSection]. to_dict() returns the JSON-ready mapping.
  • TableSection — one day (or the whole route): day: int (1-based), title: str, departure: datetime | None, distance_mi: float, speed: TableSpeed, sun: TableSun | None, rows: list[TableRow], cue: list[CueEntry].
  • TableRowname, mile (section-local), since_gas_mi, marker (""/"G"/"L"/"GL"), gas/lunch/fuel_reset (bool), layover_min, eta: datetime | None, road: str | None, symbol: str | None, lat, lon.
  • CueEntrymile, eta: datetime | None, instruction, skip: list[str] (named roads not taken).
  • TableSpeedmode: str ("osm" variable limits / "flat"), avg_mph.
  • TableSunsunrise: datetime | None, sunset: datetime | None.

gpxsheet.routetable.build_table_data

build_table_data(route: Route, *, speed: float = 0.0, departure: datetime | None = None, tz: tzinfo | None = None, classifier: list[dict[str, Any]] | None = None) -> TableDocument

Build the structured (imperial) route table from an analyzed route.

The data counterpart of :func:build_table_markdown -- same sections, rows and timings, as a typed :class:TableDocument. speed is interpreted as mph (JSON is imperial-only); departure populates ETAs, the cue ETAs and the sun block. Multi-track routes yield one section per day (rebased to mile 0, +24h/day).

Source code in src/gpxsheet/routetable.py
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
def build_table_data(
    route: Route,
    *,
    speed: float = 0.0,
    departure: datetime | None = None,
    tz: tzinfo | None = None,
    classifier: list[dict[str, Any]] | None = None,
) -> TableDocument:
    """Build the structured (imperial) route table from an analyzed ``route``.

    The data counterpart of :func:`build_table_markdown` -- same sections, rows
    and timings, as a typed :class:`TableDocument`. ``speed`` is interpreted as
    mph (JSON is imperial-only); ``departure`` populates ETAs, the cue ETAs and
    the sun block. Multi-track routes yield one section per day (rebased to mile
    0, +24h/day).
    """
    slices = _day_slices(
        route, imperial=True, speed=speed, departure=departure, classifier=classifier
    )
    sections: list[TableSection] = []
    for slc in slices:
        timings = compute_timings(
            [
                StopInput(r.distance_m, timedelta(minutes=c.delay), c.fuel_reset)
                for r, c in zip(slc.rows, slc.classes, strict=True)
            ],
            departure=slc.departure,
            speed=slc.profile,
        )
        rows = [
            TableRow(
                name=(r.name or "").replace("\n", " "),
                mile=round(meters_to_miles(r.distance_m), 1),
                since_gas_mi=round(meters_to_miles(t.since_gas_m), 1),
                marker=c.marker,
                gas="G" in c.marker,
                lunch="L" in c.marker,
                fuel_reset=c.fuel_reset,
                layover_min=round(t.layover.total_seconds() / 60),
                eta=t.arrival.astimezone(tz) if (t.arrival and tz) else t.arrival,
                road=road,
                symbol=c.symbol or None,
                lat=round(r.lat, 5),
                lon=round(r.lon, 5),
            )
            for r, c, t, road in zip(slc.rows, slc.classes, timings, slc.roads, strict=True)
        ]
        length_mi = meters_to_miles(slc.length_m)
        if slc.variable:
            speed_obj = TableSpeed("osm", round(slc.profile.average_mph(length_mi), 1))
        else:
            speed_obj = TableSpeed("flat", round(slc.flat_mph, 1))
        st = _section_sun_times(slc.rows, timings)
        sun = (
            TableSun(
                sunrise=st["Sunrise"].astimezone(tz) if tz else st["Sunrise"],
                sunset=st["Sunset"].astimezone(tz) if tz else st["Sunset"],
            )
            if st
            else None
        )
        cue = [
            CueEntry(
                mile=round(mile_val, 1),
                eta=_section_eta(mile_val, slc, tz),
                instruction=instruction,
                skip=[b.name for b in branches if b.name],
            )
            for mile_val, instruction, branches in slc.decisions
        ]
        dep = slc.departure.astimezone(tz) if (slc.departure and tz) else slc.departure
        sections.append(
            TableSection(
                day=slc.day + 1,
                title=slc.title.removeprefix("## "),
                departure=dep,
                distance_mi=round(length_mi, 1),
                speed=speed_obj,
                sun=sun,
                rows=rows,
                cue=cue,
            )
        )
    return TableDocument(name=route.name, units="imperial", sections=sections)

Day cards

A per-day, read-ahead briefing (distance, climb, sunset / riding-after-dark, passes, scenic stops, and cautions) — distinct from the tank-bag table/sheet. Lives in gpxsheet.daycard (not the top-level namespace):

from gpxsheet.daycard import render_day_cards, build_day_cards
from gpxsheet.routetable import parse_departure

depart, tz = parse_departure("Sat 8am", "US/Pacific")
render_day_cards("route.gpx", "cards.md", fmt="markdown", departure=depart, tz=tz)
# fmt is "markdown" | "html" | "json"; the sun/after-dark and weather/air sections
# need a departure. Pass live=False (or set GPXSHEET_DISABLE_LIVE=1, or the umbrella
# GPXSHEET_OFFLINE=1) for a fully offline card with no network lookups.

Return values:

  • render_day_cards(gpx_source, output_path, …)pathlib.Path — analyzes and writes md/HTML/JSON cards.
  • build_day_cards(route, …)list[DayCard] — builds the cards from an already analyzed Route. Each DayCard has index, name, date, miles, moving_time, arrive, elevation_gain_ft, passes, scenic, gravel, no_services, a sun summary, the live weather / air / fire / elevation_profile (when live=True and a source is reachable), and a warnings list of Finding (.to_dict() gives the JSON view).

The live data comes from keyless, cached, graceful providers (gpxsheet.live): Open-Meteo (weather with per-sample crosswind, air quality / smoke, an elevation_profile DEM fallback when the GPX lacks elevation) and NIFC (fire perimeters near the corridor). Any source that's unavailable is omitted — the rest of the card still builds. Gate everything with live= (and GPXSHEET_DISABLE_LIVE=1); weather/air also need a departure for ETAs. The nested live types:

  • WeatherInfo (weather) — source, as_of, note (e.g. beyond the ~16-day forecast horizon), and samples: list[WeatherSample]. Each WeatherSample is mile, time, temp_f, feels_f, wind_mph, gust_mph, wind_dir_deg, crosswind_mph, precip_prob, precip_in, visibility_mi, code (WMO). Values are imperial; metric is a render-time conversion.
  • AirInfo (air) — max_aqi, max_pm25, smoke (bool), source, as_of.
  • Fire (fire: list[Fire]) — name, dist_mi, status, url.
  • ElevationProfile (elevation_profile) — min_ft, max_ft, gain_ft, source ("gpx" or the DEM fallback); only set when the DEM filled in for a GPX with no usable elevation.

New warning codes alongside the Phase-1 ones: heat, cold, wind (sustained, gust, or crosswind), precip, smoke, and fire. See day-cards-design.md for the roadmap (key-gated AirNow/ OpenWeather and cell-coverage dead zones are Phase 3).

Lower-level helpers

load_route returns an un-analyzed Route (geometry + raw waypoints only); analyze_route runs the analysis engine on a Route you already loaded and returns the same object enriched. Use these to load once and analyze with several profiles, or to inspect raw geometry.

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
145
146
147
148
149
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
152
153
154
155
156
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

analyze() returns a Route — the analyzed route graph. It is a mutable dataclass (gpxsheet.models.Route); the analysis lists default to empty and fill in as the engine runs. Miles are statute miles; mile/start_mile/ end_mile are distances along the route from its start.

Route

field type meaning
name str route name (from the GPX <trk>/<rte>, else a fallback)
points list[GeoPoint] the route vertices, in order
distances_m list[float] cumulative meters at each point, parallel to points
waypoints list[Waypoint] named points from the GPX (<wpt>) or OSM
decision_points list[DecisionPoint] where the rider must act (turns, forks, roundabouts)
reassurance_markers list[ReassuranceMarker] "you're still on route" confidence markers between decisions
fuel_stops list[FuelStop] fuel opportunities on/near the route, in order
pois list[POI] named GPX waypoints projected onto the route for the strip
segments list[Segment] named road stretches, contiguous and in order
spans list[RouteSpan] unpaved/ferry stretches to draw as styled ribbon
fuel_report FuelReport \| None fuel-gap analysis; None when not computed for the profile
unpaved_miles float \| None total unpaved/track miles; None = not assessed (no OSM/hazard run)
ferry_crossings list[str] \| None ferry names crossed; None = not assessed
seasonal_closures list[str] \| None seasonal-closure risks (curated passes + OSM seasonal/conditional tags), each a label with its typical window; None = not assessed
speed_samples_mph list[tuple[float, float]] \| None OSM speed-limit profile as (start_mile, mph) breakpoints; drives variable ETAs; None = not assessed
day_breaks list[int] point indices where a new <trk> (≈ a new day) begins, excluding 0; empty for single-track / plain <rte>
day_names list[str] one name per day (len(day_breaks) + 1 entries); an entry may be "" if its track was unnamed; empty when there are no breaks

Two computed properties: route.length_m (float, meters) and route.length_miles (float). The None-vs-empty distinction on the hazard fields matters: None means OSM/hazard data was never gathered (e.g. osm=False, or analyze without include_hazards), whereas an empty list means "assessed, none found."

Nested types

GeoPoint — a single route vertex. lat: float, lon: float, ele: float | None (elevation, often absent).

Waypoint — a named GPX <wpt> (or OSM-sourced point). lat, lon, name: str | None, symbol: str | None, plus best-effort, display-only arrival_time / departure_time: datetime | None (from Garmin BaseCamp via points; never feed distance/ETA math).

DecisionPoint — a navigation decision.

field type meaning
mile float position from route start
instruction str the cue, e.g. "Continue onto …", "Right onto …", "Left at the fork", "Take the 2nd exit onto …"
significance int 0–80; profiles keep decisions at/above their threshold (minimalist 55, sport-touring 40, rally 30)
lat, lon float location
kind str "critical_turn" (default), "fuel", or "roundabout" (see DecisionKind)
turn_angle float \| None signed degrees, +right / -left
branches tuple[Branch, …] roads not taken at the junction (for ghosted stubs); empty by default
roundabout_exit int \| None the Nth exit, when kind == "roundabout"

Branch — a road at a junction the route does not take. direction: str ("left"/"right"/"straight"/"back", relative to the rider), relative_angle: float (signed degrees off the route heading), name: str | None.

ReassuranceMarker — a confidence marker between decisions. mile, label: str, lat, lon, reason: str ("interval" default, or "town"/"feature"/ "landmark").

FuelStop — a fuel opportunity. mile: float, name: str, lat, lon.

POI — a named GPX waypoint projected onto the route for the strip. mile, name: str, lat, lon, kind: str ("waypoint" default or "food", see POIKind), symbol: str | None.

Segment — a named road stretch. name: str, start_mile: float, end_mile: float, plus a length_miles property.

RouteSpan — an unpaved or ferry stretch drawn as a styled ribbon. start_mile, end_mile, kind: str ("unpaved" or "ferry", see SpanKind), name: str | None (ferry/road name), plus a length_miles property.

FuelReport — the fuel-gap analysis on route.fuel_report. longest_gap_miles: float (the longest distance between fuel opportunities), recommended: list[str], exceeds_range: bool (does the longest gap exceed the rider's range?), fuel_range_miles: float | None (the range used).

ValidationReport and Finding

validate() returns a ValidationReport (gpxsheet.ValidationReport):

  • report.route → the analyzed Route
  • report.findingslist[Finding]
  • report.name / report.length_miles → convenience properties delegating to the route

Each Finding (gpxsheet.Finding) is level: str ("warning" | "info"), code: str ("fuel" | "unpaved" | "ferry" | "seasonal"), message: str. A clean route within range yields only info notes; gaps, unpaved miles, a ferry, or a seasonal pass surface warnings. The unpaved/ferry/seasonal checks need OSM hazard data — when it's unavailable they emit an info "skipped (no OSM data)" note instead of a verdict. The seasonal check is a hybrid of a curated seasonal-road list and OSM seasonal/*:conditional tags (gpxsheet.seasonal).