Lines that mean something¶
Every chapter so far has followed one drinking-water fountain in Wallonia. It is still there, and
it is still a Point, chapter 2 (shapes.md) fixed that. This chapter steps off the
fountain on purpose, because a fountain cannot answer the question this chapter is about.
A ride is not a point. It is a LineString, chapter 2 already introduced the word, running from wherever a rider clipped in to wherever they stopped. A line can be asked questions a point never could: how long is it, how much does it climb, and, the one this chapter is really about, what does the ground under it actually look like? A point either sits on a mapped surface or it does not. A 40 km route crosses dozens of separately-mapped road stretches, some surfaced, some not, some not mapped at all, and "what surface is this route" stops being a lookup and becomes a measurement with its own error bars. That is the whole chapter: how this project takes that measurement, and why it tells you, honestly, how much of the measurement it can even vouch for.
GPX is a list of points¶
Before a ride is a LineString in a database, it is a GPX file, an XML format that bike
computers, phone apps, and GPS watches all export. Strip away the XML ceremony and a GPX track is
an ordered list of <trkpt lat="…" lon="…"> elements, usually each with a nested <ele> (the
recorded elevation) and often a <time>. This project's own parser,
web/src/Contribution/Gpx/GpxParser.php, GpxParser::parse(), reads exactly lat, lon, and
ele off every <trkpt> it finds and nothing else; it never looks at <time> at all, because
nothing downstream needs it.
foreach ($doc->getElementsByTagNameNS('*', 'trkpt') as $trkpt) {
$lat = $trkpt->getAttribute('lat');
$lng = $trkpt->getAttribute('lon');
...
$ele = null;
foreach ($trkpt->getElementsByTagNameNS('*', 'ele') as $eleNode) {
$ele = is_numeric($eleNode->textContent) ? (float) $eleNode->textContent : null;
That is the entire read: one attribute for latitude, one for longitude, one nested element for
elevation, walked once per <trkpt>. Everything else in GpxParser::parse(), the byte-size cap,
the coordinate-range validation, the point-count cap, is about rejecting a bad file, never about
reading more out of a good one.
Turning that into the LineString this project stores is almost entirely a subtraction, not a
transformation. RouteProposalService::propose() (web/src/Contribution/RouteProposalService.php)
takes the parsed [lat, lng, ele|null] triples, keeps only the first two numbers, and flips their
order:
$coords = array_map(
static fn (array $p): array => [$p[1], $p[0]],
$this->processor->simplify($trimmed),
);
[lat, lng] becomes [lng, lat], the same longitude-first flip chapter 1
(coordinates.md) warned about, happening in this codebase's own route intake
path. The elevation comes along for one more calculation (below) and then it is dropped for good:
recommended_route.geom never
stores an elevation, only the flattened [lng, lat] path. "Becoming a LineString" really is just
"throw away everything except where it was."
Derived numbers are choices¶
Two numbers get attached to every proposed route before it is ever saved: distance_m and
ascent_m. They sound like plain facts read off the track. They are not. Each one is the output
of a decision somebody made about how to calculate it, and a different decision gives a different
number from the same GPS trace.
Both of them are measured on the trimmed track, and that word means something specific here.
Before anything is calculated or stored, TrackProcessor::trim() cuts between 350 and 750 metres
off each end of the uploaded track, the privacy end-trim (route-domain.md §4.3), so that a
stored route never reveals where its proposer actually started or finished. How much comes off is
derived from a hash of the upload's own bytes, which makes it deterministic for a given file but not
guessable from the result. Nothing downstream ever sees the untrimmed track; the untrimmed upload
never reaches storage at all. Trimming is not the same operation as thinning, which happens later
and is a different word for a different thing. The distinction matters for ascent_m below.
Distance is the easy one, and it is still a choice. TrackProcessor::distanceM()
(web/src/Contribution/Gpx/TrackProcessor.php) walks the trimmed track and sums the great-circle
distance between every consecutive pair of points. That per-pair distance is the haversine
formula, in TrackProcessor::haversineM(): the standard way to get the distance between two
[lat, lng] pairs measured along the surface of a sphere, rather than straight through it. The
sphere it assumes is a single fixed radius, EARTH_RADIUS_M, 6,371,000 metres, not the ellipsoid
that ::geography uses in SQL (chapter 3, metres-vs-degrees.md), which is
one more small choice made here in passing, and a defensible one at the scale of a bike ride. That
sum depends on how many points the track has: a denser GPS trace produces a slightly longer sum
than a sparser one, because more short zig-zags get counted individually instead of averaged away.
The choice here is nearly invisible because GPS traces are dense enough that it barely moves the
answer, but it is still a choice, made once, and
baked into every stored distance_m.
Ascent is where the choice actually shows. TrackProcessor::ascentM() does the obvious naive
thing: walk the same trimmed points in order, and for every step where elevation went up, add the
difference to a running total.
if ($i > 0 && $points[$i][2] > $points[$i - 1][2]) {
$gain += $points[$i][2] - $points[$i - 1][2];
}
Notice what this doesn't do: it doesn't smooth, it doesn't ignore small wobbles, it doesn't
require a rise to clear any minimum before it counts. GPS and barometric elevation readings are
noisy by several metres in either direction, point to point, even on dead-flat ground. Sum every
uphill wobble on a noisy signal and you are, in part, counting noise as climbing, and this method
counts every one of them, on the full-density track, because RouteProposalService::propose()
calls it on the trimmed points (route-domain.md §4.2, step 5), ends cut off, every remaining
point still there, before step 6 thins those points down for serving: a radial pre-decimation
at half the tolerance and then Douglas-Peucker at 10 m, both inside TrackProcessor::simplify().
Two similar-sounding words, two different operations, and ascent_m lands between them. A denser track has more wobbles to sum, so, like distance, but far
more visibly, a different recording density can hand back a different ascent_m for a ride that
climbed exactly the same hill.
None of that makes the naive sum wrong. It makes it a choice, out of several reasonable ones,
some tools only count a rise once it clears a small threshold (a metre or two), precisely to filter
that wobble out, at the cost of slightly under-counting real short, sharp climbs. This project's
choice is on the record, in the four lines above, and that is the point of this whole section: a
derived spatial number is never simply "the" answer. It is one defensible way of reading the raw
data, and the value of writing it down in the open, rather than treating ascent_m as some kind
of ground truth, is that anyone reading the code can see exactly which one this project took.
Attribution by buffer¶
"What surface is this route" is the same kind of question as ascent, not a lookup, a measurement, and this project answers it with a technique called buffer-based attribution. The idea: draw a corridor a fixed width either side of the route, find every separately-mapped road segment that falls inside that corridor, and total up the metres per surface. Whichever surfaces have the most mapped metres nearby are, most likely, the surfaces the ride actually crosses.
web/src/Catalog/SurfaceProfiler.php is that technique, in full. The corridor width is
SurfaceProfiler::BUFFER_M, a constant fixed at 25 metres. Its first query is close to English
read aloud: take every served, surface-tagged item on the A layer (item.letter = 'A', "Road
surface", the same OpenStreetMap-derived layer this project already stores for its own reasons)
within ST_DWithin(…, 25) of the route, cast everything to ::geography so 25 means 25 real
metres rather than 25 degrees (chapter 3, metres-vs-degrees.md, is the
reason that cast has to be there at all), intersect each one with the buffered route, sum the
intersected length per surface, and group by attributes->>'surface'.
"WITH route AS (SELECT ST_SetSRID(ST_GeomFromGeoJSON(:geom), 4326) AS g)
SELECT i.attributes->>'surface' AS surface,
SUM(ST_Length(ST_Intersection(i.geom, ST_Buffer((SELECT g FROM route)::geography, :buf)::geometry)::geography)) AS metres
FROM item i
WHERE i.letter = 'A'
One filter matters as much as the buffer itself, and it is one line of the same query:
Rows whose stored surface is that exact string are excluded from the query outright, and from everything the rest of this chapter describes. A segment someone mapped without recording a usable surface says nothing about what is actually underfoot, so it is dropped before it can shift the total either way, a segment that says nothing should not get a vote.
This runs once at intake, RouteProposalService::propose() calls
SurfaceProfiler::profile() on the freshly built geometry and, when it finds anything, stores the
result as attributes.surfaces, an attribute route-domain.md §9 lists as a profile object and
marks "derived, never user-supplied", which is to say no rider or curator can type a value into it.
It also reruns wholesale whenever the underlying A-layer map data changes:
SurfaceProfiler::recomputeAll(), driven by RouteSurfacesCommand (app:catalog:route-surfaces)
or automatically after a harvest import, so a route's surface estimate stays current with the map
under it rather than freezing at the moment it was proposed. A curator desk also has the rider's
own dominantSurface guess sitting next to this derived figure (route-domain.md §9), one
declared, one measured, shown side by side rather than merged into one number.
Two numbers, measured on different sides¶
SurfaceProfiler::profile() doesn't return one number. It returns two, parts and covered, and
the reason it returns two rather than one is the actual lesson of this chapter: they are
deliberately measured on different sides of the same buffer, so that one of them can never
quietly lie by exceeding 100%.
parts is measured segment-side. For each surface, it is the metres of mapped segment
inside the buffer, divided by the total mapped metres found near the route, not the route's own
length. If two separate contributors mapped the same stretch of road twice, both copies get
counted: the numerator for that surface goes up, but so does the shared denominator, by exactly
the same amount. Parallel or duplicate mapping inflates both sides of the fraction equally, which
is exactly why a share can never end up above 100% no matter how much redundant mapping sits near
the route. (There is a second, smaller piece of care here too: only the top four surfaces by
length are kept, and their percentages are rounded by largest-remainder rather than independently,
independent rounding on near-equal shares can push a total just over 100 by itself, so kept shares
are floored first and the leftover integer points handed to the largest fractional remainders. It
is a rounding detail, not the headline lesson, but it is there specifically to protect the same
guarantee.)
covered is measured route-side, against the opposite of a sum: the union of all those
same mapped segments, flattened into one merged shape first (ST_Union), then buffered once. The
question covered asks is "how much of the route falls inside that single merged buffer", and
because the numerator there is a piece of the route's own length and the denominator is the whole
route's length, a portion can never be larger than the whole it is a portion of, no matter how many
overlapping segments contributed to the union. Duplicate mapping cannot push covered past 100%
even once, because by the time the union is taken, mapping the same stretch of road twice looks
identical to mapping it once.
SELECT ST_Length((SELECT g FROM route)::geography) AS route_len,
ST_Length(ST_Intersection(
(SELECT g FROM route),
ST_Buffer((SELECT ST_Union(g) FROM usable)::geography, :buf)::geometry
)::geography) AS covered_len
Set the two queries side by side and the contrast the prose just made is right there in the SQL: the
parts query never mentions the route's own length at all, its SUM runs over intersected
segment metres, and the fraction it feeds is segment-metres over segment-metres. The covered
query's very first line is ST_Length((SELECT g FROM route)::geography) AS route_len, the route's
own length, computed once, sitting in the denominator no matter how the numerator above it moves.
That is the whole "different sides of the same buffer" claim, made structurally rather than argued
in prose.
That is the whole reason covered exists: it is the "how much of this route is even mapped"
honesty figure, shown next to the surface estimate rather than folded into it. A route with
parts: [{"surface": "Asphalt", "pct": 90}] and covered: 12 is not lying, it is saying "of the
little bit of this route we found anything mapped near at all, 90% of that little bit was
asphalt, and that little bit was 12% of the ride." Read parts alone and you would think the
route is confidently asphalt. Read covered alongside it and you know exactly how much confidence
that "confidently" deserves.
parts is a share of mapped metres. On the right, the bracket always spans the entire route, mapped or not, covered is a share of the whole route. Measuring on different sides like this is deliberate, so that neither figure can quietly exceed 100%, and so a route with barely anything mapped near it says so honestly through a low covered, rather than reporting a confident-looking surface split with no disclosed error bar.Routing is a different problem¶
Everything above is about a route that already exists. "Find me a route from A to B" is a
different kind of problem entirely: not a spatial query over stored geometry, but graph search
over a network of road segments with a cost attached to each one (distance, surface, steepness),
looking for the cheapest path through it. This project does not implement that search itself. It
asks Valhalla, an open-source routing engine the project runs on its own tiles, from two narrow
places in web/src/Elevation/: one that snaps two clicked points onto the road between them while
somebody draws a climb, and one that reads ground height under a line for a climb profile.
Both answer null rather than a guess when the engine is unreachable. Neither is a route planner.
Not in the Commons, yet
A rider-facing route planner, "find me a route from A to B" with turn-by-turn directions, is not built. Course 2's routing chapter is the full account: why graph search is not a spatial query, exactly what the one existing call does and does not do, and what a real planner would take. It is not repeated here.
What to carry into chapter 10¶
- A ride is stored as a LineString the same way any GeoJSON LineString is, chapter 2 already covered the shape. What is new here is that lines carry derived numbers a point never needs.
distance_mandascent_mare not facts read off the GPS trace, they are choices about how to read it. This project's ownTrackProcessor::ascentM()makes its choice in the open: sum every uphill step, on the full-density track, with no smoothing.- "What surface is this route" is answered by buffering the route (
SurfaceProfiler::BUFFER_M, 25 m) and totalling the mapped road metres inside it, per surface, excluding anything taggedSurface unverified, because a segment that says nothing should not vote. parts(segment-side, over total mapped metres) andcovered(route-side, over the route's own length against a flattened union) are measured on deliberately different sides of the same buffer, so neither one can quietly exceed 100%, andcovereddoubles as the honest "how much of this is even mapped" figure shown next to the estimate.- The general lesson travels well beyond routes: any derived spatial number is a choice among several defensible ones, and that choice should be visible to whoever reads the result, not buried inside a function that looks like it is reporting a fact.
- Routing between two points is a graph-search problem this project hands to Valhalla, for snapping a climb's two ends to the road and for reading elevation, never something it builds itself. A rider-facing A-to-B planner is not built.
Further reading¶
- The GPX 1.1 schema: the format a rider's upload arrives in.
Try it¶
Hands-on: recompute a stored route's own length
distance_m is a choice baked in at intake, not a live fact, this chapter's whole point.
Recompute one real route's length straight from its stored geometry with ST_Length(geom::geography)
and compare it against the number recommended_route.distance_m already holds for the same row.
docker compose -f developers/docker/compose.yaml exec db psql -U cc -d cyclingcommons -c "
SELECT name, distance_m, round(ST_Length(geom::geography)) AS measured_m,
round(ST_Length(geom::geography)) - distance_m AS diff_m
FROM recommended_route WHERE name = 'Spa · Sankt Vith';
"
name | distance_m | measured_m | diff_m
------------------+------------+------------+--------
Spa · Sankt Vith | 126076 | 126312 | 236
(1 row)
The route is selected by name, not by id: recommended_route ids are assigned as rows are
inserted, so the same ride has a different id on every install. The numbers move too: a fresh
seed carries the fixture's own distance_m of 126,100, and a route that has been re-imported
since carries whatever intake computed that time. What holds is the gap.
distance_m was computed once at intake by TrackProcessor::distanceM()'s haversine sum on a
fixed-radius sphere; ST_Length(geom::geography) recomputes it now, from the stored [lng,
lat] path, on PostGIS's own ellipsoid model (chapter 3, metres-vs-degrees.md).
The two disagree by a couple of hundred metres out of 126 km, under 0.2%, which is the lesson
made concrete: not an error, just two defensible ways of measuring the same curved-earth distance
landing a little apart. Drop the WHERE clause and every row in recommended_route shows the
same small drift, not a wild mismatch: on the eleven routes make course-data seeds the largest
gap is the one above, and a 249 km ride proposed on this install drifts by about a kilometre,
still under half a percent.