Skip to content

Commit 1a5996d

Browse files
committed
feat: download button for each trace, hidden spots and traces are excluded from the downloaded KML/GPX/GeoJSON
1 parent 70b3ddb commit 1a5996d

13 files changed

Lines changed: 6032 additions & 261 deletions

File tree

.github/skills/spot-data/SKILL.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ python3 lifts.py 46.74,6.31,46.79,6.40 --ele
1515
python3 trails.py 46.14,6.65,46.18,6.71
1616
python3 find_named.py candidates.json # rung 1: what is OSM calling it, near here?
1717
python3 verify_mtb.py candidates.json # rung 2: is there riding near this coordinate?
18+
python3 gpstraces.py 47.815,7.94,47.83,7.96 # public GPS traces, when nothing is mapped
1819
```
1920

2021
## 1. Coordinates for a spot
@@ -212,6 +213,100 @@ Relation members arrive in no order and no consistent direction. The script chai
212213

213214
When you hit it: pick a single `way` instead, find a relation that is one genuine descent (`Alpages Respect`, relation 17656035, chains cleanly), or pass `--force` and delete the bad segment by hand. Always re-check the drawn line in `vp dev` afterwards.
214215

216+
### The public GPS trace archive - use it at natural spots, skip it at bike parks
217+
218+
`trails.py` queries tagged **map objects**. OSM also holds a second, quite separate database - the **GPS traces** people upload, raw and uncurated. `gpstraces.py` reads it.
219+
220+
**The verdict, from testing five spots, is a clean split:**
221+
222+
- **Natural, pedal-access, locally-built terrain: try it.** At Cousimbert two traces between them covered **Sur Martou, La Joux de Treyvaux and Belle Cierne** - three of the five lines on that spot - to within 25 m. Everything this map wants was sitting in the archive.
223+
- **Lift-served bike parks: do not bother.** Todtnau, Les Gets, Morzine-Pleney and Châtel returned nothing usable between them. Gravity riders upload to Strava and to the platforms this project does not cite; they have not uploaded to OSM in fifteen years.
224+
225+
The reason is who uses the archive. It skews old, and towards hikers, ski-tourers, trail runners and cyclotourists - exactly the people who also walk and ride the unsigned local trails that no park operator publishes a GPX for. So it fills in precisely the gap the Sugarloaf precedent otherwise leaves bare.
226+
227+
```bash
228+
python3 gpstraces.py 47.815,7.940,47.832,7.960 # what has been ridden here
229+
python3 gpstraces.py 47.815,7.940,47.832,7.960 --near 7.952,47.821 # sort by distance from the lift top
230+
python3 gpstraces.py --trace 12004364 # length and profile
231+
python3 gpstraces.py --trace 12004364 --emit # <coordinates> block
232+
```
233+
234+
**Two endpoints, and the difference is the whole trick.**
235+
236+
`api/0.6/trackpoints?bbox=W,S,E,N&page=N` is the search. Public, no auth, 5000 points per page - keep paging until a page repeats. It hands back `lat`/`lon`/`time` and **strips the elevation**, so it is only ever a way to find out _which_ traces exist.
237+
238+
`https://www.openstreetmap.org/trace/<id>/data` is the download, and it returns the **original uploaded file**: full precision, every point, and `<ele>` on all of them. Note the host - this is the website, not the API. The documented `api/0.6/gpx/<id>/data` returns **401 Couldn't authenticate you** without OAuth, so use the `/trace/` URL.
239+
240+
"Original uploaded file" is literal: much of the archive predates the web form, so a large share of it arrives **bzip2, gzip or zip compressed** - 9 of 16 downloads at Les Gets were bzip2, regardless of the `.gpx` in the URL. An XML parser reports those as `not well-formed: line 1, column 7`, which reads like a corrupt trace rather than a compressed one. `gpstraces.decompress()` sniffs the magic bytes and handles all three.
241+
242+
Bounding boxes go in as `S,W,N,E` like every other script here; the API wants `W,S,E,N` and `gpstraces.py` converts. The API caps a bbox at 0.25 square degrees.
243+
244+
**Read the privacy level before you read the geometry.** A trace's usefulness is decided entirely by what its owner chose on upload:
245+
246+
| Level | In the bbox response | Usable? |
247+
| --- | --- | --- |
248+
| identifiable / trackable | its own `<trk>`, with `<name>`, `<url>` and timestamps | yes - and `<url>` gives you the id to download in full |
249+
| trackable, anonymised | its own `<trk>`, timestamps, but no name or url | geometry only, and no elevation, ever |
250+
| public / private | dumped into shared anonymous `<trk>` blocks of 5000 points **in no order at all**, with no timestamps | no |
251+
252+
That last row is the trap. Those blocks look exactly like the others in the XML, and chaining one produces a plausible-looking `<coordinates>` list that is actually a scribble across the whole valley - at Todtnau it measured **1861 km inside a 2 km box**. `gpstraces.py` detects them (no `<time>` on any point), reports them as "unordered pool (unusable)" and hides them from the listing. Do not undo that.
253+
254+
#### How to pick the right trace: sort by distance, then read the filenames
255+
256+
**This is the whole method, and it is embarrassingly simple.** Point `--near` at the top of the descent, and read the first ten filenames.
257+
258+
```bash
259+
python3 gpstraces.py 46.670,7.130,46.730,7.220 --near 7.1872,46.6971
260+
```
261+
262+
```
263+
5 m 4.24 km 644 pts 2808981 x_fma_x 2018_09_20_Cousimbert.gpx
264+
14 m 11.44 km 1737 pts 3897510 fangly 2021_10_30_09_31_Sat_sur_martoux.gpx
265+
15 m 9.43 km 3237 pts 3320863 ch_de_75 20200530_Trail_Torryboden_LaBerra.gpx
266+
```
267+
268+
`sur_martoux` is **Sur Martou**. Download those two and check them against what is already drawn:
269+
270+
```
271+
trace 2808981: covers 100% of La Joux de Treyvaux
272+
covers 92% of Belle Cierne
273+
trace 3897510: covers 100% of Sur Martou
274+
```
275+
276+
Filename and proximity did all the work. A person who names a file after a trail rode that trail.
277+
278+
#### Do not build a physics filter. It was tried, and it finds skiers.
279+
280+
The tempting idea is that a gravity run has a signature - big drop, steep gradient, riding speed - so `tracefilter.py` was built to look inside each **downloaded** trace for a descent window of **250 m or more of drop, at 6 % or steeper, over at least 1 km, at 8-45 km/h**, closing the window as soon as the rider climbs 30 m back above their low point.
281+
282+
It works mechanically and it is useless. **It fails in both directions.**
283+
284+
_False positives at bike parks_, because a skier and a downhill rider have the same signature - same lift, same 500-700 m drop, same 12-25 km/h, same gradient:
285+
286+
| Park | segments in bbox | downloadable near the lift top | passed the filter | actually MTB |
287+
| --- | --- | --- | --- | --- |
288+
| Todtnau | 80 | 18 | 0 | 0 |
289+
| Les Gets | 62 | 16 | 3 | 0 |
290+
| Morzine-Pleney | 75 | 13 | 5 | 0 |
291+
| Châtel | 61 | 5 | 1 | 0 |
292+
293+
Every hit was February or March - `morzine20100314a1`, `2012_02_23 Skiing Portes du Soleil`, `2013_03_28_Chatel_ski`. The one summer hit was a road ride in from Lake Geneva. Add a month test to kill the skiing and all four parks return **nothing at all**.
294+
295+
_False negatives at natural spots_, which is worse, because that is where the archive actually delivers. **The filter rejects the Cousimbert traces that hold all three trails.** They average 7.5 km/h, well under the 8 km/h floor - because at a pedal-access spot the climb is in the same file as the descent. Filter on mean speed and you throw away the only good data in the archive.
296+
297+
Two more traps from the same experiment:
298+
299+
- **Speed does not identify riding.** At Todtnau 56 of 80 segments sustain over 15 km/h, because the B317 runs up the valley and cars are in the archive too.
300+
- **Concatenated archives.** `alle_Wandertracks.gpx`, `Alle_Biketracks.gpx`, `activities.zip` - somebody's entire history in one upload, 170 km, jumping between valleys. Length is not a quality signal.
301+
302+
`tracefilter.py` is kept as the record of this, guarded under `__main__`; re-running it takes about fifteen minutes. Judge by **what the trace is**, the same test rung 2 uses, and verify by hand before anything becomes a line.
303+
304+
`--trace` prints `(STORED UPHILL - reverse it)` when the net drop is negative. Believe it - the renderer draws direction arrows from point order, and at natural spots this fires often: the Cousimbert file holding La Joux de Treyvaux and Belle Cierne is stored as the climb.
305+
306+
A trace that covers a descent usually contains the climb to it as well, so **slice before you simplify**. Cut at the high point, keep the descending half, and only then run it through the 8 m simplification.
307+
308+
Credit these as **`Geometry simplified from OpenStreetMap GPS trace <id> (ODbL)`**. They are ODbL like the rest of OSM. Never credit an anonymised trace to a user.
309+
215310
## 3. Additional information
216311

217312
When creating or updating a spot or trace add the following information, when possible based on retrieved sources:
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
"""Search the OpenStreetMap public GPS trace archive, and turn one trace into KML.
2+
3+
python3 gpstraces.py 47.815,7.940,47.832,7.960 # what has been ridden here
4+
python3 gpstraces.py 47.815,7.940,47.832,7.960 --near 7.95,47.82 # sort by distance
5+
python3 gpstraces.py --trace 12004364 # profile one trace
6+
python3 gpstraces.py --trace 12004364 --emit # <coordinates> block
7+
8+
This is the fallback for when OSM has no mapped trail geometry: real riders'
9+
GPS logs. It is a different database from the one `trails.py` queries - traces
10+
are uploaded files, not tagged map objects, and nobody has curated them.
11+
12+
Two endpoints, and the difference between them matters:
13+
14+
api/0.6/trackpoints?bbox=W,S,E,N&page=N (the listing form, used here)
15+
Public, no auth. 5000 points per page, so page until a page repeats.
16+
Returns lat/lon/time only - the elevation is stripped. Traces whose
17+
owner chose "identifiable" or "trackable" carry <name> and <url>;
18+
the rest arrive as anonymous <trk> blocks with no owner and no id.
19+
20+
/trace/<id>/data (the download form, used by --trace)
21+
Also public and unauthenticated, on www.openstreetmap.org rather than
22+
api.openstreetmap.org. Returns the ORIGINAL uploaded file: full
23+
precision, full point count, and <ele> on every point. This is the one
24+
you actually build a line from.
25+
26+
api/0.6/gpx/<id>/data is the documented equivalent and returns 401
27+
without OAuth. Use the /trace/ URL.
28+
29+
Bounding boxes are given here as S,W,N,E, like the other scripts. The API
30+
wants W,S,E,N; this converts. The API caps a bbox at 0.25 square degrees.
31+
32+
Licence: traces are ODbL like the rest of OSM. Credit them as
33+
"Geometry simplified from OpenStreetMap GPS trace <id> (ODbL)".
34+
"""
35+
36+
import bz2
37+
import gzip
38+
import io
39+
import math
40+
import sys
41+
import urllib.request
42+
import xml.etree.ElementTree as ET
43+
import zipfile
44+
45+
from trails import length, simplify
46+
47+
UA = "alpine-mtb-map/1.0 (+https://github.com/vemonet/alpine-mtb-map)"
48+
BBOX_URL = "https://api.openstreetmap.org/api/0.6/trackpoints?bbox={w},{s},{e},{n}&page={p}"
49+
TRACE_URL = "https://www.openstreetmap.org/trace/{id}/data"
50+
GPX0 = "{http://www.topografix.com/GPX/1/0}"
51+
52+
53+
def get(url):
54+
req = urllib.request.Request(url, headers={"User-Agent": UA})
55+
with urllib.request.urlopen(req, timeout=60) as r:
56+
return r.read()
57+
58+
59+
def scan(bbox, max_pages=20):
60+
"""Page the bbox endpoint until it stops giving new data.
61+
62+
Each <trk> element is kept separately rather than merged by name: one
63+
upload can cross the bbox several times, and merging the visits draws a
64+
straight bar between them. Anonymous traces have no url and cannot be
65+
downloaded in full - their bbox points are all you will ever get.
66+
"""
67+
s, w, n, e = (float(x) for x in bbox.split(","))
68+
out, seen = [], set()
69+
for p in range(max_pages):
70+
body = get(BBOX_URL.format(w=w, s=s, e=e, n=n, p=p))
71+
if body in seen:
72+
break
73+
seen.add(body)
74+
trks = ET.fromstring(body).findall(GPX0 + "trk")
75+
if not trks:
76+
break
77+
for trk in trks:
78+
pts = [
79+
{"lat": float(t.get("lat")), "lon": float(t.get("lon"))}
80+
for t in trk.iter(GPX0 + "trkpt")
81+
]
82+
if len(pts) < 2:
83+
continue
84+
url = trk.findtext(GPX0 + "url") or ""
85+
# No timestamps means this is the unordered pool, not a path. The
86+
# API dumps every trace marked merely "public" into anonymous
87+
# blocks of 5000 points in no order at all, at the tail of the
88+
# pagination. Chain them and you get a 1800 km scribble.
89+
ordered = trk.find(f".//{GPX0}trkpt/{GPX0}time") is not None
90+
out.append(
91+
{
92+
"name": trk.findtext(GPX0 + "name") or "(anonymous)",
93+
"id": url.rsplit("/", 1)[-1] if url else "",
94+
"user": url.split("/user/")[-1].split("/")[0] if url else "",
95+
"pts": pts,
96+
"ordered": ordered,
97+
"km": length(pts) / 1000 if ordered else 0.0,
98+
}
99+
)
100+
return out
101+
102+
103+
def decompress(body):
104+
"""/trace/<id>/data serves the file exactly as uploaded, not as GPX.
105+
106+
Most of the archive predates the web upload form, so a large share of it is
107+
.gpx.bz2, .gpx.gz or .zip. At Les Gets 9 of 16 downloads were bzip2. Feed
108+
those to an XML parser and you get "not well-formed: line 1, column 7",
109+
which reads like a corrupt trace rather than a compressed one.
110+
"""
111+
if body[:3] == b"BZh":
112+
return bz2.decompress(body)
113+
if body[:2] == b"\x1f\x8b":
114+
return gzip.decompress(body)
115+
if body[:2] == b"PK":
116+
with zipfile.ZipFile(io.BytesIO(body)) as z:
117+
names = [n for n in z.namelist() if n.lower().endswith(".gpx")]
118+
return z.read(names[0] if names else z.namelist()[0])
119+
return body
120+
121+
122+
def parse_full(body):
123+
"""Read a downloaded trace. GPX 1.0 and 1.1 differ only in namespace."""
124+
root = ET.fromstring(decompress(body).lstrip(b"\xef\xbb\xbf"))
125+
ns = root.tag[: root.tag.index("}") + 1]
126+
pts = []
127+
for t in root.iter(ns + "trkpt"):
128+
ele = t.findtext(ns + "ele")
129+
pts.append(
130+
{
131+
"lat": float(t.get("lat")),
132+
"lon": float(t.get("lon")),
133+
"ele": float(ele) if ele else None,
134+
}
135+
)
136+
return pts
137+
138+
139+
def main(argv):
140+
if "--trace" in argv:
141+
tid = argv[argv.index("--trace") + 1]
142+
pts = parse_full(get(TRACE_URL.format(id=tid)))
143+
ele = [p["ele"] for p in pts if p["ele"] is not None]
144+
km = length(pts) / 1000
145+
if ele:
146+
drop = ele[0] - ele[-1]
147+
print(
148+
f"trace {tid}: {km:.1f} km, {len(pts)} pts, "
149+
f"{max(ele):.0f} -> {min(ele):.0f} m, net drop {drop:.0f} m"
150+
f"{' (STORED UPHILL - reverse it)' if drop < 0 else ''}"
151+
)
152+
else:
153+
print(f"trace {tid}: {km:.1f} km, {len(pts)} pts, no elevation in the file")
154+
if "--emit" in argv:
155+
simp = simplify(pts)
156+
print(
157+
f"\n<!-- GPS trace {tid}: {km:.1f} km, {len(pts)} pts -> {len(simp)}. "
158+
f"Source: OSM GPS trace {tid} (ODbL) -->"
159+
)
160+
coords = " ".join(f"{p['lon']:.6f},{p['lat']:.6f},0" for p in simp)
161+
print(f"<coordinates>{coords}</coordinates>")
162+
return
163+
164+
bbox = argv[1]
165+
near = None
166+
if "--near" in argv:
167+
lon, lat = (float(x) for x in argv[argv.index("--near") + 1].split(","))
168+
near = {"lon": lon, "lat": lat}
169+
trks = scan(bbox)
170+
if not trks:
171+
print("no public GPS traces in this bbox")
172+
return
173+
174+
def hav(a, b):
175+
r = 6371000
176+
p1, p2 = math.radians(a["lat"]), math.radians(b["lat"])
177+
h = (
178+
math.sin((p2 - p1) / 2) ** 2
179+
+ math.cos(p1) * math.cos(p2) * math.sin(math.radians(b["lon"] - a["lon"]) / 2) ** 2
180+
)
181+
return 2 * r * math.asin(math.sqrt(h))
182+
183+
if near:
184+
for t in trks:
185+
t["d"] = min(hav(near, p) for p in t["pts"])
186+
trks.sort(key=lambda t: t["d"])
187+
else:
188+
trks.sort(key=lambda t: -t["km"])
189+
190+
named = sum(1 for t in trks if t["id"])
191+
pool = sum(1 for t in trks if not t["ordered"])
192+
print(
193+
f"{len(trks)} track segments, {named} downloadable, "
194+
f"{len(trks) - named - pool} anonymous but ordered, {pool} unordered pool (unusable)\n"
195+
)
196+
for t in trks[:40]:
197+
if not t["ordered"]:
198+
continue
199+
d = f"{t['d']:5.0f} m " if near else ""
200+
who = f"{t['id']:>10} {t['user'][:16]:16s}" if t["id"] else f"{'-':>10} {'':16s}"
201+
print(f"{d}{t['km']:7.2f} km {len(t['pts']):6d} pts {who} {t['name'][:44]}")
202+
203+
204+
if __name__ == "__main__":
205+
if len(sys.argv) < 2:
206+
sys.exit(__doc__)
207+
main(sys.argv)

0 commit comments

Comments
 (0)