vcyclist
1.2.0indexedPhysics-based cycling simulator converting static GPX traces into virtual rides with realistic speeds, times and power estimates; elevation correction via Terrarium DEM, smoothing, resampling and GPX output.
Physics-based cycling simulator converting static GPX traces into virtual rides with realistic speeds, times and power estimates; elevation correction via Terrarium DEM, smoothing, resampling and GPX output.
Kotlin Multiplatform physics-based cycling simulator: it turns a static GPS trace into a
virtualized ride with realistic speeds, times and power estimates. Elevation data comes from
Terrarium-encoded DEM tiles — mapterhorn by default — fetched and decoded by the :elevation
module.
┌──────────────┐
sample.gpx ────▶│ GpxParser │
└──────┬───────┘
▼
┌─────────────────────────────────────────┐
│ Enhancer (orchestrator) │
│ ├─ PointPerDistance(-1, 30) │
│ ├─ fixElevation (Terrarium tiles)* │
│ ├─ (, ) │
│ ├─ smoothElevation ( m kernel) │
│ ├─ PathCurvature (turn radius) │
│ │ or RacingLine (optimal line)* │
│ ├─ MaxSpeedComputer (cornering+braking)│
│ ├─ VirtualizeService ( Hz physics) │
│ ├─ PointPerSecond (uniform sampling) │
│ ├─ W′bal (Critical Power annotation) │
│ ├─ PathSimplifier (Douglas-Peucker D) │
│ └─ ElevationGain (D+/D− dead band) │
└──────────────────┬──────────────────────┘
▼
┌──────────────┐
│ GpxWriter │────▶ output
└──────────────┘
(*) optional — needs an ElevationProvider
In: GPX tracks, routes (<rte>), segments and waypoints.
Physics: elevation correction from DEM tiles, curvature estimation or an optimal racing line, cornering and braking sharing one friction-ellipse budget, a configurable rider and bike, and a 1 Hz time-stepping simulation that produces speed, time and power at every point.
Out: GPX, Garmin FIT courses, CSV, column-oriented JSON, static PNG maps — plus climb detection, a racing-line report and worst-case wind analysis.
Four doors reach the same engine, and a capability is available from all of them unless noted:
Static maps are JVM-only by construction — :map draws on java.awt.
docs/ledgers/surface-coverage.md tracks this matrix as
capabilities land, so that a feature cannot reach one door and quietly miss the others.
npm install @glandais/vcyclist-engine # physics, GPX, FIT, CSV/JSON, climbs, racing line
npm install @glandais/vcyclist-elevation # DEM lookups on their own
The engine bundle already carries the elevation façade; install the second package only if you want DEM lookups without the physics.
dependencies {
implementation("io.github.glandais:vcyclist-engine:4.2.1") // pulls -jvm / -js per target
implementation("io.github.glandais:vcyclist-elevation:4.2.1")
}
<dependency>
<groupId>io.github.glandais</groupId>
<artifactId>vcyclist-engine-jvm</artifactId>
<version>4.2.1</version>
</dependency>
The badges above are the source of truth for the current version. KMP consumers get the
platform-specific variant automatically; from plain Maven, name the -jvm artifact yourself.
vcyclist-gpx (the Path model + GPX I/O) comes in transitively via vcyclist-engine, and can be
depended on alone if you only need parsing and resampling. vcyclist-fit and vcyclist-map are
published separately.
.wasmThe CLI is an application, not a library, so it is not on Maven Central: download
vcyclist-cli-<version>-all.jar from a GitHub release.
The WASI module is built from source (below) rather than published to a registry.
Requirements: Java 21+ for the JVM and CLI; Node ≥ 18 (22+ recommended) or Bun for JavaScript;
a WASI runtime with the function-references, gc and exceptions proposals — wasmtime 46+ is
known good.
java -jar vcyclist-cli-*-all.jar enhance route.gpx --gpx out.gpx --csv out.csv
enhance runs the physics pipeline; export produces maps, FIT, CSV and JSON from a file you
already have. Elevation correction is off unless you pass --fix-elevation, so nothing touches the
network by default.
Full option reference, the rider models and what each is measured to be worth, and exit codes:
cli/README.md.
import io.github.glandais.engine.Enhancer
import io.github.glandais.engine.gpx.GpxParser
import io.github.glandais.engine.gpx.GpxWriter
import io.github.glandais.engine.gpx.firstTrackAsPath
import io.github.glandais.engine.gpx.toGpxDocument
suspend fun virtualize(xml: ): String {
path = GpxParser.parse(xml).firstTrackAsPath()
= Enhancer.enhanceCourseDefault(path)
GpxWriter.write(.toGpxDocument(trackName = ))
}
Pass an ElevationProvider as the second argument to correct elevations from DEM tiles, and an
EnhanceOptions as the third to configure the pipeline. For a configured rider, build a
CoursePhysics(Course(path, cyclist, bike), …) and call Enhancer.enhanceCourse.
Path input = GpxToPathJvm.firstTrackAsPath(GpxParserJvm.parse(xml));
Path enhanced = EnhancerJvm.enhanceCourseDefaultBlocking(input);
String out = GpxWriterJvm.write(enhanced);
Every entry point has a …Jvm twin that restores Kotlin's default arguments, and every suspend
function has both a …Blocking and a …Async (CompletableFuture) bridge.
docs/guides/using-from-java.md has the calling rules — some
of them matter, on a UI thread being the obvious one.
Kotlin/JS emits a UMD bundle that preserves the package namespace, so there is exactly one top-level export. Named imports do not work — unwrap it once:
docs/guides/using-from-javascript.md covers the whole
façade — enhanceWithCourse and its five DTOs, FIT and CSV/JSON export, climbs, the racing-line
report, the standalone elevation API, and the Node/Bun specifics.
:engine links a standalone WASI module, so the whole pipeline runs inside wasmtime, WasmEdge,
wazero, or an embedding in Go, Rust, Python or the JVM.
./gradlew :engine:wasmModule # -> engine/build/wasm/vcyclist-engine.wasm + .sha256
The host implements three imports — read_input, write_output and fetch_tile (which may simply
answer "no tile") — and everything else is numeric exports over integer handles:
staged["bytes"] = open("ride.gpx", "rb").read()
handle = exports["vcParseGpx"](store, len(staged["bytes"]))
staged["bytes"] = b'{"computeOnePointPerSecond": true}'
out = exports["vcEnhance"](store, handle, len(staged["bytes"]))
print(exports["vcPathDurationMs"](store, out) / 1000, "s")
This is not a reduced surface: vcEnhanceWithCourse, vcPathToFit, vcPathToCsv,
vcDetectClimbsJson and vcAnalyzeRacingLineJson are all there, and §10 of the guide is a
function-by-function parity table against the JavaScript façade.
is the full contract;
is a working host that CI runs on every pull request.
https://glandais.github.io/vcyclist — no install, runs the real engine in your browser.
A Vue 3 + Leaflet + Chart.js app with two routes, both on the same Kotlin/JS bundle:
#/ — GPX analysis: upload a route, run the physics pipeline, inspect every field on a
synchronized chart and map, with climb detection and the racing line, then download the result
as GPX or as a Garmin FIT course.#/elevation — elevation explorer: query DEM tiles at a point or along a path, with
smoothing, Douglas-Peucker simplification and hillshade/slope relief.cd demo && npm run dev # http://localhost:3000, against a locally built engine
See demo/README.md for the architecture and the static-site build.
docs/README.md is the index and says which documents are current and which are
frozen history. The short version:
Open PRs against develop — the default and only long-lived branch — using
Conventional Commits. Build commands, module layout,
testing conventions and the release flow are in CONTRIBUTING.md.
| Capability | CLI | Kotlin / Java | JavaScript / TS | WASI |
|---|
| Run the pipeline | enhance | Enhancer.enhanceCourseDefault | enhance | vcEnhance |
| Configure rider, bike, wind, power | --cyclist-*, --bike-*, --wind-* | CoursePhysics(Course(…)) | enhanceWithCourse | vcEnhanceWithCourse |
Power models constant · durability · critical-power · from_data | --cyclist-model | CyclistPowerSpec | power.type | power.type |
| Terrain pacing, power slew limit | --cyclist-pacing, --cyclist-slew | provider decorators | power.pacing, power.maxSlewWPerS | idem |
| Road condition, dry or wet | --road-condition | Cyclist.withRoadCondition | cyclist.roadCondition | idem |
| Pedal-strike clearance | --bike-max-pedal-angle | Bike.maxPedalingLeanAngleDeg | bike.maxPedalingLeanAngleDeg | idem |
| Racing line + corridor mode | --racing-line, --corridor | RacingLineOptions | racingLineEnabled | idem |
| DEM elevation correction | --fix-elevation | ElevationProvider | fixElevation: true | host serves tiles |
| Climb detection | — | ClimbDetector.detect | detectClimbs | vcDetectClimbsJson |
| Racing-line report | --racing-line-report | RacingLine.analyze | analyzeRacingLine | vcAnalyzeRacingLineJson |
| Write GPX / CSV / JSON | --gpx --csv --json | GpxWriter, CsvWriter, JsonWriter | writeGpx, pathToCsv, pathToJson | vcWriteGpx, vcPathToCsv/Json |
| Write FIT course | --fit | Path.toFitBytes | pathToFit, pathsToFit | vcPathToFit, vcPathsToFit |
| Static map PNG | export --map | MapFactoriesJvm | — | — |
…Blockingimport * as engineRaw from '@glandais/vcyclist-engine';
const engine = engineRaw.io.github.glandais.engine;
const { parseGpx, enhance, writeGpx, pathSize, pathTotalDistance } = engine;
const path = parseGpx(gpxXml);
const out = await enhance(path, null); // physics only; { fixElevation: true } for DEM
console.log(pathSize(out), pathTotalDistance(out), 'm');
// `<power>` carries the SOURCE file's power by default — writing simulated data into a format the
// ecosystem reads as a recording is the caller's call. Ask for the simulation explicitly:
const xml = writeGpx(out, true, 'computed-or-input', 'my route');
docs/guides/ — how to use and extend the project: Java, JavaScript, the
WASI ABI, the racing line, the release flowdocs/ledgers/ — living state: research improvements, build warnings,
surface coveragedocs/research/ — the solo-rider simulation research reportcli/,
elevation/, map/, demo/Surfaced from shared tags and platforms — no rankings paid for.