The gpx library is published on Maven Central.
<dependency>
<groupId>io.github.glandais.gpx2web</groupId>
<artifactId>gpx</artifactId>
<version>1.5.2</version>
</dependency>
dependencies {
implementation("io.github.glandais.gpx2web:gpx:1.5.2")
}
The badge above is the source of truth for the current version.
Write FIT file (single path)
Detect climbs
Simplify GPX file (both on location and elevation)
References:
The wheel bearings power loss is calculated as a quadratic function of speed:
Formula: -speed * (91 + 8.7 * speed) / 1000.0
Implementation: WheelBearingsPowerProvider (io.github.glandais.gpx.virtual.power.rolling:22)
Verification: ✅ Formula matches academic literature for wheel and bearing resistance
Rolling resistance accounts for tire deformation and surface friction, adjusted for road grade:
Formula: -cos(atan(grade)) * mass * g * speed * crr
Implementation: RollingResistancePowerProvider (io.github.glandais.gpx.virtual.power.rolling:29)
final double mKg = course.getCyclist().getMKg(); // Total mass (cyclist + bike)
final double crr = course.getBike().getCrr(); // Rolling resistance coefficient
final double grade = location.getGrade(); // Road gradient
double coef = Math.cos(Math.atan(grade)); // Grade adjustment factor
double powerRollingResistance = -coef * mKg * Constants.G * location.getSpeed() * crr;
Parameters:
mKg: Total system mass (kg)crr: Rolling resistance coefficient (bike-specific)grade: Road gradient (rise/run)Constants.G: Gravitational acceleration (9.8 m/s²)Verification: ✅ Formula correctly implements standard rolling resistance physics
cos(atan(grade)) adjustment is mathematically valid for inclined surfacesGravitational power accounts for the energy required to climb (or gained when descending) on inclined surfaces:
Formula: -sin(atan(grade)) * mass * g * speed
Implementation: GravPowerProvider (io.github.glandais.gpx.virtual.power.grav:26)
final double mKg = course.getCyclist().getMKg(); // Total mass (cyclist + bike)
double grade = location.getGrade(); // Road gradient (rise/run)
double coef = Math.sin(Math.atan(grade)); // Grade component factor
double powerGravity = -mKg * Constants.G * location.getSpeed() * coef;
Parameters:
mKg: Total system mass (kg)grade: Road gradient (rise/run ratio)coef: sin(atan(grade)) - converts grade to force component parallel to slopeConstants.G: Gravitational acceleration (9.8 m/s²)Verification: ✅ Formula correctly implements standard gravitational physics for inclines
sin(atan(grade)) converts percentage grade to gravitational force componentAerodynamic power represents the largest resistance component at higher cycling speeds, following a cubic relationship with speed:
Formula: -aeroCoef * speed³
Implementation: AeroPowerProvider (io.github.glandais.gpx.virtual.power.aero:29)
if (wind.windSpeed() == 0) {
double speed = location.getSpeed();
p_air = -aeroCoef * speed * speed * speed;
}
Aerodynamic coefficient calculation:
// From AeroProviderConstant
aeroCoef = cyclist.getCd() * cyclist.getA() * course.getRho() / 2
Parameters:
aeroCoef: Combined aerodynamic coefficient (Cd × A × ρ / 2)Cd: Drag coefficient (cyclist-specific, typically 0.8-1.2)A: Frontal area (m², typically 0.3-0.5)ρ: Air density (kg/m³, typically 1.225 at sea level)speed: Cyclist velocity (m/s)Verification: ✅ Standard aerodynamic drag formula P = 0.5 × ρ × Cd × A × v³
Formula: Complex wind-adjusted calculation using relative velocity
Implementation: AeroPowerProvider.computePAirWithWind() (io.github.glandais.gpx.virtual.power.aero:61)
double alpha = windDirectionAsBearing - bearing; // Wind angle relative to travel direction
double l1 = speed + windSpeed * Math.cos(alpha); // Relative velocity component
double l2 = Math.pow(l1, 2); // Squared relative velocity
double l3 = speed * speed + windSpeed * windSpeed + // Total relative velocity squared
2 * speed * windSpeed * Math.cos(alpha);
double l4 = l2 / l3; // Velocity ratio
double mu = 1.2; // Correction factor
double lambda = l4 + mu * (1 - l4); // Combined correction factor
return -aeroCoef * lambda * Math.sqrt(l3) * l1 * speed;
Wind parameters:
alpha: Angle between wind direction and travel bearing (radians)windSpeed: Wind velocity (m/s)windDirection: Wind direction (radians, 0=North, π/2=East)bearing: Cyclist travel direction (radians)mu: Empirical correction factor (1.2)lambda: Combined velocity/correction factorVerification: ✅ Formula validated against Isvan (2011) “Power Management for Lightweight Vehicles”
Academic Source: The wind formula implements Equations 21, 22, 24, and 28 from:
Mathematical Derivation:
l3 = u² + v² + 2uvcos(α) → apparent wind speed squared: w² = u² + v² + 2uvcos(α)l1 = u + vcos(α) → component: u + vcos(α)l4 = l2/l3 = (u + vcos(α))²/(u² + v² + 2uvcos(α))λ = l4 + μ(1-l4) where μ = 1.2 (crosswind to headwind drag ratio for cyclist)P = -aeroCoef × λ × √(w²) × (u + vcos(α)) × uP = -aeroCoef × λ × √(l3) × l1 × speedPhysical meaning:
cos(α) componentλ based on aerodynamic aspect ratioμ = 1.2 represents measured drag ratio: crosswind vs headwind for cyclist shapeThe system uses academically validated default parameters for virtual cyclist simulation:
| Parameter | Value | Unit | Description | Academic Source |
|---|---|---|---|---|
DEFAULT_CRR |
0.004 | - | Rolling resistance coefficient | bicyclerollingresistance.com research |
DEFAULT_INERTIA_FRONT |
0.05 | kg⋅m² | Front wheel rotational inertia | Physics education materials |
DEFAULT_INERTIA_REAR |
0.07 | kg⋅m² | Rear wheel rotational inertia | Physics education materials |
DEFAULT_WHEEL_RADIUS |
0.35 | m | Wheel radius (700c + 25mm tire) | Standard wheel sizing (0.7 m is the diameter) |
DEFAULT_DRIVETRAIN_EFFICIENCY |
0.976 | - | Mechanical efficiency | Typical modern road bike |
DEFAULT_AIR_DENSITY |
1.225 | kg/m³ | Air density at sea level | ISO Standard Atmosphere (15°C, 1 atm) |
| Parameter | Value | Unit | Description | Academic Source |
|---|---|---|---|---|
DEFAULT_CYCLIST_MASS_KG |
80 | kg | Total system mass (cyclist + bike) | Typical recreational cyclist |
DEFAULT_CYCLIST_POWER_W |
280 | W | Sustained power output (FTP) | ~3.5 W/kg intermediate level |
DEFAULT_MAX_BRAKE_G |
0.4 | g | Maximum braking deceleration | Measured riders use 0.41 ± 0.07 g (ceiling 0.56-0.63 g) |
DEFAULT_DRAG_COEFFICIENT |
0.7 | - | Aerodynamic drag coefficient | “Aerodynamic drag in cycling” studies |
DEFAULT_FRONTAL_AREA |
0.5 | m² | Cyclist frontal area | Professional cycling aerodynamics |
DEFAULT_MAX_LEAN_ANGLE_DEG |
35 | ° | Maximum cornering lean angle (µ = tan θ = 0.70) | Brandt ; Zignoli 2020 (µ 0.90 dry / 0.36 wet) |
DEFAULT_MAX_SPEED_KMH |
100 | km/h | Maximum speed capability | Reasonable road cycling limit |
Parameter Validation:
The PowerComputer class implements the fundamental physics equations for virtual cyclist power calculations, handling acceleration, deceleration, and energy conservation.
Formula: P_total = P_cyclist - P_resistances
Implementation: getNewPower() method (line 21)
double pSum = 0;
for (PowerProvider provider : providers.getPowerProviders()) {
double w = provider.getPowerW(course, current);
pSum = pSum + w; // Sums all resistance powers (negative values)
}
Resistances included:
-cos(atan(grade)) * mass * g * speed * crr-speed * (91 + 8.7 * speed) / 1000-sin(atan(grade)) * mass * g * speed-aeroCoef * speed³ (no wind) or complex wind modelFormula: P = 0.5 * M_eq * (v₂² - v₁²) / Δt
Implementation: getTotPower() method (line 75)
return 0.5 * equivalentMass * (s2 * s2 - s1 * s1) / dt;
Physical meaning:
M_eq includes wheel rotational inertia effectsFormula: M_eq = m + I_total / r²
Implementation: getEquivalentMass() method (line 83)
double mKg = course.getCyclist().getMKg(); // Total mass (cyclist + bike)
double inertiaFront = course.getBike().getInertiaFront(); // Front wheel inertia
double inertiaRear = course.getBike().getInertiaRear(); // Rear wheel inertia
double wheelRadius = course.getBike().getWheelRadius(); // Wheel radius
double inertia = inertiaFront + inertiaRear; // Combined wheel inertia
return mKg + (inertia / (wheelRadius * wheelRadius)); // Equivalent mass
Physical meaning:
KE_total = ½mv² + ½Iω²v = ωr, rotational part becomes: ½I(v/r)² = ½(I/r²)v²KE = ½(m + I/r²)v² → equivalent mass = m + I/r²Formula: v_new = √(2ΔtP/(M_eq) + v_old²)
Implementation: getDx() method (line 32)
// From: P = 0.5 * M_eq * (v_new² - v_old²) / Δt
// Solving for v_new: v_new² = 2ΔtP/M_eq + v_old²
double newSpeed = Math.max(
Math.sqrt(dt * pSum / (0.5 * equivalentMass) + currentSpeed * currentSpeed),
Constants.MINIMAL_SPEED); // 2 km/h minimum
return (newSpeed + currentSpeed) * dt / 2; // Distance via trapezoidal integration
Implementation: getDt() method using binary search (line 40)
// Given: distance dx, power P, current speed
// Find: time dt such that getDx(P, M_eq, v_current, dt) = dx
// Uses binary search with tolerance dx/10,000,000
Implementation: computeCyclistPower() method (line 58)
double power = getNewPower(course, p1, false); // Sum all resistances
double tot_power = getTotPower(equivalentMass, s1, s2, dt); // Power from acceleration
double cyclistPower = tot_power - power; // Net cyclist power
cyclistPower = Math.max(0.0, cyclistPower); // No negative power
cyclistPower = cyclistPower / course.getBike().getEfficiency(); // Account for drivetrain losses
Sources:
P_input = P_output + dE/dtKey validation points:
✅ Equivalent mass formula matches rotational dynamics theory
✅ Power-velocity relationship validated by Martin et al. (3% accuracy)
✅ Energy conservation maintained throughout calculations
✅ Numerical methods ensure physical constraints (minimum speed, positive power)
Constants:
MINIMAL_SPEED = 2 km/h (0.556 m/s) - prevents division by zeroDT = 1.0 s - default time step for calculationsSimulates a virtual cyclist on a give path.
Steps :
Uses Mapterhorn terrain-RGB tiles (Terrarium-encoded WebP) to compute elevation by bilinear interpolation across 4 surrounding pixels at zoom 12 (~10 m horizontal resolution).
Tiles are fetched from https://tiles.mapterhorn.com/{z}/{x}/{y}.webp and cached on disk under
<cache>/mapterhorn/{z}/{x}/{y}.webp. The base URL, zoom level, tile size, in-memory LRU
capacity, and User-Agent are all configurable via MapterhornConfig.
Elevation data shown by gpx2web is © Mapterhorn and its upstream sources. Mapterhorn is built from many national elevation datasets, each with its own license (mostly CC BY 4.0). When you display, redistribute, or publish maps or analyses produced with this tool, you must credit Mapterhorn and the underlying data providers as listed in the canonical attribution document.
If you self-host Mapterhorn tiles or point gpx2web at a private mirror via
MapterhornConfig.tileUrlTemplate, you remain responsible for honouring the upstream licenses
of every source dataset you use.
The MaxSpeedComputer calculates safe maximum speeds considering cornering physics and braking constraints through a two-pass algorithm.
Implementation: firstPass() and computeMaxSpeedByIncline() methods
Algorithm:
Vector circleCenter = getCircleCenter(pointBefore, currentPoint, pointAfter);
double radius = Math.hypot(circleCenter.x(), circleCenter.y());
radius = radius + 2; // Add 2m safety margin for trajectory
// https://en.wikipedia.org/wiki/Bicycle_and_motorcycle_dynamics#Leaning
double vmax = Math.sqrt(Constants.G * radius * cyclist.getTanMaxAngle());
p.setSpeedMax(Math.min(cyclist.getMaxSpeedMs(), vmax));
Physics:
v_max = √(g × r × tan(θ_max))θ_max = 35° (maximum lean angle), g = 9.8 m/s²Implementation: secondPass() and computeMaxSpeedByBraking() methods
Algorithm:
double t = (vf - v0) / a; // Time to brake from v0 to vf
double dBrake = v0 * t + (a * t * t) / 2; // Distance needed to brake
if (dBrake > dist) {
// Not enough distance to brake safely
double newMaxSpeedPrevious = Math.sqrt(vf * vf - 2 * a * dist);
pm1.setSpeedMax(newMaxSpeedPrevious);
}
Physics:
v² = u² + 2asa = -0.6g = -5.88 m/s² (from cyclist parameters)Implementation: getCircleCenter() method (lines 106-128)
// Solves system of equations for circle through three points
double G = 2 * (A * (cy - by) - B * (cx - bx));
if (Math.abs(G) < 0.001) return null; // Collinear points
double px = (D * E - B * F) / G;
double py = (A * F - C * E) / G;
Handles edge cases:
Implementation: transform() method (lines 130-136)
double x = (lon / (2 * Math.PI)) * Constants.CIRC * Math.cos(pRef.getLat());
double y = (lat / (2 * Math.PI)) * Constants.CIRC;
Purpose: Converts GPS coordinates (lat/lon) to local Cartesian coordinates (meters) for geometric calculations.
Sources:
Key physics equations:
v = √(g × r × tan(θ)) - maximum speed without exceeding lean angles = (v² - u²) / 2a - minimum distance needed to decelerateSafety margins:
The VirtualizeService orchestrates the complete virtual cyclist simulation, integrating power calculations, speed constraints, and temporal progression to generate a realistic ride profile.
Implementation: virtualizeTrack() method combining all physics components
OptimalSpeeds optimalSpeeds = new OptimalSpeeds(optimalSpeedService, course);
double equivalentMass = powerComputer.getEquivalentMass(course);
Point current = input.get(0).copy();
current.setSpeed(Constants.MINIMAL_SPEED); // Start at 2 km/h
Setup:
MaxSpeedComputer (cornering + braking limits)M_eq = m + I_total/r² (includes wheel inertia)Time-stepping algorithm with adaptive step size:
double pSum = powerComputer.getNewPower(course, current, true);
double dx = powerComputer.getDx(pSum, equivalentMass, currentSpeed, DT);
dx = (v_new + v_old) * dt / 2v_new = √(2ΔtP/M_eq + v_old²)if (index != newIndex) {
// Step crosses GPS waypoint - adjust to exact waypoint
dxToNext = input.get(newIndex).getDist() - current.getDist();
dtToNext = powerComputer.getDt(pSum, equivalentMass, currentSpeed, dxToNext);
current = input.get(newIndex).copy();
} else {
// Regular time step
dxToNext = dx;
dtToNext = DT;
current = Point.interpolate(input.get(index), input.get(index + 1), coef);
}
double speedNew = 2 * (dxToNext / dtToNext) - currentSpeed; // Trapezoidal rule
if (speedNew > current.getSpeedMax()) {
speedNew = current.getSpeedMax(); // Apply speed limits
}
dtToNext = 2 * dxToNext / (currentSpeed + speedNew); // Adjust time
Duration dtToNextDuration = getDuration(dtToNext);
now = now.plus(dtToNextDuration);
current.setInstant(start, now);
for (int i = 0; i < newPoints.size() - 1; i++) {
double cyclistPower = powerComputer.computeCyclistPower(course, equivalentMass,
newPoints.get(i), newPoints.get(i + 1));
newPoints.get(i).setPower(cyclistPower);
}
Cyclist power calculation:
P_cyclist = P_total - P_resistances + dE/dtPrimary integration method: Modified trapezoidal rule
v_new = 2(dx/dt) - v_old (trapezoidal velocity)dx = (v_new + v_old) * dt / 2 (trapezoidal area)dt = 2dx / (v_new + v_old) (ensures consistency)Academic validation:
MaxSpeedComputer enforcedprivate int getIndex(double[] dists, int distsLength, double dist) {
// O(log n) search for GPS point index at given distance
while (left <= right) {
int mid = left + (right - left) / 2;
// Binary search implementation...
}
}
MaxSpeedComputer limitsNumerical integration theory:
Cycling simulation research:
Output: Complete time-series cycling simulation with:
The HRSimulator generates realistic heart rate data using machine learning trained on real cycling data, incorporating temporal dynamics and physiological constraints.
Implementation: Linear regression model using SMILE machine learning library
public void train(final List<GPXPath> samples) {
List<DataPoint> dataPoints = new ArrayList<>();
for (GPXPath sample : samples) {
getDataPoints(dataPoints, sample); // Extract features every 5 seconds
}
this.linearModel = OLS.fit(Formula.lhs("hr"), DataFrame.of(dataPoints));
// Model serialized to resources/hrmodel
}
Training features:
DataPoint features (DataPoint.java:11-22):
// Heart rate features - temporal lookback
new Field("hr", PropertyKeys.heartRate, 0.0), // Current HR
new Field("hr5", PropertyKeys.heartRate, 5.0), // HR 5 seconds ago
new Field("hr10", PropertyKeys.heartRate, 10.0), // HR 10 seconds ago
new Field("hr30", PropertyKeys.heartRate, 30.0), // HR 30 seconds ago
// Power features - moving averages
new Field("p5", PropertyKeys.power, 0.0, 5.0), // 5-second power average
new Field("p10", PropertyKeys.power, 0.0, 10.0), // 10-second power average
new Field("p60", PropertyKeys.power, 0.0, 60.0), // 60-second power average
Feature extraction (Field.java:23-25):
double getValue(GPXPath gpxPath, double t) {
double average = gpxPath.getAverage(t - start, t - end, propertyKey);
return hr ? clamp(average) : average; // HR clamped to 60-220 bpm
}
public void simulateHeartRate(final GPXPath gpxPath) {
for (int i = 0; i < points.size() - 1; i++) {
double t = points.get(i).getElapsedSeconds();
DataPoint dataPoint = getDataPoint(gpxPath, t); // Extract features
double hr = linearModel.predict(dataPoint); // ML prediction
points.get(i).setHeartRate(hr);
}
smoothService.smoothHr(gpxPath); // Post-processing smoothing
}
Academic foundation:
Key research findings:
Why Random Forest would improve results:
Expected improvements:
Exercise physiology sources:
Current implementation: Simple but physiologically grounded approach suitable for basic simulation. Random forest upgrade would provide significantly more realistic and personalized heart rate modeling.
The GPXEnhancer orchestrates the complete virtual cyclist simulation pipeline, transforming raw GPS tracks into realistic cycling experiences with proper data preprocessing and post-processing.
Main method: virtualize(Course course, boolean filter) (lines 67-78)
1. Distance-based Resampling (line 69)
gpxPerDistance.computeOnePointPerDistance(gpxPath, 10.0);
2. Elevation Correction (line 70)
gpxElevationFixer.fixElevation(gpxPath);
3. Speed Constraint Calculation (line 71)
maxSpeedComputer.computeMaxSpeeds(course);
4. Physics-based Simulation (line 72)
virtualizeService.virtualizeTrack(course);
5. Heart Rate Modeling (line 73)
hrSimulator.simulateHeartRate(gpxPath);
6. Temporal Resampling (line 74)
gpxPerSecond.computeOnePointPerSecond(gpxPath);
7. Optional Track Simplification (lines 75-77)
if (filter) {
GPXFilter.filterPointsDouglasPeucker(gpxPath);
}
Academic foundations:
Step sequencing rationale:
Default configuration setup:
Cyclist cyclist = Cyclist.getDefault(); // Standard cyclist parameters
Bike bike = Bike.getDefault(); // Standard bike parameters
Course course = new Course(
gpxPath, instant, cyclist, bike,
new PowerProviderConstant(), // Constant power capability
new WindProviderNone(), // No wind conditions
new AeroProviderConstant() // Constant aerodynamics
);
Multi-track support (lines 39-43):
Computational complexity:
Memory efficiency:
Data validation at each step:
Error handling:
UncheckedIOException (no silent fallback to zero)The GPXEnhancer pipeline represents a comprehensive approach to virtual cyclist simulation, combining established GPS processing techniques with validated cycling physics models to produce realistic and accurate virtual cycling experiences.