GPX Doctor
A Ruby gem for parsing and manipulating GPX routes and activity files. The parser reads GPX 1.1 and GPX 1.0 (http://www.topografix.com/GPX/1/0, e.g. Traseo and older RideWithGPS exports); the builder always writes GPX 1.1.
Installation
Add to your Gemfile:
gem "gpx_doctor"Or install directly:
gem install gpx_doctorConfiguration
GpxDoctor.configure do |config|
config.elevation_server = true
config.elevation_server_url = "https://elevation.example.com"
config.elevation_server_user = "user"
config.elevation_server_password = "secret"
config.unit_system = :imperial # or :metric (default)
end
GpxDoctor.configuration.elevation_server_url # => "https://elevation.example.com"
GpxDoctor.reset_configuration! # resets to defaults| Option | Type | Default | Description |
|---|---|---|---|
elevation_server |
Boolean | false |
Whether to use an elevation server |
elevation_server_url |
String | nil |
URL of the elevation server |
elevation_server_user |
String | nil |
Username for the elevation server |
elevation_server_password |
String | nil |
Password for the elevation server |
unit_system |
Symbol | :metric |
Unit system (:metric or :imperial) for distance and elevation values |
Unit System
The unit_system configuration affects the following fields:
-
:metric(default):-
distance_to_nextandelevation_changeare in meters -
cumulative_distanceis in kilometers
-
-
:imperial:-
distance_to_nextandelevation_changeare in feet -
cumulative_distanceis in miles
-
# Use imperial units
GpxDoctor.configure do |config|
config.unit_system = :imperial
end
result = GpxDoctor::Parser.parse("path/to/file.gpx", params: {
segment_statistics: true,
cumulative_distance: true
})
# distance_to_next and elevation_change will be in feet
# cumulative_distance will be in milesParsing
From a file
result = GpxDoctor::Parser.parse("path/to/file.gpx")From a string
result = GpxDoctor::Parser.parse_string(xml_string)Parsing with processing parameters
Both parse and parse_string accept an optional params: hash to enable post-processing:
result = GpxDoctor::Parser.parse("path/to/file.gpx", params: {
max_distance: 200, # insert interpolated points so no two consecutive points exceed this distance (metres)
max_points: 500, # reduce each segment to at most this many points
segment_statistics: true, # compute distance_to_next, elevation_change, direction for each point
cumulative_distance: true, # add cumulative distance from start of each segment/route
label_interval: 1.0, # insert an interpolated, labelled point at every interval mark (kilometres or miles based on unit_system)
enhance_elevation: true, # fetch missing elevations from the configured elevation server
full_poi_data: true, # populate result.pois with start/finish boundary data for the whole GPX and each track segment
performance_analysis: true # populate result.analysis with activity performance data (requires path timestamps)
})Processing is applied in the following order:
-
max_distance— segment splitting (interpolates intermediate points) -
max_points— point reduction -
segment_statistics— per-point statistics (distance, bearing, elevation change) -
cumulative_distance— cumulative distance from the start of each segment/route -
label_interval— labelled point insertion -
enhance_elevation— elevation lookup via the elevation server -
full_poi_data— POI boundary extraction (start, finish, and per-segment boundaries) -
performance_analysis— activity performance analysis for timed path points
enhance_elevation: true requires the elevation server to be configured (see Configuration above). It only fills in points that have no elevation value; existing elevations are left unchanged.
cumulative_distance: true adds a cumulative_distance field to each point, representing the cumulative distance in kilometers from the start of its segment or route. For tracks with multiple segments, each segment's cumulative distance starts at 0.0 (gaps between segments are not included in the calculation).
label_interval: 1.0 walks each route/segment and inserts an interpolated point at every multiple of the given interval (measured as cumulative distance from the start, in kilometres for :metric or miles for :imperial — see Unit System above), setting the new point's label field to that distance (e.g. 1.0 for the point at the 1.0 km/mi mark). A mark falling on an existing point (within a small tolerance) is skipped rather than duplicated. label_interval runs after cumulative_distance, reusing its cumulative_distance values instead of recalculating them when cumulative_distance: true is also given. Because label_interval is applied after max_points, the resulting number of points may exceed max_points.
full_poi_data: true populates result.pois with the first and last geographic point of the entire GPX (across all routes and track segments), plus optional per-segment boundary data. Distances within each segment start at 0.0 and reflect that segment's length only. The ele key is omitted for points that have no elevation value. The global finish distance is the sum of all individual collection lengths. See result.pois below for the output shape.
performance_analysis: true adds an analysis hash to the parse result. It raises GpxDoctor::InvalidGpxError if no route/track path timestamps are present. Returned fields include:
- Time-based metrics (when timestamps are present):
total_time(seconds),distance(km),avg_speed(km/h),top_speed(km/h),speed_array(km/h for each consecutive timed point pair) - Elevation-based metrics (when elevations are present):
ascent,descent,elevation_change_array - Combined time + elevation metrics (when both are present on consecutive point pairs):
top_vertical_speed,vertical_speed_array
Accessing data
result.points # => [#<Waypoint lat=…, lon=…, ele=…>, …] (the path)
result.all_points # => [#<Waypoint …>, …] (the path plus standalone waypoints)
result.waypoints # => [#<Waypoint …>] (top-level <wpt> elements only)
result.routes # => [#<Route …>]
result.tracks # => [#<Track …>]
result.metadata # => #<Metadata …> (or nil)
result.pois # => Hash (only when parsed with full_poi_data: true, otherwise nil)
result.analysis # => Hash (only when parsed with performance_analysis: true, otherwise nil)result.points is a flat array describing the path, in order:
-
<rtept>elements inside each<rte> -
<trkpt>elements inside each<trkseg>inside each<trk>
Top-level <wpt> elements are not included. They are points of interest that may
sit anywhere on — or off — the path, and exporters list them in file order rather than
path order, so treating them as path points draws straight lines back and forth across
the map. Read them from result.waypoints, or use result.all_points (standalone
waypoints first, then the path) when you genuinely want every geographic point in the
file. All processing params (max_points, cumulative_distance, label_interval,
segment_statistics, full_poi_data, performance_analysis) operate on the path only; enhance_elevation is
the exception and fills in elevations for standalone waypoints too.
result.pois
When parsed with full_poi_data: true, result.pois contains boundary data for the entire GPX and each track segment:
result = GpxDoctor::Parser.parse("route.gpx", params: { full_poi_data: true })
result.pois
# =>
# {
# start: { lon: 16.38, lat: 48.23, ele: 170.0, distance: 0.0 },
# finish: { lon: 16.39, lat: 48.24, ele: 175.0, distance: 1.42 },
# segments: [
# {
# start: { lon: 16.38, lat: 48.23, ele: 170.0, distance: 0.0 },
# finish: { lon: 16.39, lat: 48.24, ele: 175.0, distance: 1.42 }
# }
# ]
# }-
start/finish— first and last geographic point across all routes and track segments, each withlon,lat,distance(andelewhen present).startalways hasdistance: 0.0;finishdistance is the sum of all individual route/segment lengths. -
segments— present only when the GPX contains track segments. Each entry is{start:, finish:}for one<trkseg>, with distances measured from the beginning of that segment (startis alwaysdistance: 0.0). - The
elekey is omitted for points that have no elevation value. - Distance values respect the configured
unit_system(kilometres for:metric, miles for:imperial).
Comparing tracks
GpxDoctor::Similarity tells how much of a track follows an already known path. Every point of the compared data is measured against the reference path — against its points and against the stretches between them — and the returned value is the fraction of compared points lying within tolerance metres of it: 0.0 when none of them follow the path, 1.0 when all of them do. Comparing a file with itself therefore returns 1.0.
The order of the points is never taken into account, only where they lie: a track compared with its own reverse returns 1.0, and a circular route matches the same lap whatever point it is started at.
# Two GPX files
GpxDoctor::Similarity.compare_files("original.gpx", "compared.gpx") # => 0.94
# A GPX file and a collection of coordinates
GpxDoctor::Similarity.compare("original.gpx", [[48.21, 16.36], [48.22, 16.37]])
GpxDoctor::Similarity.compare("original.gpx", [{ lat: 48.21, lon: 16.36 }])
GpxDoctor::Similarity.compare("original.gpx", "SRID=4326;LINESTRING(16.36 48.21, 16.37 48.22)")
# A GPX file and GeoJSON
GpxDoctor::Similarity.geojson_compare("original.gpx", geojson)Options
| Option | Type | Default | Description |
|---|---|---|---|
tolerance |
Float | 25.0 |
How far, in metres, a point may sit from the reference path and still count as following it |
sample_interval |
Float | nil |
When given, the compared points are treated as a continuous track and additional positions are tested every sample_interval metres along the straight lines between them |
coordinate_order |
Symbol | :lat_lon |
Order of bare numeric pairs such as [48.21, 16.36]; use :lon_lat for PostGIS/GeoJSON style coordinates (compare only) |
GpxDoctor::Similarity.compare_files("original.gpx", "compared.gpx", tolerance: 50)
GpxDoctor::Similarity.compare("original.gpx", [[16.36, 48.21]], coordinate_order: :lon_lat)
# Judge a sparse track by the path it describes rather than by its few points
GpxDoctor::Similarity.compare("original.gpx", coordinates, sample_interval: 25)Accepted input
Both arguments of compare (and the first argument of geojson_compare) accept:
- a path to a GPX, GeoJSON or WKT file, or the content of one as a String
- a
GpxDoctor::Parser::Result,Route,TrackorTrackSegment - an array of coordinates, each of which may be a
Waypoint(or any object answering tolat/lon,latitude/longitudeorx/y, such as a PostGIS point), a[lat, lon]pair, a hash keyed bylat/lon,latitude/longitudeorx/y(string or symbol keys), or a WKT point - a nested array (for example
[[[48.21, 16.36], …], […]]), where each inner array is a separate track - WKT/EWKT geometries:
POINT,MULTIPOINT,LINESTRING,MULTILINESTRING,POLYGONandMULTIPOLYGON, with coordinates read aslon lat - GeoJSON, as a Hash or a JSON string;
geojson_compareaccepts the same and always reads positions as[lon, lat]per RFC 7946
Standalone <wpt> elements — and isolated GeoJSON points — are points of interest rather than part of a path, so they are ignored on both sides unless the document holds nothing else. Distances use the same flat-earth approximation as the rest of the gem and are always expressed in metres, regardless of the configured unit_system.
Unsupported or malformed input raises ArgumentError.
Building GPX files
The GpxDoctor::Builder class generates GPX 1.1 XML from a Result object (the same structure returned by the parser).
Build to a string
result = GpxDoctor::Parser::Result.new(
waypoints: [],
routes: [],
tracks: [],
metadata: nil
)
xml_string = GpxDoctor::Builder.build(result)
# => "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<gpx version=\"1.1\"..."
# Optional: specify a custom creator attribute (identifies the software that created the GPX file)
xml_string = GpxDoctor::Builder.build(result, creator: 'My Application')Build to a file
GpxDoctor::Builder.build_file(result, 'path/to/output.gpx')
# Writes the GPX XML to the file and returns the XML string
# Optional: specify a custom creator attribute
GpxDoctor::Builder.build_file(result, 'path/to/output.gpx', creator: 'My Application')Input structure
The builder expects a GpxDoctor::Parser::Result object with the following fields:
-
waypoints— Array ofGpxDoctor::Waypointobjects (top-level waypoints) -
routes— Array ofGpxDoctor::Routeobjects -
tracks— Array ofGpxDoctor::Trackobjects -
metadata—GpxDoctor::Metadataobject (optional)
All model classes are simple Ruby objects with attributes matching the GPX 1.1 specification (see Model field reference below).
Example: Creating a GPX file from scratch
require 'gpx_doctor'
# Create waypoints
waypoint = GpxDoctor::Waypoint.new(
lat: 48.2093723,
lon: 16.356099,
ele: 160.0,
name: 'Vienna',
desc: 'Capital of Austria'
)
# Create a route with points
route_point_1 = GpxDoctor::Waypoint.new(lat: 48.21, lon: 16.36, ele: 155.0)
route_point_2 = GpxDoctor::Waypoint.new(lat: 48.22, lon: 16.37, ele: 162.0)
route = GpxDoctor::Route.new(
name: 'City Tour',
desc: 'A route through the city',
points: [route_point_1, route_point_2]
)
# Create a track with segments
track_point_1 = GpxDoctor::Waypoint.new(lat: 48.23, lon: 16.38, ele: 170.0)
track_point_2 = GpxDoctor::Waypoint.new(lat: 48.24, lon: 16.39, ele: 175.0)
segment = GpxDoctor::TrackSegment.new(points: [track_point_1, track_point_2])
track = GpxDoctor::Track.new(
name: 'Morning Run',
desc: 'My morning jog',
segments: [segment]
)
# Create metadata (optional)
metadata = GpxDoctor::Metadata.new(
name: 'My GPX File',
desc: 'A custom GPX file',
time: Time.now
)
# Build the result object
result = GpxDoctor::Parser::Result.new(
waypoints: [waypoint],
routes: [route],
tracks: [track],
metadata: metadata
)
# Generate GPX XML
xml_string = GpxDoctor::Builder.build(result, creator: 'My Application')
# Or write directly to a file
GpxDoctor::Builder.build_file(result, 'my_route.gpx', creator: 'My Application')Round-trip workflow
You can parse an existing GPX file, modify it, and build it back:
# Parse existing file
result = GpxDoctor::Parser.parse('input.gpx')
# Modify data
result.routes.first.name = 'Updated Route Name'
result.waypoints << GpxDoctor::Waypoint.new(lat: 48.5, lon: 16.5, name: 'New Point')
# Build back to GPX
GpxDoctor::Builder.build_file(result, 'output.gpx')Model field reference
Waypoint
| Field | Type | Notes |
|---|---|---|
lat |
Float | Required |
lon |
Float | Required |
ele |
Float | Elevation in metres |
time |
Time | |
magvar |
Float | Magnetic variation |
geoidheight |
Float | |
name |
String | |
cmt |
String | Comment |
desc |
String | Description |
src |
String | Source |
links |
Array | |
sym |
String | Symbol |
type |
String | |
fix |
String |
none, 2d, 3d, dgps, pps
|
sat |
Integer | Number of satellites |
hdop |
Float | |
vdop |
Float | |
pdop |
Float | |
ageofdgpsdata |
Float | |
dgpsid |
Integer | 0–1023 |
distance_to_next |
Float | Distance to next point (metres or feet based on unit_system) — set by segment_statistics: true
|
elevation_change |
Float | Elevation change to next point (metres or feet based on unit_system) — set by segment_statistics: true
|
direction |
Float | Bearing to next point (0–360°) — set by segment_statistics: true
|
cumulative_distance |
Float | Cumulative distance from segment/route start (kilometres or miles based on unit_system) — set by cumulative_distance: true; also set (to the same value as label) for points inserted by label_interval
|
label |
Float | Cumulative distance mark for a point inserted by label_interval (kilometres or miles based on unit_system); nil for points not created for labelling — set by label_interval
|
Waypoint#to_h returns a hash of all non-nil fields.
Metadata
| Field | Type |
|---|---|
name |
String |
desc |
String |
author |
Person |
copyright |
Copyright |
links |
Array |
time |
Time |
keywords |
String |
bounds |
Bounds |
Route
| Field | Type |
|---|---|
name |
String |
cmt |
String |
desc |
String |
src |
String |
links |
Array |
number |
Integer |
type |
String |
points |
Array |
Track
| Field | Type |
|---|---|
name |
String |
cmt |
String |
desc |
String |
src |
String |
links |
Array |
number |
Integer |
type |
String |
segments |
Array |
points |
Array (all points across all segments) |
TrackSegment
| Field | Type |
|---|---|
points |
Array |
Person
| Field | Type |
|---|---|
name |
String |
email |
|
link |
Link |
Copyright
| Field | Type |
|---|---|
author |
String |
year |
String |
license |
String |
Link
| Field | Type |
|---|---|
href |
String |
text |
String |
type |
String |
Email
| Field | Type |
|---|---|
id |
String |
domain |
String |
Email#to_s returns "id@domain".
Bounds
| Field | Type |
|---|---|
minlat |
Float |
minlon |
Float |
maxlat |
Float |
maxlon |
Float |
Development
Building the gem
gem build gpx_doctor.gemspecThis produces a file like gpx_doctor-0.1.0.gem in the current directory.
Running tests
bundle install
bundle exec rspecPublishing to RubyGems
-
Create an account at https://rubygems.org if you don't have one.
-
Set up credentials (one-time):
gem signin
This stores your API key in
~/.gem/credentials. -
Build and push:
gem build gpx_doctor.gemspec gem push gpx_doctor-0.1.0.gem
-
Verify the release at
https://rubygems.org/gems/gpx_doctor.
Tip: Bump
GpxDoctor::VERSIONinlib/gpx_doctor/version.rbbefore each release and tag the commit:git tag -a v0.1.0 -m "Release 0.1.0" git push origin v0.1.0
License
MIT