GeoCourse

GeoCourse specification, draft 0.1

1. Introduction

GeoCourse is a JSON format for one rideable cycling course: the route, its elevation, and the road along it (surface, width, bends, structures, climbs and markers). Any indoor-cycling app, trainer, route planner or editor can read and write it. It is small, self-describing and simple to parse.

How any app stores, sells, draws or serves courses is out of scope. A reference writer and validator ship with the spec; they do not define the format. The spec, schema, registry and test files live at geocourse.org.

Design principles

Conformance. MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are used as in RFC 2119 and RFC 8174. A writer produces files; a reader consumes them. A conforming writer produces files that pass every rule in section 8. A conforming reader accepts every such file, applies the interpretation rules in sections 2 to 6, and fails clearly on files it cannot interpret.

2. Model and conventions

Distance (d). A course is a directed path from d = 0 to d = length. Distance is horizontal (map) distance in metres, the same measure GPX tools and OpenStreetMap use. Grade is rise over horizontal run, so the slope angle is θ = atan(grade). A reader that moves a rider at ground speed v advances d by v · cos θ per second.

Loops. When closed is true, d wraps: position length is position 0, and riding on continues from the start. Ranges on a closed course MAY wrap (see 5.1).

Units. SI throughout: metres, seconds, metres per second, kilograms. Angles are degrees. Compass headings are clockwise from true north, 0 ≤ h < 360. Timestamps are RFC 3339 strings in UTC.

Coordinates. Geographic positions are WGS 84 longitude, latitude, in that order. The format stores no map projection: positions are unprojected, and a reader picks a projection only when it draws a map. Distances between geographic positions are geodesic distances on the WGS 84 ellipsoid. Elevation is metres above the EGM2008 geoid (roughly mean sea level); writers MUST NOT store ellipsoidal heights. Fictional courses use a local frame: x east, y north, in metres from an origin (see 4.1). Readers that draw in another frame, such as y up and z south, convert on load.

Scaled integers. Large arrays store integers, and each array declares its own scale: the real value is stored × scale. This keeps files small and lets a writer pick precision without a format change.

Encoding. A file is one JSON object in UTF-8 with no byte-order mark, and MUST be I-JSON (RFC 7493): no duplicate member names, no NaN or Infinity, integers within ±2⁵³. Member names are case-sensitive camelCase, except members that mirror OpenStreetMap keys, which keep OSM spelling (section 6), and $schema (section 3). File extension .geocourse.json. Proposed media type application/geocourse+json. Files MAY be sent gzip or Brotli compressed.

3. Course object

The root of every file. Members not listed here MUST be ignored by readers (section 7 covers extensions).

Member Type Req. Meaning
type string MUST Always "Course"
format string MUST Spec version, "major.minor"; this draft is "0.1"
id string MUST Stable across versions, ^[a-z0-9-]{1,64}$
version integer MUST Content version, 1 or more; any change to the course raises it
name string MUST Display name
description string MAY Plain text
length number MUST Metres, greater than 0
closed boolean MUST True for a loop
created string MAY RFC 3339
authors array of string MAY People or organisations
licence string SHOULD SPDX identifier, for example "ODbL-1.0"
attribution array of string MUST if any source requires it Credits to show with the course
sources array of Source SHOULD Where geometry, elevation and road data came from
summary object MAY elevationGain, elevationLoss, maxGrade; informative only
geometry Geometry see 4.1 Plan view of the route
profile Profile MUST Sampled elevation, grade, heading
ranges array of Range MUST Everything placed along the route
osm object MAY Course-level OpenStreetMap link (section 6)
extensionsUsed, extensionsRequired, extensions MAY Section 7

Source. { "kind": "geometry" | "elevation" | "road", "name": string, "licence": SPDX string, "url"?: string, "retrieved"?: RFC 3339 }. Example: elevation from Copernicus GLO-30, road data from OpenStreetMap.

Summary. Readers SHOULD recompute rather than trust it. When given, elevationGain MUST equal the sum of positive differences between consecutive elevation samples as stored, with no extra smoothing, so every app shows the same number.

Schema. A file MAY carry "$schema": "https://geocourse.org/schema/0.1/geocourse.schema.json" so editors and validators find the schema on their own. Readers MUST NOT depend on it, and MUST NOT fetch it to read a file.

4. Geometry and profile

Both use the same sampling rule. With step s, there are n = ceil(length / s) + 1 samples, and sample i sits at dᵢ = min(i · s, length). The last sample is always exactly at the end, even when the last stretch is shorter than s.

4.1 Geometry

{ "frame", "step", "scale", "positions", "origin"? }

4.2 Profile

{ "step", "elevation", "grade"?, "heading"?, "bendRadiusLimit"? }

step is metres, greater than 0 and at most 50; 10 or less is RECOMMENDED. Each channel is { "scale": number > 0, "values": [integer, …] } with exactly n values.

Channel Req. Value Recommended scale
elevation MUST metres above the geoid 0.1 (decimetres)
grade MAY rise over run, positive uphill in the direction of travel 0.0001 (8 % = 800)
heading MUST if no geometry compass degrees of travel 0.1

5. Ranges

Everything about the road is a range along d.

5.1 Range object

{ "type", "from", "to", "id"?, "properties"?, "osm"?, "extensions"? }

5.2 surface (MUST cover the course)

Surface ranges MUST cover [0, length] with no gaps and no overlaps. Physics members are REQUIRED; readers MUST use them as given and MUST NOT replace them from the OSM tags.

Property Req. Meaning
crr MUST Rolling-resistance coefficient
grip MUST Tyre-on-road friction coefficient, dry tarmac about 0.8; used for both cornering and braking
surface SHOULD OSM value: asphalt, concrete, paving_stones, sett, compacted, fine_gravel, gravel, dirt, grass, mud, …
smoothness MAY OSM value: excellent to impassable
tracktype MAY OSM value: grade1 to grade5
name MAY Display text, for example “Pavé sector 12”

Typical dry values for road-bike tyres (informative, from the reference writer), with the matching OSM tags:

Surface OSM tags crr grip
Smooth tarmac surface=asphalt, smoothness=good 0.004 0.8
Worn or patched tarmac asphalt, smoothness=intermediate 0.005 0.75
Cracked tarmac asphalt, smoothness=bad 0.006 0.7
Potholed tarmac asphalt, smoothness=very_bad 0.008 0.65
Setts surface=sett 0.009 0.6
Cobbles surface=unhewn_cobblestone 0.012 0.55
Dirt surface=dirt 0.010 0.55
Gravel surface=gravel 0.012 0.5
Roots surface=ground, smoothness=bad 0.015 0.45
Rock surface=rock 0.018 0.45
Mud surface=mud 0.025 0.3
Packed snow surface=snow 0.030 0.2

Packed snow on the road is a surface. Rain, wet roads and ice from the weather are ride conditions, not course data.

5.3 road

The way the course runs on: a road, cycle path, track or trail. Road ranges SHOULD cover the whole course; a new range starts wherever a value changes.

Property Req. Meaning
width SHOULD Metres of usable surface, edge to edge (a two-way road: the full carriageway), > 0
widthSource MAY "osm", "survey", "estimated" or "design"
highway SHOULD OSM class: primary, residential, cycleway, track, path, …
lanes, name, ref, lit, cycleway MAY Lane count, road name, road number, street lights, OSM cycleway value

Width lets a reader draw the road at the right size, keep riders on it, and judge room for passing and drafting. It has no physics effect. OSM often has no width tag, so writers SHOULD estimate one and mark it estimated. A designed course's width is exact; mark it design. When width is missing, readers SHOULD assume one from highway (informative: primary 7 m, residential 5 m, cycleway 2.5 m, track 3 m, path 1.5 m).

5.4 bend

radius MUST (m, at the tightest point, > 0), direction MUST ("left" or "right"), apex MUST (d of the tightest point, within the range; where the radius stays within 1 % of its minimum over a stretch, as on a constant-radius arc, the middle of that stretch).

5.5 climb

name MAY, category MAY ("4", "3", "2", "1", "HC"). Gain and average grade come from the profile; readers compute them.

5.6 segment

A timed stretch for results. Range id MUST be present and stay the same across course versions while from, to and the road under it are unchanged; if the stretch changes, the id MUST change, so old times are never compared against a different road. name MAY.

5.7 structure

kind MUST: bridge, tunnel or building_passage. name MAY. For drawing, lighting and GPS-style effects; no physics effect.

5.8 hazard

kind MUST: cattle_grid, speed_bump, railway_crossing, ford, gate or an extension value. Usually a point. Readers MAY warn or slow the rider; the spec sets no physics effect.

5.9 marker

kind MUST: start, finish, summit, sprint, feed, town, viewpoint, sign or info. name and text MAY. Usually a point. An open course SHOULD have start at 0 and finish at length. A closed course SHOULD have start at 0 and MUST NOT have a finish: laps, and where a ride ends, are up to the reader.

6. OpenStreetMap linking

A range MAY name the OpenStreetMap elements it came from:

"osm": [ { "type": "way" | "node" | "relation", "id": integer, "version"?: integer, "from"?: m, "to"?: m } ]

from and to narrow the ref to part of the range when one range spans several ways. The root MAY carry "osm": { "snapshot": RFC 3339, "relation"?: integer }: the date of the OSM data used, and the route relation the course follows, if any.

Rules

  1. Refs are provenance. A file MUST be fully rideable and drawable without resolving any ref.
  2. Copy, don't point. Any value a reader needs MUST be in properties. Members that mirror OSM keys use the OSM key and value spelling unchanged (surface, smoothness, tracktype, highway, width, lanes, lit, cycleway). Other tags MAY be kept verbatim in a tags property (string to string), so a round trip loses nothing.
  3. Ids drift. Ways split, merge and get deleted, and tags change. Writers SHOULD give version and the root snapshot. Readers MUST NOT assume a ref still exists or still has the same tags. Refreshing values from live OSM makes a new course version.
  4. No direction. A ref says nothing about travel direction; an OSM way may run either way along the course.

Tag to range mapping (for writers):

OSM tags Range
surface, smoothness, tracktype surface (writer also sets crr, grip)
highway, name, ref, width, lanes, lit, cycleway road
bridge=yes, tunnel=yes, tunnel=building_passage structure
barrier=cattle_grid, traffic_calming=bump, railway=level_crossing, ford=yes, barrier=gate hazard (node, usually a point)
natural=peak, place=town or village marker

Data licence. The spec puts no licence on courses. Authors are responsible for meeting the terms of their sources. A file SHOULD state its own licence and carry the attribution its sources require; for OpenStreetMap data that is ODbL 1.0 and “© OpenStreetMap contributors”.

7. Extensions and versioning

Extensions follow the glTF pattern.

Versioning. format is "major.minor".

8. Validation rules

A file is valid only when all of these hold. A JSON Schema (https://geocourse.org/schema/0.1/geocourse.schema.json) checks structure, types, patterns and value ranges; the reference validator checks every rule, including the counts and cross-references a schema cannot express.

  1. type is "Course"; format matches ^\d+\.\d+$.
  2. id matches ^[a-z0-9-]{1,64}$; version is an integer of 1 or more; length is finite and greater than 0.
  3. profile.step is greater than 0 and at most 50; every channel has scale > 0 and exactly ceil(length / step) + 1 integer values.
  4. geometry, when present, has 2 × (ceil(length / geometry step) + 1) integer positions; wgs84 longitudes lie within ±180 and latitudes within ±90.
  5. Either geometry or profile.heading is present; headings lie in [0, 360).
  6. Every range has 0 ≤ from, to ≤ length, and from ≤ to unless closed is true. Range ids are unique in the file.
  7. Surface ranges cover [0, length] exactly, with no gaps or overlaps; crr ≥ 0, grip > 0.
  8. Geometry length agrees with d within max(1 m, 0.5 %) at every sample.
  9. When grade is present, its integral matches the elevation change within 2 m over every 1 km window. A window starts at every profile sample and slides by one sample; a course of 1 km or less is checked as one window; windows do not wrap on a closed course.
  10. Bends have radius > 0 and apex inside the range, and do not overlap; with bendRadiusLimit set, none is missing: every measured bend has a listed bend in the same direction whose apex lies within the measured stretch, give or take 1 m.
  11. Every segment has an id.
  12. On a closed course the first and last elevation samples are equal, and the first and last geometry positions are within 1 m.
  13. Every name in extensionsRequired is also in extensionsUsed.
  14. attribution is present whenever a source licence is on the registry's attribution list. An unknown licence id is a warning, not an error.
  15. A closed course has no finish marker.

Test files are published at https://geocourse.org/tests/0.1/: valid files a reader must accept and ride to known results (Frost Gate Traverse among them), and invalid files, one per rule, that a validator must reject.

9. Example

A 25 m test course at a steady 8 %, heading east. With step 10 there are four samples, at 0, 10, 20 and 25 m. Geometry uses step 10 here only to keep the example short. OSM ids are placeholders.

{
  "$schema": "https://geocourse.org/schema/0.1/geocourse.schema.json",
  "type": "Course",
  "format": "0.1",
  "id": "example-ramp",
  "version": 1,
  "name": "Example ramp",
  "length": 25,
  "closed": false,
  "licence": "ODbL-1.0",
  "attribution": ["© OpenStreetMap contributors"],
  "sources": [
    { "kind": "road", "name": "OpenStreetMap", "licence": "ODbL-1.0", "retrieved": "2026-10-01T00:00:00Z" }
  ],
  "summary": { "elevationGain": 2.0, "elevationLoss": 0, "maxGrade": 0.08 },
  "geometry": {
    "frame": "wgs84",
    "step": 10,
    "scale": 1e-7,
    "positions": [107522000, 599139000, 107523792, 599139000,
                  107525584, 599139000, 107526480, 599139000]
  },
  "profile": {
    "step": 10,
    "elevation": { "scale": 0.1, "values": [1200, 1208, 1216, 1220] },
    "grade": { "scale": 0.0001, "values": [800, 800, 800, 800] },
    "heading": { "scale": 0.1, "values": [900, 900, 900, 900] },
    "bendRadiusLimit": 400
  },
  "ranges": [
    { "type": "marker", "from": 0, "to": 0, "properties": { "kind": "start" } },
    { "type": "road", "from": 0, "to": 25,
      "properties": { "highway": "tertiary", "name": "Example Road", "width": 5.5 },
      "osm": [{ "type": "way", "id": 123456789, "version": 7 }] },
    { "type": "segment", "id": "example-ramp-sprint", "from": 0, "to": 25,
      "properties": { "name": "Ramp sprint" } },
    { "type": "surface", "from": 0, "to": 25,
      "properties": { "crr": 0.004, "grip": 0.8, "surface": "asphalt", "smoothness": "good" },
      "osm": [{ "type": "way", "id": 123456789, "version": 7 }] },
    { "type": "hazard", "from": 18, "to": 18,
      "properties": { "kind": "cattle_grid" },
      "osm": [{ "type": "node", "id": 987654321 }] },
    { "type": "marker", "from": 25, "to": 25, "properties": { "kind": "finish" } }
  ],
  "osm": { "snapshot": "2026-10-01T00:00:00Z" },
  "extensionsUsed": ["acme_scenery"],
  "extensions": { "acme_scenery": { "tileSize": 512 } }
}

No bend ranges: the road is straight, and bendRadiusLimit says the list is complete. acme_scenery is used but not required, so a reader without it rides the course the same way.

10. Scope, changes and open questions

Out of scope for the core spec

Changes from the current format 0 (for files written before this draft)

Open questions

Findings from the first export (Frost Gate Traverse, 2026-10-30)

The reference writer's first export is valid under every rule of section 8: 75.6 km, 756 KB (289 KB gzip), 816 bends (format 0: 183 KB, 881 bends with an 8 m window).

Appendix A. GPX and FIT (informative)

For trainers and bike computers that only read GPX 1.1 or FIT course files. Both carry a track and points, not surfaces, physics or road data, so conversion loses those.

Course GPX 1.1 FIT course file
name trk/name course.name
Geometry and elevation One trkpt (lat, lon, ele) per geometry sample One record per sample: position, altitude, distance
Distance d Not stored; readers recompute record.distance, horizontal metres
marker start, finish, info wpt course_point type generic
marker summit, sprint, feed wpt with type course_point summit, sprint, food
climb with category Not carried course_point fourth_category to hors_category, at the summit
bend Not carried course_point at the apex: slight_left / slight_right under 45° of turn, left / right to 120°, sharp_left / sharp_right to 160°, u_turn beyond
hazard wpt with type "danger" course_point danger
surface, road, physics Lost (or a GPX extension) Lost