Skip to content

Widen curves TrainingPeaks rejects as too tight - #14

Merged
BenA-SA merged 5 commits into
masterfrom
feat/widen-tight-curves
Sep 20, 2026
Merged

BenA-SA merged 5 commits into
masterfrom
feat/widen-tight-curves

Conversation

@BenA-SA

@BenA-SA BenA-SA commented Sep 17, 2026

Copy link
Copy Markdown
Owner

TrainingPeaks rejected the Malaga course that ADR-0002's leg offset produced:

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.

That point is a ~96° junction turn described by two points 4.5 m and 5.1 m apart. The offset caused it — the same corner measured 7.41 m before the shift, because moving a course sideways pushes the inside of a bend towards its own centre of curvature. Four more corners were at or near the limit, two of them at 6.14 m and 6.35 m, which would have failed the next upload attempt.

What this adds

tools/gpx-widen-curves.py replaces each over-tight corner with a circular arc tangent to the straights either side, sampled every 10° so that every consecutive triple of points sits on a circle of exactly that radius — the quantity TrainingPeaks measures. It sweeps repeatedly, because widening one corner can leave its new ends curving, and backs off to a smaller radius or wider straights rather than leaving a corner untouched.

Results on the offset Malaga course:

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 −21 m (−0.023%) over 89.85 km
  • no point moved more than 3.44 m
  • leg separation from ADR-0002 untouched: still no stretch within 12 m of another
  • the one self-crossing at km 74.3 pre-existed
  • also tested against both Warsaw course files

The two tools now have a required order — separate legs, then widen curves — because offsetting tightens corners. A hairpin between close legs has no room for an arc of any useful radius; the script names it and points back at gpx-separate-legs.py --offset, which opens the hairpin up.

Also here

  • docs/custom-route-tight-curves.md — the upload rejection, the fix, the ordering, and an AI prompt describing the same algorithm
  • docs/adr/0006-widen-tight-curves-with-circular-fillets.md — builds on ADR-0002, including the three approaches rejected first
  • README section + Files row, and a cross-link from custom-route-overlap.md

Not yet proven

The widened file clears the check as we compute it, but hasn't been through TrainingPeaks' own validator. Confirm on the next upload. Note 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 exactly.

@BenA-SA

BenA-SA commented Sep 20, 2026

Copy link
Copy Markdown
Owner Author

@sentry review

Comment thread tools/gpx-widen-curves.py Outdated
Comment thread tools/gpx-widen-curves.py
Comment thread tools/gpx-widen-curves.py Outdated
Comment thread tools/gpx-widen-curves.py
TrainingPeaks refuses any upload whose points describe a curve radius
under 6 m. The Malaga course fixed by ADR-0002 was rejected at a 96
degree junction turn drawn with two points ~5 m apart, radius 4.69 m.
The sideways offset caused it: the same corner measured 7.41 m before,
and shifting sideways moves the inside of a bend towards its own centre
of curvature. Four more corners sat at or near the threshold.

Replace each tight corner with a circular arc tangent to the straights
either side, sampled every 10 degrees so every consecutive triple sits
on that circle - the quantity TrainingPeaks measures. On Malaga, five
corners widened to >= 8.25 m for -21 m of length (-0.023%), no point
moved more than 3.44 m, and the leg separation from ADR-0002 held.

The two tools now have a required order, separate legs then widen
curves, since offsetting tightens corners. A hairpin between close legs
has no room for an arc; the script names it and points back at
gpx-separate-legs.py --offset.
Render with the source point tag, not a hardcoded <trkpt>. The parser
accepts <rtept> too, but _render partitioned on '<trkpt', so an rtept
route -- what TrainingPeaks exports for a planned route -- fell through
with head=the whole document: the original points were kept and the
widened ones appended after </gpx>. Output was invalid XML with every
point duplicated (+169% length on a test route).

Splice each arc against the points it was actually hung between.
Fillet.arc() searches successively wider tangent pairs (back 1..3) but
returned only the sampled points, so _apply_groups assumed back=1 and
kept points the arc had already passed. On a corner with a starved
run-in that doubles the path back on itself -- an exact 180 degree
reversal, reproduced on four starved-tangent courses. arc() now returns
an Arc carrying its before/after indices. Behaviour at back=1 is
unchanged (before == start-1, after+1 == end+2).
Both were auto-marked 'Resolved in 69ab2ed' because that commit touched
the file, not because it addressed them. Neither was fixed; both
reproduce.

Collinear course crashes the report. Every radius is inf on a straight
line, so 'min(r for r in radii if r != inf)' gets an empty sequence and
raises ValueError. A 6-point straight course dies on it. Report n/a
instead.

A point with no elevation was written as a self-closing tag, which this
tool's own parser cannot see -- its regex requires a closing tag. The
effect was total: on a GPX carrying no <ele>, the widen succeeded and
then re-parsing its own output raised 'no <trkpt>/<rtept> points found',
so nothing was written at all. Emit '<tag ...></tag>' instead; an
18-point no-elevation course now round-trips to 25 points, valid XML.
@BenA-SA
BenA-SA force-pushed the feat/widen-tight-curves branch from f0e992b to 83116cd Compare September 20, 2026 08:37
Comment thread tools/gpx-widen-curves.py
Comment thread tools/gpx-widen-curves.py
Seer flagged that a tight corner at index 1 (or the last) cannot be
widened, because _tangent_candidates needs a straight either side and
there is none at a route boundary. That limitation is real and is not
worth engineering around: the suggested fix invents context points, i.e.
route geometry the rider never rode.

The reportable defect sat next to it. main() printed 'widened N
corner(s)' from the count of corners FOUND tight, not the count
actually fixed, so a boundary corner produced:

    ! no arc of 8 m or wider fits the corner where point 1 turns 90 deg
    widened 1 corner(s) to 9 m      <- untrue
    points under 8 m: 1

and wrote a file TPV still rejects, while claiming success. Report
'widened X of Y' from the failures instead, and say plainly when a
corner is unfixable because it sits at the boundary rather than blaming
its geometry.
Comment thread tools/gpx-widen-curves.py Outdated
Seer, correctly, on my own previous fix: 'widened X of Y' took Y from
len(tight) -- individual tight POINTS -- and X by subtracting len(notes),
which counts failed corner GROUPS. Mixing the two units overstates the
work whenever one group holds several tight points.

Reproduced with a boundary corner spanning two tight points: it printed
'widened 1 of 2 corner(s)' while widening none, with 'points under 8 m: 2'
unchanged directly beneath it.

widen() now returns the number of arcs it actually applied, summed per
pass as groups-minus-failures, and main() prints that. A two-tight-point
corner that succeeds now reports 'widened 1 corner', not 2.
@BenA-SA
BenA-SA merged commit 590aebf into master Sep 20, 2026
4 checks passed
@BenA-SA
BenA-SA deleted the feat/widen-tight-curves branch September 20, 2026 09:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant