Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,15 @@ Not a container bug: TPV mis-renders custom routes where two legs run within
script that shifts a course a few metres to fix it
(`tools/gpx-separate-legs.py`), and an AI prompt that does the same.

### TrainingPeaks rejects a route for a tight curve radius

Not a container bug either: TrainingPeaks refuses any upload whose points
describe a curve radius under 6 m, which junctions drawn with two or three
closely spaced points routinely do — and shifting a course sideways tightens
them further. See
[docs/custom-route-tight-curves.md](docs/custom-route-tight-curves.md) and
`tools/gpx-widen-curves.py`. Run it **after** `gpx-separate-legs.py`.

### TrainingPeaks Hub app cannot find TPV

The [Hub companion app](https://help.trainingpeaks.com/hc/en-us/articles/34618989898765-TrainingPeaks-Virtual-Hub-App)
Expand Down Expand Up @@ -213,6 +222,7 @@ it to the trainer. Set `QZ_HR_BELT` and pair `Wahoo HRM` in TPV explicitly.
| `run-qz-debian.sh` | Runs the QZ-only image |
| `verify.sh` | End-to-end proof the bridge works |
| `tools/gpx-separate-legs.py` | Finds and fixes overlapping legs in custom GPX routes |
| `tools/gpx-widen-curves.py` | Widens curves TrainingPeaks rejects as too tight |
| `docs/` | Longer write-ups of known issues, and decision records |

## Why each podman flag is load-bearing
Expand Down
125 changes: 125 additions & 0 deletions docs/adr/0006-widen-tight-curves-with-circular-fillets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# 0006. Widen over-tight GPX curves with circular fillets

- **Status:** Accepted
- **Date:** 2026-09-17
- **Deciders:** Ben Atkinson
- **Feature / area:** custom-route-preparation
- **Builds on:** ADR-0002
- **Supersedes / Superseded by:** none

## What problem were we trying to solve?

Uploading the Malaga course fixed by ADR-0002 to TrainingPeaks was **rejected**:

> The curve radius of points near ~60135.39 meters into the route (lat: 36.735,
> lon: -4.108, elevation: 3.500) is too tight. The curve should be widened to
> ensure no points create a curve radius less than 6 meters.

This is a different failure from ADR-0002. That one was TPV mis-rendering an
accepted route; this one stops the route being accepted at all, so it blocks the
ride earlier.

The named coordinate is point 1528, a ~96 degree junction turn described by two
points 4.5 m and 5.1 m apart. The circle through it and its neighbours has a
radius of 4.69 m. In the file *before* ADR-0002's offset the same corner measured
7.41 m, so the offset pushed it under the limit: shifting sideways moves the
inside of a bend towards its own centre of curvature. Four more corners were at
or near the threshold, two of them at 6.14 m and 6.35 m, which would have failed
the next upload attempt.

Constraints:
- The course has to stay a faithful copy of the race: same distance, same
elevation profile, same roads.
- It has to compose with the sideways offset, not undo it.
- Other riders should be able to apply it, container or no container.

## What did we try?

### Attempt 1 — blame the offset and reduce it ❌ rejected

The offset made this corner worse, so a smaller one is tempting. But the corner
was already at 7.41 m before any offset, and a smaller offset reopens the
overlap bug ADR-0002 exists to fix. The two constraints pull in opposite
directions, so one offset value can't satisfy both.

### Attempt 2 — smooth the whole course ❌ rejected

A Laplacian or Chaikin pass over every point raises the tightest radii. It also
moves the entire course, blunts every real corner, and changes the distance —
against the "faithful copy" constraint. It fixes a five-point problem by
rewriting 2108 points.

### Attempt 3 — push the tight vertex outwards ❌ rejected

Moving just the flagged point away from the turn's centre raises its
circumradius. But for a triple the circumradius is minimised when the apex
height equals half the neighbour spacing, so it can only be raised so far before
the neighbouring points become the tightest ones instead. It moves the problem
along rather than solving it.

### Attempt 4 — circular fillet at each tight corner ✅ chosen

Standard road-design geometry. Take the straight feeding the corner and the one
leaving it, intersect them for the apex, and lay an arc of radius R tangent to
both, tangent length R·tan(Δ/2) for a deflection Δ. Sample it every 10 degrees:
every consecutive triple then sits on a circle of exactly R, which is precisely
the quantity TrainingPeaks measures. On Malaga, R = 9 m:

| Distance into route | Radius before | After |
|---|---|---|
| 45 035 m | 3.25 m | ≥ 8.25 m |
| 45 037 m | 7.71 m | ≥ 8.25 m |
| 57 320 m | 6.35 m | ≥ 8.25 m |
| 57 322 m | 6.14 m | ≥ 8.25 m |
| 60 077 m | 4.69 m | ≥ 8.25 m |

Length changed by −21 m (−0.023%), no point moved more than 3.44 m, the leg
separation from ADR-0002 was untouched (still no stretch within 12 m of
another), and the one self-crossing at km 74.3 was already there.

## What did we land on, and why?

`tools/gpx-widen-curves.py`:
- **Checks** the circumradius at every point, the same measure TrainingPeaks
applies, and reports each offender by distance into the route so its output
lines up with the upload error message.
- **Fixes** by replacing each corner — grown outwards to cover the whole bend,
not just the flagged point — with a tangent arc, then sweeping again until
nothing is tight, because widening one corner can leave its new ends curving.
- **Backs off** to a smaller radius, and to wider straights to hang the arc
between, rather than leaving a corner untouched.
- **Interpolates elevation** across a replaced corner, since unlike the offset
tool this one adds and removes points.
- **Names what it can't fix.** A hairpin between close legs has no room for an
arc of any useful radius; the script says so and points at
`gpx-separate-legs.py --offset`, which opens the hairpin by separating the
legs further.
- **Standard library only**, like its sibling.

The two tools have a required order — **separate legs, then widen curves** —
because offsetting tightens corners. `docs/custom-route-tight-curves.md` says so,
and carries an AI prompt describing the same algorithm for people who'd rather
not run the script.

It won because it changes only the five corners that fail the check, by a few
metres each, and it targets the exact quantity TrainingPeaks measures instead of
approximating it. We'd revisit it if TrainingPeaks changes the 6 m threshold, or
if its own smoothing re-tightens the widened corners on upload.

## What does this cost us?

- **Unproven against the uploader:** the widened file clears the check as we
compute it, but has not been through TrainingPeaks' own validator yet.
Confirm on the next upload.
- **Distances don't match exactly.** TrainingPeaks reported the failure at
60 135 m where we measure that corner at 60 077 m — probably a 3D distance
against our 2D one. The coordinates match, so the corner is identified by
position, not distance.
- **Corners get cut.** An arc sits inside the original corner, by up to 3.44 m
on Malaga. On a real road that is within the carriageway; on a hairpin taken
wide it would shorten the course slightly.
- **Points are added and removed**, so timestamps and extensions inside a
replaced corner are lost. Race course files don't normally carry them.
- **Hairpins need the other tool first**, and a course whose legs can't be
separated (same-direction laps, per ADR-0002) may have a hairpin that neither
tool can fix.
4 changes: 4 additions & 0 deletions docs/custom-route-overlap.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,7 @@ account:
- Offsetting to the inside of a very tight hairpin can fold the course. The
script's self-crossing report catches this; use a smaller `--offset` or the
other `--side` if it appears.
- Offsetting **tightens corners** on the side it shifts towards, and
TrainingPeaks rejects an upload whose curve radius drops under 6 m. Run
`tools/gpx-widen-curves.py` on the offset file afterwards — see
[custom-route-tight-curves.md](custom-route-tight-curves.md).
140 changes: 140 additions & 0 deletions docs/custom-route-tight-curves.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Custom GPX routes: TrainingPeaks rejects an upload for a tight curve radius

This is a **TrainingPeaks upload check**, not a container bug or a TPV rendering
fault. It stops the route reaching your Routes Library at all, so it blocks the
ride before TPV is involved.

## Symptom

Uploading a GPX route to TrainingPeaks fails with a message naming a distance
and a coordinate:

> The curve radius of points near ~60135.39 meters into the route (lat: 36.735,
> lon: -4.108, elevation: 3.500) is too tight. The curve should be widened to
> ensure no points create a curve radius less than 6 meters.

Fix that corner and the upload usually fails again a few metres later, at the
next tightest one.

## Cause

TrainingPeaks measures the radius of the circle through each point and its two
neighbours, and rejects anything under **6 m**. A junction or roundabout that the
source data describes with two or three points a few metres apart turns 90
degrees or more in that short distance, so the circle through them is tiny — even
though the real road is perfectly rideable.

On the IRONMAN 70.3 Malaga 2026 bike course the raw file had corners at 7.4 m
and 2.7 m, close enough to the limit that they were a problem waiting to happen.

**A sideways offset makes this worse.** Shifting a course to separate its legs
(see [custom-route-overlap.md](custom-route-overlap.md)) moves the inside of a
corner towards its own centre of curvature, tightening it. On Malaga, a 6 m
offset took one corner from 7.41 m to 4.69 m — over the line TrainingPeaks
accepts. Expect to run both fixes, **separate the legs first, then widen the
curves**, because widening is the one that has to have the last word.

## Fix: replace each tight corner with a wider arc

Swap the corner for a circular arc of a safe radius, tangent to the straights
either side, with enough points along it that every consecutive triple sits on
that circle. The route keeps its shape; only the corner itself moves, by a few
metres.

### With the script in this repo

`tools/gpx-widen-curves.py` needs only Python 3.10+ and no other packages.

```bash
# report: every corner tighter than 8 m, and where it is
python3 tools/gpx-widen-curves.py course.gpx

# fix: widen them to 9 m arcs, write a new file
python3 tools/gpx-widen-curves.py course.gpx -o course_TPV.gpx

# a bigger margin, if TrainingPeaks still complains
python3 tools/gpx-widen-curves.py course.gpx -o course_TPV.gpx --radius 12 --minimum 10
```

The defaults widen anything under 8 m into a 9 m arc, keeping clear of the 6 m
limit without rounding off corners the game renders fine. It reports the tightest
radius before and after, the distance into the route of each offending corner (so
you can match it against TrainingPeaks' message), the change in length, and exits
`1` if anything is still tight.

On Malaga, after the 6 m leg offset:

| Distance into route | Radius before | After |
|---|---|---|
| 45 035 m | 3.25 m | ≥ 8.25 m |
| 45 037 m | 7.71 m | ≥ 8.25 m |
| 57 320 m | 6.35 m | ≥ 8.25 m |
| 57 322 m | 6.14 m | ≥ 8.25 m |
| 60 077 m | 4.69 m | ≥ 8.25 m |

Length changed by −21 m (−0.023%) over 89.85 km, no point moved more than
3.44 m, and the leg separation from the earlier offset was untouched (still no
stretch within 12 m of another).

### Hairpins it can't fix

A U-turn at the end of an out-and-back is not a corner but two legs joined by a
semicircle, and its radius can only be half the gap between those legs. When the
legs are 10 m apart, no 8 m arc exists. The script says so and names the turn:

```
! no arc of 8 m or wider fits the hairpin where point 1046 turns 161 deg: its
legs are too close together, so separate them further with
gpx-separate-legs.py --offset before widening
```

Run `gpx-separate-legs.py` with a larger `--offset` and the hairpin opens up
enough for an arc to fit. This is the other reason to separate legs first.

### With an AI assistant

Paste this into an AI assistant that can run code, and attach your GPX file:

````text
TrainingPeaks rejects my GPX route: "The curve radius of points near ~X meters
into the route is too tight... no points create a curve radius less than 6
meters." Please widen every over-tight corner, changing as little else as
possible.

Do this with code, not by hand:

1. Parse every <trkpt>/<rtept> in order, keeping its <ele>. Project to local
metres (equirectangular around the mean latitude is fine).
2. For each point, compute the radius of the circle through it and its two
neighbours (circumradius = abc / 4 * area; infinite if collinear). List every
point under 8 m with its distance into the route and its coordinates.
3. Group each tight point with its neighbours that are also curving hard (under
40 m), so a bend described by several points is treated as one corner.
4. Replace each group with a circular fillet: take the straight segment feeding
the corner and the one leaving it, intersect them to find the apex, and lay a
9 m arc tangent to both, sampled every 10 degrees. Drop the original points
inside the fillet. If the tangent points don't fit on those straights, hang
the arc between the next segments out, then try smaller radii down to 8 m.
5. Interpolate <ele> across each replaced corner from its two ends. Leave every
other point, and the rest of the file, exactly as it was.
6. Repeat steps 2-5 until no point is under 8 m, then verify and report:
- tightest radius before and after (after must be over 6 m, ideally over 8 m)
- total length before and after; the change should be well under 0.1%
- the largest distance any point moved; it should be a few metres at most
- any corner you could not widen, and why
7. Give me the fixed file named <original name>_TPV.gpx.
````

## Caveats

- **Run it after the leg offset**, not before. Offsetting tightens corners, so
widening has to be the last step.
- **The corner moves.** The arc cuts inside the original corner by a few metres.
That is what widening means, and TrainingPeaks smooths and snaps uploads to
known roads anyway.
- **Elevation across a widened corner is interpolated** from its two ends, so a
corner that straddles a sharp change in gradient loses a little detail over
those few metres.
- **Points are added and removed**, unlike `gpx-separate-legs.py`, which only
edits coordinates. Timestamps and extensions inside a replaced corner do not
survive; course files don't normally carry them.
Loading
Loading