From 3dbe0f5db66a103817797be6aa74d07e754cf591 Mon Sep 17 00:00:00 2001 From: Ben Atkinson Date: Thu, 17 Sep 2026 22:57:39 +0100 Subject: [PATCH 1/5] Widen curves TrainingPeaks rejects as too tight 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. --- README.md | 10 + ...iden-tight-curves-with-circular-fillets.md | 125 ++++++ docs/custom-route-overlap.md | 4 + docs/custom-route-tight-curves.md | 140 ++++++ tools/gpx-widen-curves.py | 422 ++++++++++++++++++ 5 files changed, 701 insertions(+) create mode 100644 docs/adr/0006-widen-tight-curves-with-circular-fillets.md create mode 100644 docs/custom-route-tight-curves.md create mode 100755 tools/gpx-widen-curves.py diff --git a/README.md b/README.md index fe2660e..d305de7 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 diff --git a/docs/adr/0006-widen-tight-curves-with-circular-fillets.md b/docs/adr/0006-widen-tight-curves-with-circular-fillets.md new file mode 100644 index 0000000..c9b6849 --- /dev/null +++ b/docs/adr/0006-widen-tight-curves-with-circular-fillets.md @@ -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. diff --git a/docs/custom-route-overlap.md b/docs/custom-route-overlap.md index 9cb2707..e9746e0 100644 --- a/docs/custom-route-overlap.md +++ b/docs/custom-route-overlap.md @@ -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). diff --git a/docs/custom-route-tight-curves.md b/docs/custom-route-tight-curves.md new file mode 100644 index 0000000..2e5f287 --- /dev/null +++ b/docs/custom-route-tight-curves.md @@ -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 / in order, keeping its . 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 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 _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. diff --git a/tools/gpx-widen-curves.py b/tools/gpx-widen-curves.py new file mode 100755 index 0000000..3bfeead --- /dev/null +++ b/tools/gpx-widen-curves.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +"""Widen over-tight corners in a GPX course so TrainingPeaks Virtual will accept it. + +TPV rejects an upload whose points describe a curve radius below 6 m, naming the +distance into the route and the offending coordinate. The cause is usually a +junction or roundabout that the source data describes with two or three closely +spaced points, turning 90 degrees or more in a few metres. + +The fix replaces each tight corner with a circular fillet of the target radius, +tangent to the straight segments either side, sampled densely enough that every +consecutive triple of points sits on that circle. Points outside the corners are +left exactly where they were; elevations across a replaced corner are +interpolated from its two ends. + +Usage:: + + tools/gpx-widen-curves.py course.gpx # report only + tools/gpx-widen-curves.py course.gpx -o fixed.gpx # widen to 9 m + tools/gpx-widen-curves.py course.gpx -o fixed.gpx --radius 12 +""" + +import argparse +import math +import re +import sys +from dataclasses import dataclass + +TRKPT = re.compile( + r'<(?:trkpt|rtept)\b[^>]*\blat="([-+\d.eE]+)"[^>]*\blon="([-+\d.eE]+)"[^>]*>' + r"(?:\s*([-+\d.eE]+))?.*?", + re.DOTALL, +) +BOUNDS_TAG = re.compile(r"]*/>") + +TPV_MIN_RADIUS_M = 6.0 +CORNER_CONTEXT_RADIUS_M = 40.0 +ARC_STEP_DEG = 10.0 +MAX_PASSES = 6 +RADIUS_BACKOFF_M = 0.5 +MIN_DEFLECTION_DEG = 5.0 +HAIRPIN_DEG = 150.0 + + +@dataclass +class Point: + """One course point: position in local metres, with its elevation carried along.""" + + x: float + y: float + ele: float | None + + +class Course: + """A GPX course projected onto a local flat plane in metres.""" + + def __init__(self, text: str): + self.text = text + raw = TRKPT.findall(text) + if len(raw) < 3: + raise SystemExit("no / points found") + latlon = [(float(lat), float(lon)) for lat, lon, _ in raw] + self.origin = latlon[0] + mean_lat = sum(lat for lat, _ in latlon) / len(latlon) + self.m_per_deg_lon = 111_320 * math.cos(math.radians(mean_lat)) + self.m_per_deg_lat = 110_540 + self.points = [ + Point(*self._to_xy(lat, lon), float(ele) if ele else None) + for (lat, lon), (_, _, ele) in zip(latlon, raw) + ] + + def _to_xy(self, lat: float, lon: float) -> tuple[float, float]: + return ( + (lon - self.origin[1]) * self.m_per_deg_lon, + (lat - self.origin[0]) * self.m_per_deg_lat, + ) + + def to_latlon(self, point: Point) -> tuple[float, float]: + return ( + self.origin[0] + point.y / self.m_per_deg_lat, + self.origin[1] + point.x / self.m_per_deg_lon, + ) + + @property + def length_m(self) -> float: + return sum(_distance(a, b) for a, b in zip(self.points, self.points[1:])) + + def radii(self) -> list[float]: + """Circumradius of each point and its two neighbours, as TPV measures curvature.""" + inf = float("inf") + return ( + [inf] + + [_circumradius(*self.points[i - 1 : i + 2]) for i in range(1, len(self.points) - 1)] + + [inf] + ) + + def tight_indices(self, minimum_m: float) -> list[int]: + return [i for i, r in enumerate(self.radii()) if r < minimum_m] + + def distance_to(self, index: int) -> float: + return sum( + _distance(a, b) for a, b in zip(self.points[: index + 1], self.points[1 : index + 1]) + ) + + +def _distance(a: Point, b: Point) -> float: + return math.hypot(b.x - a.x, b.y - a.y) + + +def _circumradius(a: Point, b: Point, c: Point) -> float: + """Radius of the circle through three points; infinite when they are collinear.""" + sides = _distance(a, b) * _distance(b, c) * _distance(a, c) + twice_area = abs((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) + if twice_area < 1e-9: + return float("inf") + return sides / (2 * twice_area) + + +def _unit(from_point: Point, to_point: Point) -> tuple[float, float]: + dx, dy = to_point.x - from_point.x, to_point.y - from_point.y + norm = math.hypot(dx, dy) or 1.0 + return (dx / norm, dy / norm) + + +def _corner_groups(radii: list[float], minimum_m: float) -> list[tuple[int, int]]: + """Index ranges to replace: each tight point, widened to the whole bend it sits in. + + A bend is described by more points than the one TPV flags, so the group grows + outwards over neighbours that are still curving hard. Overlapping groups merge. + """ + groups: list[tuple[int, int]] = [] + for i, radius in enumerate(radii): + if radius >= minimum_m: + continue + start, end = _grow(radii, i) + if groups and start <= groups[-1][1] + 1: + groups[-1] = (groups[-1][0], max(groups[-1][1], end)) + continue + groups.append((start, end)) + return groups + + +def _grow(radii: list[float], index: int) -> tuple[int, int]: + """Extend outwards from a tight point while the neighbours are still part of the bend.""" + start = index + while start > 1 and radii[start - 1] < CORNER_CONTEXT_RADIUS_M: + start -= 1 + end = index + while end < len(radii) - 2 and radii[end + 1] < CORNER_CONTEXT_RADIUS_M: + end += 1 + return start, end + + +class Fillet: + """A circular arc of the target radius replacing one corner of the course.""" + + def __init__(self, points: list[Point], start: int, end: int, radius_m: float, floor_m: float): + self.points = points + self.start, self.end = start, end + self.radius_m, self.floor_m = radius_m, floor_m + + def arc(self) -> list[Point] | None: + """The widest arc that fits this corner, or ``None`` when even the floor radius won't. + + A corner between two short segments has no room for the target radius, so + the search widens the straights it hangs the arc between, then settles for + a smaller radius rather than leaving the corner untouched. + """ + for radius in self._radii(): + for before, after in self._tangent_candidates(): + arc = self._arc_between(before, after, radius) + if arc is not None: + return arc + return None + + def _radii(self): + """The target radius, then progressively smaller ones down to the floor.""" + radius = self.radius_m + while radius >= self.floor_m: + yield radius + radius -= RADIUS_BACKOFF_M + + def _tangent_candidates(self): + """Successively wider pairs of straight segments to hang the arc between.""" + for back in range(1, 4): + before, after = self.start - back, self.end + back + if before < 1 or after > len(self.points) - 2: + return + yield before, after + + def _arc_between(self, before: int, after: int, radius: float) -> list[Point] | None: + entry, exit_ = self.points[before], self.points[after] + heading_in = _unit(self.points[before - 1], entry) + heading_out = _unit(exit_, self.points[after + 1]) + deflection = _signed_angle(heading_in, heading_out) + if abs(math.degrees(deflection)) < MIN_DEFLECTION_DEG: + return None + apex = _intersect(entry, heading_in, exit_, heading_out) + if apex is None: + return None + tangent_m = radius * abs(math.tan(deflection / 2)) + if not self._tangents_fit(tangent_m, apex, before, after, heading_in, heading_out): + return None + return self._sample( + apex, tangent_m, deflection, heading_in, heading_out, before, after, radius + ) + + def _tangents_fit(self, tangent_m, apex, before, after, heading_in, heading_out) -> bool: + """Whether both tangent points land on the straights, not beyond the points feeding them.""" + room_in = (apex.x - self.points[before - 1].x) * heading_in[0] + ( + apex.y - self.points[before - 1].y + ) * heading_in[1] + room_out = (self.points[after + 1].x - apex.x) * heading_out[0] + ( + self.points[after + 1].y - apex.y + ) * heading_out[1] + return tangent_m < room_in and tangent_m < room_out + + def _sample(self, apex, tangent_m, deflection, heading_in, heading_out, before, after, radius): + start = Point(apex.x - tangent_m * heading_in[0], apex.y - tangent_m * heading_in[1], None) + finish = Point( + apex.x + tangent_m * heading_out[0], apex.y + tangent_m * heading_out[1], None + ) + turn = 1 if deflection > 0 else -1 + centre = Point( + start.x - turn * radius * heading_in[1], + start.y + turn * radius * heading_in[0], + None, + ) + steps = max(2, math.ceil(abs(math.degrees(deflection)) / ARC_STEP_DEG)) + first = math.atan2(start.y - centre.y, start.x - centre.x) + arc = [ + Point( + centre.x + radius * math.cos(first + deflection * step / steps), + centre.y + radius * math.sin(first + deflection * step / steps), + None, + ) + for step in range(steps + 1) + ] + arc[0], arc[-1] = start, finish + return _with_elevations(arc, self.points[before - 1].ele, self.points[after + 1].ele) + + +def _with_elevations( + arc: list[Point], start_ele: float | None, end_ele: float | None +) -> list[Point]: + """Spread the elevation change across the corner in proportion to distance along it.""" + if start_ele is None or end_ele is None: + return arc + spans = [0.0] + for a, b in zip(arc, arc[1:]): + spans.append(spans[-1] + _distance(a, b)) + total = spans[-1] or 1.0 + for point, along in zip(arc, spans): + point.ele = start_ele + (end_ele - start_ele) * along / total + return arc + + +def _signed_angle(a: tuple[float, float], b: tuple[float, float]) -> float: + return math.atan2(a[0] * b[1] - a[1] * b[0], a[0] * b[0] + a[1] * b[1]) + + +def _intersect( + a: Point, da: tuple[float, float], b: Point, db: tuple[float, float] +) -> Point | None: + """Where the two straights would meet if extended; ``None`` when they are parallel.""" + denom = da[0] * db[1] - da[1] * db[0] + if abs(denom) < 1e-9: + return None + t = ((b.x - a.x) * db[1] - (b.y - a.y) * db[0]) / denom + return Point(a.x + t * da[0], a.y + t * da[1], None) + + +def widen(course: Course, radius_m: float, minimum_m: float) -> tuple[list[Point], list[str]]: + """Rebuild the course with each corner tighter than ``minimum_m`` replaced by an arc. + + Widening one corner can leave its new end points curving tighter than the + threshold, so the sweep repeats until nothing is left to fix. Corners that no + arc fits are reported once and then left alone, rather than retried forever. + """ + points, notes = course.points, [] + stuck: set[tuple[float, float]] = set() + for _ in range(MAX_PASSES): + radii = Course.radii(_as_course(course, points)) + groups = [g for g in _corner_groups(radii, minimum_m) if _key(points, g) not in stuck] + if not groups: + break + points, failures = _apply_groups(points, groups, radius_m, minimum_m, stuck) + notes.extend(failures) + return points, notes + + +def _key(points: list[Point], group: tuple[int, int]) -> tuple[float, float]: + """Identifies a corner by position, so it survives the renumbering a pass causes.""" + return (round(points[group[0]].x, 2), round(points[group[0]].y, 2)) + + +def _as_course(course: Course, points: list[Point]) -> Course: + """A shallow stand-in carrying replacement points, for reusing the geometry helpers.""" + clone = object.__new__(Course) + clone.__dict__ = dict(course.__dict__) + clone.points = points + return clone + + +def _apply_groups(points, groups, radius_m, minimum_m, stuck) -> tuple[list[Point], list[str]]: + """Replace each corner with its arc, in order, keeping everything between them.""" + rebuilt: list[Point] = [] + cursor = 0 + failures = [] + for start, end in groups: + arc = Fillet(points, start, end, radius_m, minimum_m).arc() + if arc is None: + stuck.add(_key(points, (start, end))) + failures.append(_why_stuck(points, start, end, minimum_m)) + continue + rebuilt.extend(points[cursor : max(cursor, start - 1)]) + rebuilt.extend(arc) + cursor = end + 2 + rebuilt.extend(points[cursor:]) + return rebuilt, failures + + +def _why_stuck(points: list[Point], start: int, end: int, minimum_m: float) -> str: + """Explain a corner no arc fits, naming a hairpin because that needs the other tool.""" + turn = abs( + math.degrees( + _signed_angle( + _unit(points[start - 1], points[start]), _unit(points[end], points[end + 1]) + ) + ) + ) + where = f"point {start} turns {turn:.0f} deg" + if turn < HAIRPIN_DEG: + return f"no arc of {minimum_m:g} m or wider fits the corner where {where}" + return ( + f"no arc of {minimum_m:g} m or wider fits the hairpin where {where}:" + " its legs are too close together, so separate them further" + " with gpx-separate-legs.py --offset before widening" + ) + + +def _render(course: Course, points: list[Point]) -> str: + """The original file with its point list replaced and refreshed.""" + latlon = [course.to_latlon(p) for p in points] + body = "\n".join( + f' \n {p.ele:.1f}\n ' + if p.ele is not None + else f' ' + for (lat, lon), p in zip(latlon, points) + ) + head, _, rest = course.text.partition("") + out = f"{head}{body.lstrip()}{tail}" + lats = [lat for lat, _ in latlon] + lons = [lon for _, lon in latlon] + bounds = ( + f'' + ) + return BOUNDS_TAG.sub(bounds, out, count=1) + + +def report(label: str, course: Course, minimum_m: float) -> list[int]: + radii = course.radii() + tight = course.tight_indices(minimum_m) + print(f"{label}: {course.length_m / 1000:.2f} km, {len(course.points)} points") + print(f" tightest curve radius: {min(r for r in radii if r != float('inf')):.2f} m") + print(f" points under {minimum_m:g} m: {len(tight)}") + for i in tight: + lat, lon = course.to_latlon(course.points[i]) + print( + f" {course.distance_to(i):9.1f} m in:" + f" r={radii[i]:6.2f} m (lat {lat:.5f}, lon {lon:.5f})" + ) + return tight + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("gpx") + parser.add_argument( + "-o", "--output", help="write the widened course here (omit to only report)" + ) + parser.add_argument( + "--radius", + type=float, + default=9.0, + help="radius of the replacement arcs, metres (default 9)", + ) + parser.add_argument( + "--minimum", + type=float, + default=8.0, + help="widen anything tighter than this, metres" + f" (default 8; TPV rejects under {TPV_MIN_RADIUS_M:g})", + ) + args = parser.parse_args() + + with open(args.gpx, encoding="utf-8") as fh: + original = Course(fh.read()) + tight = report("original", original, args.minimum) + if not args.output: + return 1 if tight else 0 + if not tight: + print("nothing to fix") + return 0 + + points, notes = widen(original, args.radius, args.minimum) + fixed = Course(_render(original, points)) + print(f"\nwidened {len(tight)} corner(s) to {args.radius:g} m") + for note in notes: + print(f" ! {note}", file=sys.stderr) + remaining = report("fixed", fixed, args.minimum) + change = fixed.length_m - original.length_m + print(f" length change: {change:+.0f} m ({100 * change / original.length_m:+.3f}%)") + with open(args.output, "w", encoding="utf-8") as fh: + fh.write(fixed.text) + print(f"wrote {args.output}") + return 1 if remaining or notes else 0 + + +if __name__ == "__main__": + sys.exit(main()) From be6fea58b2c0f42edf63975407097c060bc851d2 Mon Sep 17 00:00:00 2001 From: Ben Atkinson Date: Sun, 20 Sep 2026 09:21:00 +0100 Subject: [PATCH 2/5] Fix two bugs Seer found in gpx-widen-curves Render with the source point tag, not a hardcoded . The parser accepts too, but _render partitioned on '. 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). --- tools/gpx-widen-curves.py | 54 ++++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/tools/gpx-widen-curves.py b/tools/gpx-widen-curves.py index 3bfeead..2d008a6 100755 --- a/tools/gpx-widen-curves.py +++ b/tools/gpx-widen-curves.py @@ -25,8 +25,8 @@ import sys from dataclasses import dataclass -TRKPT = re.compile( - r'<(?:trkpt|rtept)\b[^>]*\blat="([-+\d.eE]+)"[^>]*\blon="([-+\d.eE]+)"[^>]*>' +POINT = re.compile( + r'<(trkpt|rtept)\b[^>]*\blat="([-+\d.eE]+)"[^>]*\blon="([-+\d.eE]+)"[^>]*>' r"(?:\s*([-+\d.eE]+))?.*?", re.DOTALL, ) @@ -55,17 +55,18 @@ class Course: def __init__(self, text: str): self.text = text - raw = TRKPT.findall(text) + raw = POINT.findall(text) if len(raw) < 3: raise SystemExit("no / points found") - latlon = [(float(lat), float(lon)) for lat, lon, _ in raw] + self.tag = raw[0][0] + latlon = [(float(lat), float(lon)) for _, lat, lon, _ in raw] self.origin = latlon[0] mean_lat = sum(lat for lat, _ in latlon) / len(latlon) self.m_per_deg_lon = 111_320 * math.cos(math.radians(mean_lat)) self.m_per_deg_lat = 110_540 self.points = [ Point(*self._to_xy(lat, lon), float(ele) if ele else None) - for (lat, lon), (_, _, ele) in zip(latlon, raw) + for (lat, lon), (_, _, _, ele) in zip(latlon, raw) ] def _to_xy(self, lat: float, lon: float) -> tuple[float, float]: @@ -150,6 +151,22 @@ def _grow(radii: list[float], index: int) -> tuple[int, int]: return start, end +@dataclass +class Arc: + """A fitted arc, with the indices of the two points it hangs between. + + ``before``/``after`` are what the caller must splice against: the arc starts + on the straight running into ``points[before]`` and ends on the one leaving + ``points[after]``. They are not always the corner's immediate neighbours, + because a tight corner is hung between wider pairs (see + ``Fillet._tangent_candidates``). + """ + + points: list[Point] + before: int + after: int + + class Fillet: """A circular arc of the target radius replacing one corner of the course.""" @@ -158,7 +175,7 @@ def __init__(self, points: list[Point], start: int, end: int, radius_m: float, f self.start, self.end = start, end self.radius_m, self.floor_m = radius_m, floor_m - def arc(self) -> list[Point] | None: + def arc(self) -> Arc | None: """The widest arc that fits this corner, or ``None`` when even the floor radius won't. A corner between two short segments has no room for the target radius, so @@ -167,9 +184,9 @@ def arc(self) -> list[Point] | None: """ for radius in self._radii(): for before, after in self._tangent_candidates(): - arc = self._arc_between(before, after, radius) - if arc is not None: - return arc + fitted = self._arc_between(before, after, radius) + if fitted is not None: + return Arc(fitted, before, after) return None def _radii(self): @@ -307,14 +324,14 @@ def _apply_groups(points, groups, radius_m, minimum_m, stuck) -> tuple[list[Poin cursor = 0 failures = [] for start, end in groups: - arc = Fillet(points, start, end, radius_m, minimum_m).arc() - if arc is None: + fitted = Fillet(points, start, end, radius_m, minimum_m).arc() + if fitted is None: stuck.add(_key(points, (start, end))) failures.append(_why_stuck(points, start, end, minimum_m)) continue - rebuilt.extend(points[cursor : max(cursor, start - 1)]) - rebuilt.extend(arc) - cursor = end + 2 + rebuilt.extend(points[cursor : max(cursor, fitted.before)]) + rebuilt.extend(fitted.points) + cursor = max(cursor, fitted.after + 1) rebuilt.extend(points[cursor:]) return rebuilt, failures @@ -341,14 +358,15 @@ def _why_stuck(points: list[Point], start: int, end: int, minimum_m: float) -> s def _render(course: Course, points: list[Point]) -> str: """The original file with its point list replaced and refreshed.""" latlon = [course.to_latlon(p) for p in points] + tag = course.tag body = "\n".join( - f' \n {p.ele:.1f}\n ' + f' <{tag} lat="{lat:.9f}" lon="{lon:.9f}">\n {p.ele:.1f}\n ' if p.ele is not None - else f' ' + else f' <{tag} lat="{lat:.9f}" lon="{lon:.9f}"/>' for (lat, lon), p in zip(latlon, points) ) - head, _, rest = course.text.partition("") + head, _, rest = course.text.partition(f"<{tag}") + _, _, tail = rest.rpartition(f"") out = f"{head}{body.lstrip()}{tail}" lats = [lat for lat, _ in latlon] lons = [lon for _, lon in latlon] From 83116cd26d9be9589f6f487450dec85114332e41 Mon Sep 17 00:00:00 2001 From: Ben Atkinson Date: Sun, 20 Sep 2026 09:32:04 +0100 Subject: [PATCH 3/5] Fix two more Seer findings in gpx-widen-curves 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 , the widen succeeded and then re-parsing its own output raised 'no / points found', so nothing was written at all. Emit '' instead; an 18-point no-elevation course now round-trips to 25 points, valid XML. --- tools/gpx-widen-curves.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tools/gpx-widen-curves.py b/tools/gpx-widen-curves.py index 2d008a6..f7567eb 100755 --- a/tools/gpx-widen-curves.py +++ b/tools/gpx-widen-curves.py @@ -362,7 +362,7 @@ def _render(course: Course, points: list[Point]) -> str: body = "\n".join( f' <{tag} lat="{lat:.9f}" lon="{lon:.9f}">\n {p.ele:.1f}\n ' if p.ele is not None - else f' <{tag} lat="{lat:.9f}" lon="{lon:.9f}"/>' + else f' <{tag} lat="{lat:.9f}" lon="{lon:.9f}">' for (lat, lon), p in zip(latlon, points) ) head, _, rest = course.text.partition(f"<{tag}") @@ -380,8 +380,12 @@ def _render(course: Course, points: list[Point]) -> str: def report(label: str, course: Course, minimum_m: float) -> list[int]: radii = course.radii() tight = course.tight_indices(minimum_m) + curved = [r for r in radii if r != float("inf")] print(f"{label}: {course.length_m / 1000:.2f} km, {len(course.points)} points") - print(f" tightest curve radius: {min(r for r in radii if r != float('inf')):.2f} m") + if curved: + print(f" tightest curve radius: {min(curved):.2f} m") + else: + print(" tightest curve radius: n/a (no curvature; every point is collinear)") print(f" points under {minimum_m:g} m: {len(tight)}") for i in tight: lat, lon = course.to_latlon(course.points[i]) From f2f9095317d7361e886519a7c26fb9c11a52fc90 Mon Sep 17 00:00:00 2001 From: Ben Atkinson Date: Sun, 20 Sep 2026 09:51:18 +0100 Subject: [PATCH 4/5] Stop claiming corners that were not widened, and name the boundary case 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. --- tools/gpx-widen-curves.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tools/gpx-widen-curves.py b/tools/gpx-widen-curves.py index f7567eb..37c3312 100755 --- a/tools/gpx-widen-curves.py +++ b/tools/gpx-widen-curves.py @@ -346,6 +346,12 @@ def _why_stuck(points: list[Point], start: int, end: int, minimum_m: float) -> s ) ) where = f"point {start} turns {turn:.0f} deg" + if start - 1 < 1 or end + 1 > len(points) - 2: + return ( + f"the corner where {where} sits at the route boundary: widening hangs an" + " arc between the straights either side, and there is no room for one" + " here, so extend the route past the corner or redraw it by hand" + ) if turn < HAIRPIN_DEG: return f"no arc of {minimum_m:g} m or wider fits the corner where {where}" return ( @@ -428,7 +434,7 @@ def main() -> int: points, notes = widen(original, args.radius, args.minimum) fixed = Course(_render(original, points)) - print(f"\nwidened {len(tight)} corner(s) to {args.radius:g} m") + print(f"\nwidened {len(tight) - len(notes)} of {len(tight)} corner(s) to {args.radius:g} m") for note in notes: print(f" ! {note}", file=sys.stderr) remaining = report("fixed", fixed, args.minimum) From dbf24010024dd3d28b98bed41bb0670a4f8ba3a5 Mon Sep 17 00:00:00 2001 From: Ben Atkinson Date: Sun, 20 Sep 2026 09:59:41 +0100 Subject: [PATCH 5/5] Count corners actually widened, not tight points minus failures 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. --- tools/gpx-widen-curves.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tools/gpx-widen-curves.py b/tools/gpx-widen-curves.py index 37c3312..d92643f 100755 --- a/tools/gpx-widen-curves.py +++ b/tools/gpx-widen-curves.py @@ -286,14 +286,16 @@ def _intersect( return Point(a.x + t * da[0], a.y + t * da[1], None) -def widen(course: Course, radius_m: float, minimum_m: float) -> tuple[list[Point], list[str]]: +def widen( + course: Course, radius_m: float, minimum_m: float +) -> tuple[list[Point], list[str], int]: """Rebuild the course with each corner tighter than ``minimum_m`` replaced by an arc. Widening one corner can leave its new end points curving tighter than the threshold, so the sweep repeats until nothing is left to fix. Corners that no arc fits are reported once and then left alone, rather than retried forever. """ - points, notes = course.points, [] + points, notes, widened = course.points, [], 0 stuck: set[tuple[float, float]] = set() for _ in range(MAX_PASSES): radii = Course.radii(_as_course(course, points)) @@ -301,8 +303,9 @@ def widen(course: Course, radius_m: float, minimum_m: float) -> tuple[list[Point if not groups: break points, failures = _apply_groups(points, groups, radius_m, minimum_m, stuck) + widened += len(groups) - len(failures) notes.extend(failures) - return points, notes + return points, notes, widened def _key(points: list[Point], group: tuple[int, int]) -> tuple[float, float]: @@ -432,9 +435,9 @@ def main() -> int: print("nothing to fix") return 0 - points, notes = widen(original, args.radius, args.minimum) + points, notes, widened = widen(original, args.radius, args.minimum) fixed = Course(_render(original, points)) - print(f"\nwidened {len(tight) - len(notes)} of {len(tight)} corner(s) to {args.radius:g} m") + print(f"\nwidened {widened} corner(s) to {args.radius:g} m") for note in notes: print(f" ! {note}", file=sys.stderr) remaining = report("fixed", fixed, args.minimum)