Project

gpx_doctor

0.0
The project is in a healthy, maintained state
GPX Doctor helps with manipulation of GPX routes. It parses GPX 1.1 files into Ruby objects.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 3.12

Runtime

~> 1.15
 Project Readme

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_doctor

Configuration

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_next and elevation_change are in meters
    • cumulative_distance is in kilometers
  • :imperial:
    • distance_to_next and elevation_change are in feet
    • cumulative_distance is 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 miles

Parsing

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:

  1. max_distance — segment splitting (interpolates intermediate points)
  2. max_points — point reduction
  3. segment_statistics — per-point statistics (distance, bearing, elevation change)
  4. cumulative_distance — cumulative distance from the start of each segment/route
  5. label_interval — labelled point insertion
  6. enhance_elevation — elevation lookup via the elevation server
  7. full_poi_data — POI boundary extraction (start, finish, and per-segment boundaries)
  8. 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 with lon, lat, distance (and ele when present). start always has distance: 0.0; finish distance 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 (start is always distance: 0.0).
  • The ele key 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, Track or TrackSegment
  • an array of coordinates, each of which may be a Waypoint (or any object answering to lat/lon, latitude/longitude or x/y, such as a PostGIS point), a [lat, lon] pair, a hash keyed by lat/lon, latitude/longitude or x/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, POLYGON and MULTIPOLYGON, with coordinates read as lon lat
  • GeoJSON, as a Hash or a JSON string; geojson_compare accepts 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 of GpxDoctor::Waypoint objects (top-level waypoints)
  • routes — Array of GpxDoctor::Route objects
  • tracks — Array of GpxDoctor::Track objects
  • metadata — GpxDoctor::Metadata object (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 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.gemspec

This produces a file like gpx_doctor-0.1.0.gem in the current directory.

Running tests

bundle install
bundle exec rspec

Publishing to RubyGems

  1. Create an account at https://rubygems.org if you don't have one.

  2. Set up credentials (one-time):

    gem signin

    This stores your API key in ~/.gem/credentials.

  3. Build and push:

    gem build gpx_doctor.gemspec
    gem push gpx_doctor-0.1.0.gem
  4. Verify the release at https://rubygems.org/gems/gpx_doctor.

Tip: Bump GpxDoctor::VERSION in lib/gpx_doctor/version.rb before 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