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..d92643f --- /dev/null +++ b/tools/gpx-widen-curves.py @@ -0,0 +1,453 @@ +#!/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 + +POINT = 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 = POINT.findall(text) + if len(raw) < 3: + raise SystemExit("no / points found") + 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) + ] + + 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 + + +@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.""" + + 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) -> 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 + 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(): + fitted = self._arc_between(before, after, radius) + if fitted is not None: + return Arc(fitted, before, after) + 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], 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, widened = course.points, [], 0 + 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) + widened += len(groups) - len(failures) + notes.extend(failures) + return points, notes, widened + + +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: + 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, fitted.before)]) + rebuilt.extend(fitted.points) + cursor = max(cursor, fitted.after + 1) + 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 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 ( + 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] + tag = course.tag + 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}">' + for (lat, lon), p in zip(latlon, points) + ) + 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] + 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) + curved = [r for r in radii if r != float("inf")] + print(f"{label}: {course.length_m / 1000:.2f} km, {len(course.points)} points") + 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]) + 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, widened = widen(original, args.radius, args.minimum) + fixed = Course(_render(original, points)) + 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) + 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())