Vai al contenuto principale
riferimento · 11 min di lettura

Terrain elevation (SRTM / HGT)

we-fly does not serve terrain over the API: this is the public dataset it uses, the exact HGT decoding, and the conventions needed to reproduce our groundAltitude and AGL numbers.

we-fly does not serve terrain over the partner API. There is no /api/v1/terrain, no DEM endpoint, no elevation lookup, and no scope that would grant one.

That is deliberate rather than an omission: the terrain under a we-fly flight comes from a public dataset that costs us nothing and is equally free to you. Proxying it would only add our bandwidth and our latency to a file you can fetch directly. This page documents that source, the exact decoding we do, and the conventions we apply, so that a third-party client can produce groundAltitude and agl numbers identical to the ones stored on a we-fly flight rather than merely similar ones.

If you need terrain as a we-fly endpoint anyway (an instrument that cannot reach anything but our host, say), raise it and we will discuss it.


The source

AWS Open Data: Terrain Tiles (the Tilezen/Mapzen joerd dataset), skadi format:

https://elevation-tiles-prod.s3.amazonaws.com/skadi/{latPrefix}/{tile}.hgt.gz
https://elevation-tiles-prod.s3.amazonaws.com/skadi/N45/N45E006.hgt.gz
https://elevation-tiles-prod.s3.amazonaws.com/skadi/S13/S13W078.hgt.gz
  • Anonymous HTTPS GET. No API key, no AWS credentials, no request signing, not requester-pays. curl is a complete client.
  • Range requests are supported (Accept-Ranges: bytes), which matters if you want a handful of samples rather than a whole tile: see Fetching less than a whole tile.
  • The dataset is static. Objects carry a 2016 Last-Modified and have not moved since. Cache a tile forever; there is no invalidation story to build. The flip side is that it will not improve either: treat it as a fixed reference surface, not a living survey.
  • Registry entry, licence and provenance: https://registry.opendata.aws/terrain-tiles/

Compressed sizes vary enormously with terrain roughness; the decompressed size never does. Measured 2026-08-28:

TileRegion.hgt.gz.hgt
N45E006French/Italian Alps17.4 MB25.9 MB
S13W078Peruvian coast3.2 MB25.9 MB
N30W040mid-Atlantic, open ocean3.1 MB25.9 MB
N70E020Arctic Norway2.1 MB25.9 MB
N85E020high Arctic1.8 MB25.9 MB

Budget for 25.9 MB per tile in memory or on disk, always. A tile covers 1° × 1°, so a 200 km XC corridor in the Alps typically spans four to six of them.


Tile naming

Tiles are named for their south-west corner, which is the floor() of the coordinate, not its truncation. This is the classic implementation bug: in the southern and western hemispheres, Math.trunc(-12.3) gives -12 and yields S12, but the correct tile is S13.

function getTileName(lat: number, lng: number): string {
  const latInt = Math.floor(lat);
  const lngInt = Math.floor(lng);

  const latPrefix = latInt >= 0 ? "N" : "S";
  const lngPrefix = lngInt >= 0 ? "E" : "W";

  const latStr = Math.abs(latInt).toString().padStart(2, "0");
  const lngStr = Math.abs(lngInt).toString().padStart(3, "0");

  return `${latPrefix}${latStr}${lngPrefix}${lngStr}`;
}

getTileName(45.5, 6.3);    // "N45E006"
getTileName(-12.3, -77.5); // "S13W078"

Latitude is zero-padded to two digits, longitude to three. The URL's directory component is simply the first three characters of the tile name (N45E006 → N45).

A missing tile answers 404. Treat that as "no terrain here", not as an error worth retrying, though note that skadi coverage is global, so in practice you should rarely see one.


What is inside a tile

gunzip yields a raw .hgt file (no header, no trailer, no metadata):

  • 25,934,402 bytes = 3601 × 3601 samples × 2 bytes. This is SRTM-1 geometry: 1 arc-second spacing, one sample per ~30 m at the equator.
  • 16-bit signed integers, big-endian (Motorola byte order).
  • Row-major, north to south. The first row in the file is the northern edge of the tile, so a south-west-origin row index has to be flipped.
  • Columns run west to east.
  • Unprojected WGS84 (EPSG:4326): plain lat/lng degrees, no reprojection.
  • Elevations are in metres referenced to the EGM96 geoid, i.e. mean sea level, not the WGS84 ellipsoid. See Vertical datum below.
  • -32768 marks a void. Skadi is largely void-filled, but handle the sentinel: reading it as a number gives you a 32 km hole in the ground.

The classic 1201 × 1201 SRTM-3 (3 arc-second, ~90 m) layout is 2,884,802 bytes. Skadi does not serve it, but if you also ingest tiles from other providers you will meet it, and the only difference is the sample count: our own reader auto-detects the grid from the file size.

Every tile is 3601², but not every tile is 30 m data

The skadi grid is uniform; the information in it is not. The dataset is composited from whatever open source is best for a given area: SRTM on land within roughly ±60° latitude, EU-DEM and various national DTMs over Europe, GMTED and ArcticDEM at high latitudes, ETOPO1 over water. Coarser sources are resampled up to the 3601² grid.

So a tile at 70°N has the same shape as a tile in the Alps and far less real detail. Do not present the grid spacing as an accuracy figure to pilots. Provenance per source region is documented at https://github.com/tilezen/joerd/blob/master/docs/data-sources.md.

Ocean tiles contain bathymetry, not zero

This one surprises people. Over water, skadi carries ETOPO1 sea-floor depth:

N30W040 (mid-Atlantic): min -4264 m, max -2599 m, 0 voids

A naïve agl = altitude - ground over the sea therefore reports a pilot several kilometres higher than they are. we-fly does not clamp this: our stored groundAltitude over water is the seabed, faithfully. If your product shows AGL over water, clamp at 0 yourself, and clamp for display only; do not write the clamped value back as terrain.


Reading a value

Convert the coordinate to a fractional row/column inside the tile, then interpolate. Note that a 3601-sample grid spans the degree inclusively ( the last row of N45E006 is the same ground as the first row of N46E006), so size - 1 (3600) is the divisor, and a coordinate exactly on a tile boundary resolves identically in either neighbour.

// swLat / swLng are the tile's south-west corner (45, 6 for N45E006)
// size is 3601
const n = size - 1;                    // 3600 intervals across the degree
const row = (lat - swLat) * n;         // 0 at the south edge
const col = (lng - swLng) * n;         // 0 at the west edge
if (row < 0 || col < 0 || row > n || col > n) return undefined;

Reading a single sample, with the north-to-south row flip:

function readSample(buf: Buffer, size: number, row: number, col: number) {
  const offset = ((size - row - 1) * size + col) * 2;
  return buf.readInt16BE(offset);   // -32768 === void
}

Nearest-neighbour sampling produces visible 30 m staircases on a barogram, so we bilinearly interpolate the four surrounding samples:

const row0 = Math.floor(row), col0 = Math.floor(col);
const rowFrac = row - row0, colFrac = col - col0;
const row1 = Math.min(row0 + 1, size - 1);
const col1 = Math.min(col0 + 1, size - 1);

const e00 = readSample(buf, size, row0, col0);
const e10 = readSample(buf, size, row1, col0);
const e01 = readSample(buf, size, row0, col1);
const e11 = readSample(buf, size, row1, col1);

// Never interpolate across a void: it would drag the result toward -32768.
// Fall back to any non-void neighbour, and give up only if all four are void.
if ([e00, e10, e01, e11].includes(-32768)) {
  for (const e of [e00, e10, e01, e11]) if (e !== -32768) return e;
  return undefined;
}

const e0 = e00 * (1 - colFrac) + e01 * colFrac;
const e1 = e10 * (1 - colFrac) + e11 * colFrac;
return e0 * (1 - rowFrac) + e1 * rowFrac;

That is exactly what we run, so a client following it lands on our numbers to the last decimal. we-fly stores the interpolated value unrounded.

Fetching less than a whole tile

The objects are gzip-compressed as a single member, so a byte range of the .hgt.gz is not independently decompressible: you cannot seek to one sample without streaming from the start. If you only need a few points, stream the gzip and stop early, or accept the full download once and cache it. If you need terrain for rendering rather than lookup, the same bucket also publishes terrarium PNG and geotiff formats in Web Mercator, which tile far better for that job: https://github.com/tilezen/joerd/blob/master/docs/formats.md


Matching we-fly's numbers exactly

AGL is computed from pressure altitude

agl = fix.altitude - groundAltitude

fix.altitude is the barometric/pressure altitude from the IGC B-record, not gpsalt. If you compute AGL from GPS altitude you will get a different ( and not obviously worse) answer, but it will not match ours.

The reasoning is that baro altitude is the smoother, more self-consistent signal for a vertical difference over a single flight, and it is the one every other vertical statistic in we-fly is derived from. Its absolute datum drifts with the day's QNH, which is a real weakness; GPS altitude's noise is a bigger one for this purpose.

Vertical datum: the one that bites

Skadi elevations are orthometric, EGM96 geoid. GNSS receivers natively produce ellipsoidal WGS84 heights, and the geoid-ellipsoid separation reaches roughly ±100 m worldwide (about +47 m over the western Alps). Whether that offset is present in a given IGC file depends on the recorder: some emit ellipsoidal, some apply a geoid model, few say which.

we-fly does not attempt to correct for it, because we take AGL from pressure altitude, which sidesteps the question. If you go via GPS altitude, this offset is your problem to identify and handle, and it is large enough to matter to a pilot judging clearance.

Rounding and caching granularity

we-fly caches elevation lookups keyed to three decimal places of latitude and longitude (~111 m), for 30 days. Two fixes 50 m apart therefore share a cached elevation in our storage. The interpolation itself is full-precision; the quantisation is in the cache key only, and it is the reason our stored groundAltitude for two nearby fixes is sometimes identical.


Running it at scale

What our pipeline does, offered as a working shape rather than a prescription:

  • On-demand tile fetch. We download a tile the first time a flight needs it, never up front. Concurrency capped at 3, 30 s timeout per tile.
  • Stream, decompress, atomic rename. Gunzip in the pipeline to a .tmp path, then rename() into place, so a killed process never leaves a half-tile that later reads as valid terrain.
  • Sharded on disk as {SRTM_DIR}/N45/N45E006.hgt: a flat directory of global tiles is unpleasant to work with.
  • LRU tile cache in memory, 20 tiles ≈ 500 MB resident. Tiles are read whole; the buffer is explicitly released on eviction.
  • Redis in front, keyed as above, 30-day TTL. A 3000-fix flight collapses to 50-300 unique keys, fetched in one MGET.
  • De-duplicate in-flight downloads. Without it, one flight crossing a new tile starts the same 25 MB download from every concurrent fix batch.

For an instrument, the shape is different and simpler: work out the tiles under your planned task on the ground, fetch them over wifi, and keep them. There is nothing to refresh.


Attribution

Terrain Tiles composite many providers, several of which require attribution. You are responsible for checking the terms of the sources covering your area of operation; the dataset's own required text is:

* ArcticDEM terrain data DEM(s) were created from DigitalGlobe, Inc., imagery and
  funded under National Science Foundation awards 1043681, 1559691, and 1542736;
* Australia terrain data © Commonwealth of Australia (Geoscience Australia) 2017;
* Austria terrain data © offene Daten Österreichs – Digitales Geländemodell (DGM)
  Österreich;
* Canada terrain data contains information licensed under the Open Government
  Licence – Canada;
* Europe terrain data produced using Copernicus data and information funded by the
  European Union - EU-DEM layers;
* Global ETOPO1 terrain data U.S. National Oceanic and Atmospheric Administration
* Mexico terrain data source: INEGI, Continental relief, 2016;
* New Zealand terrain data Copyright 2011 Crown copyright (c) Land Information New
  Zealand and the New Zealand Government (All rights reserved);
* Norway terrain data © Kartverket;
* United Kingdom terrain data © Environment Agency copyright and/or database right
  2015. All rights reserved;
* United States 3DEP (formerly NED) and global GMTED2010 and SRTM terrain data
  courtesy of the U.S. Geological Survey.

Source: https://github.com/tilezen/joerd/blob/master/docs/attribution.md


Not a navigation surface

Terrain here is an analysis input: for AGL traces, low-save detection, ground proximity in flight statistics. It is a 2016 composite of mixed-resolution sources on a geoid datum, and it knows nothing about obstacles, cables, masts or trees. Do not build terrain-avoidance warnings on it and present them as authoritative, and do not let it stand in for airspace clearance: see airspace for that.