Skip to content

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:

  1. This one: what a DEM is, resolution against accuracy, the trees, the file format.
  2. Building elevation tiles: the pipeline, proving a source, installing it, and onboarding a region end to end.
  3. 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.

The same climb measured by a reference source and by a 90 metre elevation modelTwo elevation profiles of the Cote de la Redoute drawn from real measurements, in one hundred metre bins. The accent line is the reference profile from another source, rising continuously from foot to summit. The stepped line is what Copernicus GLO-90, a ninety metre model, returns for the identical road: it follows the general shape but in flat jumps, and in two bins it goes down instead of up. Those two descending bins are tinted, and the larger reports minus ten percent through the middle of a climb that never descends, bracketed by a twenty-five percent bin and a zero percent bin. The model is not slightly noisy here; it is reporting terrain that is not there. Same road, two measurements −10% foot summit the roadwhat GLO-90 returnsbins it reports as DOWNHILL
Both lines measure the same road. The accent line is the reference profile; the stepped line is Copernicus GLO-90, which stores one elevation per 90 m cell and so answers in flat jumps. Follow it through the middle: 25%, then 0%, then −10%, a ten-percent descent on a climb that never descends, with a wall on one side and a flat on the other. None of that is on the road. This is why a bin is never narrower than about four DEM cells: ask a grid a question finer than its cells and it answers with its own shape.

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.

A surface model follows the treetops, not the road beneath themA cross-section of a climb running through woodland. The accent line along the bottom is the road, rising gently and steadily. A stand of trees sits over the middle section. The muted dashed line above is what a digital surface model records: it tracks the road where the road is open, jumps up to follow the canopy through the wooded stretch, then drops back when the trees end. Through that section the elevation it reports is the height of the treetops, tens of metres above the tarmac a rider is actually on. Both EU-DEM and Copernicus DEM are surface models, so this error cannot be removed by choosing between them. What the model measures, not what you ride model reads HERE you ride HERE the road, a DTM follows thiswhat a DSM records
A DSM (digital surface model) records "the first surface as illuminated by the sensors", EU-DEM's own words. Through woodland that surface is the canopy, not the tarmac, so readings over a tree-lined stretch are partly the trees. A DTM (terrain model) is the bare-earth counterpart and would follow the road, but none is available worldwide at useful resolution, and both EU-DEM and Copernicus DEM are surface models. So this is not a defect to fix by choosing a different dataset; it is a permanent error term, and the likeliest explanation whenever two good sources agree on a climb's total gain and disagree over one stretch of it.

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:

780m 381.07   840m 388.88   900m 395.15   960m 401.88
800m 384.19   860m 390.67   920m 398.02   980m 403.79

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:

Size is 2400, 3600
Pixel Size = (0.000416666666667,-0.000277777777778)

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.

Why a Copernicus tile is 2400 columns wide instead of 3600Two grids compared at fifty degrees north. On the left, a grid sampled one arc-second in both directions: because a degree of longitude is worth only about two thirds of a degree of latitude here, its ground cells come out as tall narrow rectangles, roughly twenty metres wide by thirty-one metres high. On the right, the same ground sampled one and a half arc-seconds in longitude and one in latitude: fewer columns, but cells close to square at about thirty metres each way. That is what Copernicus ships, and it is why a one degree tile at this latitude measures 2400 columns by 3600 rows. The SRTM hgt format is one arc-second in both directions by definition, so converting upsamples longitude back to 3600 columns, adding no information and destroying none. At 50° N, 1″ is not the same both ways 1″ × 1″square in ANGLE ≈20 m × ≈31 m oversampled E–W 1.5″ × 1″square on GROUND ≈30 m × ≈30 m Copernicus: 2400 × 3600 Fewer columns is not less detail.
Meridians converge toward the poles, so at 50° N one arc-second of longitude buys about 20 m of ground while one of latitude buys about 31 m. Sampling both axes at 1″ therefore gives tall, thin cells, oversampled east–west. Copernicus samples longitude at 1.5″ instead, keeping the ground cell roughly square, which is why the tile covering La Redoute measures 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.
  • .hgt tiles 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')"

25934402 bytes -> 3601 x 3601 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:

one degree of latitude    ~111,320 m
3600 cells across         ~30.9 m per cell
four-cell floor           ~124 m

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.