River elevation pipeline based on the MERIT hydro dataset.
This is an end-to-end workflow to manually download MERIT-Hydro data, preprocess it locally with GDAL into COGs + VRT mosaics, and run a FastAPI service to query elevations by latitude/longitude.
You need gdalinfo, gdalwarp, gdal_translate, gdaldem, gdalbuildvrt, python3, curl, unzip, and tar available in your PATH.
brew install gdal- Validates required tools and prints versions.
- Creates the full data directory layout under
data/
./scripts/check_deps.sh
./scripts/prepare_dirs.shCanonical local elevation layout after bootstrap:
data/canada/elv/clipped/data/canada/elv/cog/data/mosaic/canada_elv.vrt
Important: MERIT-Hydro downloads require a license/registration. This project does not bypass that gate.
- Register/accept MERIT-Hydro license and obtain download credentials
- Download MERIT archives into
data/raw/downloads/. - By default, the processing scripts use a Quebec-focused bbox (
lon -82..-51,lat 43..63):- N60–N90:
elv_n60w090.tar,elv_n60w060.tar - N30–N60:
elv_n30w090.tar,elv_n30w060.tar
- N60–N90:
- Unpacks archives into shared
data/raw/extracted/. - Finds
.tif/.tiffand symlinks them into shareddata/raw/tifs/. - For performance, applies a filename-based bbox prefilter so obvious non-intersecting tiles are not linked for downstream steps.
./scripts/unpack_and_discover.sh- Clips each input raster to the configured bbox
- Reprojects to EPSG:4326 if needed
- Deletes fully nodata outputs (empty clips)
./scripts/clip_quebec.sh- Converts each clipped raster into a Cloud-Optimized GeoTIFF (COG)
- Skips if the output COG already exists
- COGs use moderate lossless compression (
COMPRESS=ZSTD,LEVEL=9,PREDICTOR=3) and disable overviews (OVERVIEWS=NONE) to reduce storage usage
./scripts/cogify.shBuilds variable-specific mosaics from COGs using gdalbuildvrt:
./scripts/build_vrt.shThe script is incremental: it rebuilds when source COGs are newer than the target VRT. Use FORCE=1 to rebuild unconditionally.
Serve both the API and a Terracotta tile server:
docker compose up --buildMake a request:
curl -H "X-API-Key: dev-local-key" \
-H "Content-Type: application/json" \
-X POST "http://localhost:8000/elevations" \
-d '{"points":[{"lat":46.8139,"lng":-71.2080},{"lat":46.8145,"lng":-71.2050}]}'Example response shape:
{
"version": 1,
"source": {
"generated_at": "2026-02-27T15:04:05Z",
"request_id": "..."
},
"line_length_m": 60.0,
"points": [
{ "chainage_m": 0.0, "elevation_m": 42.1, "status": "ok" },
{ "chainage_m": 60.0, "elevation_m": null, "status": "nodata" }
],
"quality": {
"total": 2,
"ok": 1,
"nodata": 1,
"out_of_coverage": 0,
"coverage_ratio": 0.5
}
}API Endpoints:
- GET
/healthis liveness-only and always returns HTTP 200 with{ "ok": true, "status": "alive" }. DockerHEALTHCHECKprobes this endpoint. - GET
/readyis readiness and returns:- HTTP 200 when DEM is available and
API_KEYis configured. - HTTP 503 when DEM is unavailable or
API_KEYis not configured. - A payload like
{ "ok": false, "dem_ready": true }when only API key configuration is missing.
- HTTP 200 when DEM is available and
- POST
/elevations:- Accepts
{ "points": [ {"lat":..,"lng":..}, ... ] }with zero or more points. - Enforces a maximum batch size from
MAX_BATCH(default1000); larger requests are rejected by request validation. - Returns the same envelope shape with one point entry per input coordinate.
- Out-of-coverage points return
status: "out_of_coverage"in the payload (HTTP 200).
- Accepts
All data endpoints require an API key via the X-API-Key header.
Railway builds the API from Dockerfile.api, mounts the persistent DEM volume at /data, and exposes the public domain for the FastAPI service.
Railway service configuration is versioned in railway.json.
Install the Railway CLI if needed:
brew install railwayAuthenticate and confirm the active workspace:
railway login
railway whoami --jsonLink this repo to the target Railway project/environment/service:
railway link
railway status --jsonAt minimum, set the shared API key:
railway variable set API_KEY="your-secret-key" --service merit-apiIf you are creating the service from scratch, railway.json already declares the core service shape. In Railway, make sure the service also has:
- builder:
DOCKERFILE - dockerfile path:
/Dockerfile.api - volume mounted at
/data - health check path:
/health
Verify the current config:
railway environment config --jsonDeploy the current repo:
railway up --detach -m "Initial merit-api deploy"Then confirm the service is up:
railway service status --service merit-api --json
railway logs --service merit-api --lines 100 --jsonThe API emits structured JSON logs to stdout/stderr so Railway can index custom fields. The canonical fields are level, message, request_id, event, path, status_code, and duration_ms, which makes filters like @level:error, @event:request_completed, and @request_id:<id> usable in Railway Observability.
Package only the files the API needs on the persistent volume. The production VRT expects the canonical layout below inside the mounted volume:
data/mosaic/canada_elv.vrtdata/canada/elv/cog/*.tif
tar \
--exclude='.DS_Store' \
--exclude='._*' \
-czf /tmp/merit-api-data.tar.gz \
-C data \
mosaic/canada_elv.vrt \
canada/elv/cogThis archive is extracted into /data by scripts/railway_import_data.sh, so the tarball should contain:
mosaic/canada_elv.vrtcanada/elv/cog/*.tif
You can inspect the archive before upload:
tar -tzf /tmp/merit-api-data.tar.gz | sed -n '1,40p'scripts/railway_import_data.sh downloads the archive from inside Railway using either:
ARCHIVE_URLfor a single.tar.gzARCHIVE_PART_URLSfor split archive parts
Upload /tmp/merit-api-data.tar.gz to any HTTPS-accessible object store or file host that Railway can reach, then copy the signed/public URL.
Run the import from this repo after the service is linked:
ARCHIVE_URL='https://example.com/merit-api-data.tar.gz' \
./scripts/railway_import_data.shFor large split archives:
ARCHIVE_PART_URLS='https://example.com/part-aa https://example.com/part-ab' \
./scripts/railway_import_data.shThe importer:
- checks archive reachability from Railway
- downloads the archive inside Railway
- extracts it into
/data - preserves the
mosaic/andcanada/paths expected by the API
Check service state and recent logs:
railway service status --service merit-api --json
railway logs --service merit-api --lines 200 --jsonPrimary environment variables:
API_KEY: shared secret forX-API-Key; required for successful/elevationsrequests and for/readyto return HTTP 200DEM_PATH(default/data/mosaic/canada_elv.vrt): path to the elevation VRT mosaic
Optional:
ALLOWED_ORIGINS(default*): comma-separated list of origins for CORSMAX_BATCH(default1000): maximum number of points accepted byPOST /elevationsPORT(default8000in the container): bind port used by gunicorn and the container health checkWEB_CONCURRENCY(default2): gunicorn worker countLOG_LEVEL(defaultinfo)
Import script variables:
ARCHIVE_URL: HTTPS URL for a single.tar.gzarchive to import into RailwayARCHIVE_PART_URLS: space-separated HTTPS URLs for split archive parts; use this instead ofARCHIVE_URLDEST(default/data): extraction destination inside the Railway volumeARCHIVE_PATH(default/tmp/data.tar.gz): temporary archive path inside the Railway container
Processing script overrides:
BBOX_MIN_LON,BBOX_MIN_LAT,BBOX_MAX_LON,BBOX_MAX_LAT: override the default Quebec-focused clip bbox used byscripts/unpack_and_discover.shandscripts/clip_quebec.shPARALLEL(default4): number of parallel clip jobs used byscripts/clip_quebec.shFORCE=1: forces re-extraction inscripts/unpack_and_discover.shand unconditional VRT rebuilds inscripts/build_vrt.sh
Terracotta is configured in this repo for the overlay workflow (/data/overlays/{tile}_{layer}_{band}.vrt). It does not replace the FastAPI elevation API.
docker compose up --build terracottacurl http://127.0.0.1:8080/datasetsExample response for the overlay registry:
{ "datasets": [{ "tile": "mosaic", "layer": "elvhypsometric", "band": "r" }], "limit": 100, "page": 0 }The viewer uses:
http://127.0.0.1:8080/rgb/mosaic/elvhypsometric/{z}/{x}/{y}.png?r=r&g=g&b=b
Run these once before serving overlays:
./scripts/make_hypsometric_overlay.sh
./scripts/build_overlay_band_vrts.shThe repo includes viewer/index.html, a simple static viewer with:
- A basemap for context
- The Terracotta elevation tile layer
- Dataset coverage outlines and aggregate bounds derived from Terracotta dataset tile keys
- A live elevation readout that calls FastAPI
POST /elevationswithX-API-Key - An in-page config panel for API base URL, Terracotta base URL, and API key
- Start FastAPI and Terracotta:
docker compose up --build - Serve the
viewer/folder:
python3 -m http.server 63783 -d viewerOpen: http://localhost:63783/
The viewer supports three configuration sources (highest priority first):
- Query params (
?apiBase=...&terracottaBase=...&apiKey=...) localStorage(saved by the in-page config panel)window.MERIT_CONFIGtemplate object (API_BASE,TERRACOTTA_BASE,API_KEY)
This repo can generate a pre-colored elevation overlay (0–1000m ramp) and serve it via Terracotta, with a Leaflet opacity slider in the viewer.
The viewer requests the overlay via the mosaic dataset:
http://127.0.0.1:8080/rgb/mosaic/elvhypsometric/{z}/{x}/{y}.png?r=r&g=g&b=b
./scripts/make_hypsometric_overlay.shThis writes files like:
data/overlays/n40w060_elvhypsometric.tif
Terracotta's /rgb endpoint expects three datasets (r/g/b). We create lightweight VRTs that expose the overlay's R, G, B bands as separate datasets, both per-tile and as a mosaic dataset.
./scripts/build_overlay_band_vrts.shOutputs (examples):
- Per-tile:
data/overlays/n40w060_elvhypsometric_r.vrt,..._g.vrt,..._b.vrt - Mosaic:
data/overlays/mosaic_elvhypsometric_r.vrt,..._g.vrt,..._b.vrt
Terracotta is configured to serve the per-tile and mosaic band VRTs:
docker compose up --build terracotta- Terracotta key values cannot contain underscores, so the overlay layer key is
elvhypsometric(notelv_hypsometric). - If you change the overlay name or ramp, regenerate the overlays and rebuild the mosaic VRTs.
- If
gdalinfoorgdalwarpfail with projection errors, ensure GDAL is installed with PROJ data and environment variables are set properly for your system.
If a clipped raster is fully nodata, it is deleted automatically. This can happen if your bbox does not intersect a tile.
The VRT references the COG file paths at build time. If you move or delete COGs, or the API runs in Docker with /data mounted, rebuild the VRT using:
./scripts/build_vrt.shMERIT-Hydro tiles can be large. The workflow stores:
- Raw downloads
- Extracted data
- Clipped tiles
- COGs
After you validate your VRTs and API, you can consider deleting intermediate clips to save space, keeping only:
data/canada/elv/cog/data/mosaic/canada_elv.vrt
Use this helper to remove intermediate files in one step:
./scripts/cleanup_intermediate_data.shIt removes:
data/raw/extracted/*data/raw/tifs/*data/canada/elv/clipped/*
- Build a tile index to route queries to a single COG instead of a VRT for faster I/O.
- Swap MERIT-Hydro for higher-resolution HRDEM tiles where available.
Dockerfile.terracottapins Terracotta to a specific version for reproducible builds.- Recommended update cadence: review and bump pinned service/runtime dependencies monthly or during planned release windows.
Contract tests cover readiness semantics, auth, batch response shape, out-of-bounds handling, and endpoint removal behavior.
python3 -m pip install -r api/requirements-dev.txt
python3 -m unittest discover -s tests -v