On-demand discovery of Strava Run segments near a point I haven't run yet
(or ran without taking the KOM), ranked by how likely I am to get the KOM.
Sister project to
folha-do-clube — it only
reuses comum.py; different purpose and cadence (this one is just for me, on
request, not a daily multi-athlete ranking).
It reads Strava segment tiles through my own authenticated session cookie, at human cadence, with an explicit per-run request cap. That makes it a personal tool rather than something to point at anyone else's data — see Auth below.
explore.py— segment tiles (Mapbox Vector Tile) in a radius around a point, filters Run, computes a rough GAP (mean grade + fixed reference pace) only to order the queue (most promising first) — never to exclude. Tested (25 Jul 2026): an earlier version that excluded by margin dropped candidates the good model later placed ~2s off the KOM. Writescandidatos.jsonalready sorted, trimmed only by--top. Segments with nokomElapsedTimein the tile (rare, missing data in Strava's backend — confirmed, not "nobody tried") go separately tosem_kom.jsonfor manual inspection via link.segment_detail.py— for each candidate, the/segments/<id>page: exact distance/grade/elevation, top-10 leaderboard (KOM included),athleteEffortCount(have I run it?), elevation/distance streams. Explicit per-run cap (--max) — one request per segment is new traffic, not pages you would visit anyway.gap_model.py— prediction of my time per segment, from the Intervals.icu GAP pace curve (direct API key) + effective distance computed point-by-point over the streams. Two distinct confidence levels in the output (see "Four groups" below) — it does not fake precision the model does not have for short segments.rank.py— final orchestrator with a persistent per-segmenthistorico.json. It only redoes Phase 2+3 for a segment if: it is not in the history yet, the KOM changed (compared for free — it already comes in Phase 1'scandidatos.json, no extra request), or--revisao-semanashave passed since the last analysis (default 4 — my own capability moves, not just other people's KOMs). Otherwise it reuses the stored score, zero new requests. Outputranking.jsonin the same groups asgap_model.py, each sorted byscore(predicted/heuristic − KOM) ascending — most achievable first.
Normal path (2 commands — rank.py already calls Phase 2 internally per
segment, no need to run segment_detail.py separately):
STRAVA_SESSION=<_strava4_session cookie> python explore.py \
--lat <LAT> --lon <LON> --athlete-id <ID> --pace-flat 3:40
STRAVA_SESSION=<_strava4_session cookie> python rank.py \
--in candidatos.json --out ranking.json --pace-flat 3:40segment_detail.py and gap_model.py are still useful run separately just to
inspect the raw detalhes.json / previsoes.json of a batch without touching
the persistent history — they are not part of the normal path.
--pace-flat (explore.py and gap_model.py/rank.py) is a flat reference pace
(mm:ss/km) used only in the rough heuristics — never in the high-confidence
model (that one comes from the real GAP curve, no manual input). Use a genuine
effort pace (short, chasing a KOM), not your normal training pace — only
validated so far against 3:40/km.
STRAVA_SESSION — an authenticated session cookie (DevTools → Application →
Cookies → strava.com → _strava4_session). Confirmed by test: the tiles
endpoint returns 401 without it, and also 401 if the --athlete-id in the URL
is not the session's own athlete — it is not just security by obscurity.
Renew it manually when it expires.
This is why the tool is personal: it reads Strava through my own logged-in
session, on my own request, at human cadence (explicit --max cap per run).
It is not a scraper pointed at other people's data and it does not run
unattended.
Real segment density only stabilises from z15 (~19/km² in the area I tested, 25 Jul 2026); at z10 (the old default) it measured ~170× lower — not a shortage of segments in the area, just normal pyramidal-tile decimation at low zoom (the map shows less detail when zoomed out, like any vector map). So:
- default
--zoom 15, default--raio 1.5(km) — area grows with the square of the radius, so fine zoom is only sustainable at small radii within a reasonable request count (MAX_TILES=40, covers up to ~2.5 km radius). - For areas larger than ~2 km: run several searches with different centres,
do not raise
--raio. Raising the radius at zoom 15 grows the tile count quadratically (3 km radius already exceeds 40 tiles); lowering the zoom to fit a bigger radius undersamples again. No automation for this yet (new scope, undecided if it is worth it). explore.pyalways prints the real coverage (tiles requested, km² covered vs requested, observed density) — so you never have to guess whether a low candidate count is "segment-poor area" or "incomplete search".intent=explorein the tile request, neverintent=popular— tested:popularreturns fewer than 2/3 of what any other value (explore,browse,nearby,top,recentall gave the same larger result) returns for the same tile/zoom.
Validation (24-25 Jul 2026) showed the Intervals.icu GAP pace curve is only reliable above ~1000 m effective distance, and that the short heuristic overvalues descents — see "Technical notes". So the output has 4 groups, not 2:
confianca_alta(effective ≥ 1000 m) —previsto_scomes from the real GAP model (curve table or critical-speed CS/D'), uncorrected. Tested against 5 real segments: error of -15% to -35%, always in the same direction — an explicit decision not to correct it, because the model measures the capability ceiling (best effort), not casual training pace, and that is the question the tool exists to answer ("if I go for it, can I beat the KOM?").confianca_especulativa_plano_subida(effective < 1000 m, mean grade ≥ 0) — no real coverage in the GAP curve (endurance training does not produce structured sprint data).heuristica_suses, in order: real curve points that survive the speed filter in that range (rare), else effective grade ×--pace-flat, else Phase 1'sprevisto_grosseiro_s. Tested against the only 2 short segments with a known real time (94 s, 64 s — where I already hold the KOM): error of -4% to -8% with--pace-flat 3:40— but it is heuristic, not physics, and only tested with this pace; do not generalise without a new test.confianca_especulativa_descida_SEM_CONFIANCA(effective < 1000 m, mean grade < 0) — not sorted, does not enter the decision of where to hunt. The effective distance reduced by Minetti on a descent, combined with a single--pace-flat(meant for flat, not descent speed), visibly overvalued these segments in testing: one never-run segment came out "more beatable" than two where I already hold the KOM. Zero real descent effort in the history to calibrate — kept aside until there is.revisao_manual— any never-run segment whose prediction beats the KOM by more thanMARGEM_SUSPEITA_PCT(15%,gap_model.py) drops out of the normal ranking into here. Automatic sanity guard-rail, not descent-specific — beating someone else's KOM by a large margin on an unknown segment is more likely a model error than sudden talent.
STRAVA_SESSION— session cookie (renew manually when it expires)STRAVA_ATHLETE_ID— numeric Strava athlete id for the session above (or pass--athlete-id); required, no defaultINTERVALS_ICU_API_KEY/INTERVALS_ICU_ATHLETE_ID— direct Basic Auth (literal usernameAPI_KEY, password is the key — confirmed on the official Intervals.icu forum).id=0in the path works as "the key's athlete". Keep it in a local.env(already in.gitignore) —carregar_env()ingap_model.pyreads it with no extra dependencies.
/maps/segments(Explore) is an SPA with no data in the HTML — segments come fromcdn-1.strava.com/tiles/segments/<athleteId>/<z>/<x>/<y>(Mapbox Vector Tile, decoded bymapbox-vector-tile).activityType == 9is Run (confirmed by spot-check)./segments/<id>is server-rendered — everything comes in__NEXT_DATA__(pageProps), no extra requests:metadata,measurements,initialLeaderboard.leaderboard(rank 1 = KOM),athleteEffortCount,athletePrEffort.timing.elapsedTime,streams.{distance,elevation,location}.komElapsedTimefrom the tile matchesleaderboard[0].elapsedTimefrom the detail (spot-check on 3 segments) — even so the detail is always the source of truth; the tile only serves the rough pre-filter.GET /api/v1/athlete/{id}/pace-curves.json?gap=truefrom Intervals.icu returns adistance[]/values[]curve (time in s) +paceModels(critical speedCS/dPrime), already GAP-normalised. Finding: between ~45 m and ~900 m, the table came entirely from ONE run (10K, 8 Mar 2026) with GPS noise at the start — implying 41-55 km/h, impossible.custo_minetti()incomum.pynow clamps grade to ±45% (Minetti's 5th-degree polynomial blows up outside that range) anddistancia_efetiva_streams()ingap_model.pyresamples the streams at steps ≥5 m before computing grade (Strava streams come with irregular spacing, sometimes <0.5 m — at that scale normal GPS/altimeter noise already implies grades of hundreds of %).gap_model.pyalso filters GAP curve points with implied speed > 24 km/h before using them — permanent guard-rail, not just for this case.