Elevation: what a DEM is, and what it gets wrong¶
Before any pipeline, the ground truth: what a digital elevation model actually holds, why a finer grid is not a more accurate one, and why the trees are in the data. Nothing here is operational. It is the reasoning every decision on the next two pages rests on, and it is the half worth reading even if you never run a build.
What this project runs today
Copernicus GLO-30 is the source, worldwide: 30 m cells, served from
Valhalla's /height, so the gradients a rider sees come from it.
The GLO-90 measurements below are of a coarser product this project does not serve. They are kept because they are the clearest lesson available in why resolution is not accuracy, and because the failure was measured on our own climbs rather than borrowed from a textbook.
Course 2's Elevation and terrain covers the same
subject for a reader who wants the concept and not the operation. This page goes
one level deeper, because an operator has to know exactly how the data lies to
them. The design record is
climb-elevation.md;
the tool reference is
tools/elevation/README.md.
Three pages cover elevation, in order:
- This one: what a DEM is, resolution against accuracy, the trees, the file format.
- Building elevation tiles: the pipeline, proving a source, installing it, and onboarding a region end to end.
- Measuring a climb: turning installed tiles into a published gradient, and the faults no source can fix.
Why this exists: a number nobody measured¶
The Côte de la Redoute was published as "2.0 km · 8.4% avg". That string was typed into a JavaScript literal while the map was a static prototype and carried into the seed command unchanged, in the same commit whose comment says to omit any attribute we cannot verify.
When three independent sources were finally asked, they said ~9.0%. The published figure was not a rounding difference or a measurement dispute. Nobody had ever measured it.
That is the failure this pipeline exists to prevent, and it explains the order of everything below: build the measurement first, draw the chart last. A beautiful profile chart over unmeasured numbers is worse than no chart, because it makes a fabrication look like a survey.
A DEM does not know where the road is¶
A DEM never produces coordinates. It has no idea a road exists.
It is a lookup table. You hand it a latitude and longitude, it returns a height. The trajectory, where the climb actually goes, comes from a routing engine, and the two are completely independent:
rider taps foot + summit
↓ routing engine (this decides WHERE)
a polyline following the road
↓ resample to a fixed count
200 points, evenly spaced along the line
↓ DEM lookup (this decides HOW HIGH)
one elevation per point
↓
bins → the profile chart
The count is fixed, not the spacing (ClimbProfiler::SAMPLES), so a longer
climb is sampled more coarsely: 200 points over 26 km is one every 130 m. That
detail looks like an obvious suspect later in this page, and it turns out to be
innocent.
That independence is useful: a profile can be recomputed against a better DEM without re-routing, and a redrawn line gets a new profile without changing sources.
It also means a DEM cannot rescue a bad line. If the route is wrong, better elevation data produces a more precise wrong answer.
Resolution is not accuracy¶
The obvious question is "how many metres per cell?", and the obvious answer misleads.
Copernicus GLO-90, the 90 m product, is the demonstration. Sampled every 25 m along La Redoute it returned 30 distinct values across 99 samples, with runs of seven identical readings and eight samples going downhill on a climb that never descends. Binned at 100 m it published a 10% descent through the middle of the climb.
That is not noise. It is asking a grid a question finer than its cells, and getting the grid's shape back instead of the road's. The rule that falls out:
A bin is never narrower than about four DEM cells.
At 90 m cells that means bins no narrower than 360 m, which is why GLO-90 cannot draw the 100 m bars we want, at any level of cleverness. GLO-30's 30 m cells clear the same rule at 120 m, which is what makes the profile actually published honest.
The corollary matters just as much: a finer raster does not automatically buy a better answer. When EU-DEM v1 (1 arc-second) was compared against EU-DEM v1.1 (25 m), they agreed to 0.54 of a percentage point per bin and both landed within 1.2 m of the reference gain. The finer grid bought smoothness, not truth.
The trees are in the data¶
EU-DEM and Copernicus DEM are both Digital Surface Models. EU-DEM's own readme describes it as "the first surface as illuminated by the sensors".
That means canopy and buildings, not bare ground. On a wooded Ardennes climb, a reading over a tree-lined stretch is partly the trees.
This is not a defect you fix by choosing a different version, because the alternatives are DSMs too. It is a permanent error term, and it is the most likely explanation when two good sources disagree locally while agreeing on the total, exactly the pattern measured here: 2.02 points of per-bin disagreement between EU-DEM and GLO-30, but gains within 2 m of each other.
A DTM (digital terrain model) is the bare-earth counterpart. Where one is available at useful resolution it is the better input for road gradients.
What the trees cost, measured¶
The section above is the theory. Here is what it did to a real climb.
Côte de Stockeu is wooded and steep. Our figure for its steepest 100 m, from Copernicus GLO-30, was 27%. Another source put the same climb at 19%. The averages agreed almost exactly, 10.2% against 9.9%, which is the clue: an error that cancels over a whole climb but not over its worst hundred metres is not random noise, it is something concentrated in one place.
Wallonia publishes a 50 cm LiDAR terrain model, bare earth, vegetation and buildings removed. Sampled along the same road:
| average | steepest 100 m | |
|---|---|---|
| Copernicus GLO-30 (surface model) | 10.2% | 27% |
| Wallonia 50 cm LiDAR (terrain model) | 9.8% | 16.7% |
| another source | 9.9% | 19% |
And on the exact stretch GLO-30 called 27%, the bare earth is unremarkable:
That is a steady ~11%. GLO-30 read a 27 m rise across 100 m where the ground rises 9 m. The difference is the tree canopy: a surface model measures the top of the forest, and where the canopy thickens going up a hill it manufactures a gradient that no rider will ever feel.
Two things worth taking from this.
The maximum is far more fragile than the average. A sliding maximum searches every position and keeps the largest reading, so it selects for wherever the error happened to be worst, the one statistic guaranteed to find the canopy. Averages let errors cancel; maxima accumulate them by construction. When two sources agree on a climb's average and disagree on its maximum, suspect the maximum, not the average.
It pointed at the wrong road, not just the wrong number. The LiDAR's steepest stretch is at 550 m; GLO-30's was at 850 m. A profile can be wrong about where as well as how much, and a marker on a map makes that visible in a way a table of numbers does not.
You can check this without downloading anything. The Wallonia model is served
as an ArcGIS MapServer with query enabled, so a single HTTP request returns the
elevation at one coordinate, ninety-three of them profiled the whole climb in
about thirty seconds. That is enough to test a hypothesis about a source, which
is a different job from serving a catalogue and needs none of the storage.
Two formats, and a lesson hiding in one of them¶
Copernicus ships Cloud Optimized GeoTIFF. Valhalla's elevation service
reads .hgt: a raw grid of big-endian int16 metres, 3601×3601 per 1°
tile, at 1 arc-second in both axes. So a conversion is unavoidable.
Inspecting a GLO-30 tile is where it gets interesting:
2400 columns, not 3600. The tile is decimated in longitude, 1.5 arc-seconds across, 1 arc-second down.
That is not a defect. Meridians converge toward the poles, so at 50°N one arc-
second of longitude is only about 20 m of ground while one of latitude is about
31 m. By sampling longitude at 1.5″, Copernicus keeps its cells roughly square,
about 30 m each way. It is the sensible choice, and .hgt cannot express it,
being 1″ by definition.
2400 × 3600 rather than 3601 × 3601. The .hgt format is 1″ both ways by definition, so conversion upsamples longitude back to 3600 columns: no information added, none destroyed. Two consequences: the decimation factor changes with latitude band, so a converter that hard-codes 2400 is right in Belgium and wrong in Norway; and every .hgt tile is 24.7 MB whatever it really carries.So the conversion upsamples longitude onto the uniform grid. It adds no information and destroys none, which is why the converted data measures just as well. But two things follow:
- The decimation factor changes with latitude band. A converter that hard-codes 2400 columns is correct in Belgium and wrong in Norway. Read each tile's actual size; let GDAL resample to the target grid.
.hgttiles are a fixed 24.7 MB (3601 × 3601 × 2 bytes) regardless of how much information they carry. Storage is a function of area, not detail.
Reading a .hgt tile¶
Worth knowing, because it demystifies the format entirely. Tiles are named for
their south-west corner, N50E005.hgt covers 50–51°N, 5–6°E. Row 0 is the
north edge, column 0 the west edge.
The lookup is arithmetic:
const r = (latSW + 1 - lat) * 3600, c = (lon - lonSW) * 3600;
const r0 = Math.floor(r), c0 = Math.floor(c), dr = r - r0, dc = c - c0;
The integer part of a coordinate picks the tile, the fractional part indexes the grid. The service then interpolates between the four surrounding cells: it does not snap to the nearest one.
You can see the interpolation from outside: walking 60 m in 2 m steps returns values that change every ~14 m on a 6.7% slope. The run length tracks the gradient, not the 30 m cell size, which snapping could never produce.
But the reply is integer metres, and that is a second, independent reason for the four-cell bin rule. Rounding is ±0.5 m per reading no matter how good the raster is. Over a 100 m bin at 9%, 9 m of rise, that is ±0.5 of a point, tolerable. Over a 20 m bin it would be ±2.5 points, and the bar would be mostly rounding error.
Try it¶
Hands-on: open one tile and read the two numbers this page turns on
A .hgt file has no header. Its size is its resolution, which is the whole
reason the arithmetic above works, and you can confirm that with ls before
you confirm anything else.
ls -l "$DEM_DIR"/N46E008.hgt
python3 -c "import os,math; b=os.path.getsize('N46E008.hgt'); n=int(math.isqrt(b//2)); print(b,'bytes ->',n,'x',n,'samples')"
3601 rather than 3600 because the edges are shared with the neighbouring tile, and 25,934,402 bytes because every sample is two bytes. That number is on the numbers page, derived rather than typed, and every storage estimate on the next page is that one figure multiplied.
Now the point this page exists to make. Divide the tile's own cell size into the window a gradient is measured over:
That floor is why the published gradient window is 250 m and not the conventional 100 m. A 100 m window over a 30 m grid asks for a figure across barely three cells, which is a question finer than the data can answer, and the third page in this series is the story of what happens when you ask it anyway.