Netherlands terrain¶
bag3d-terrain generates the Netherlands terrain used by the 3DBAG viewer:
AHN5 + AHN6 5 m DTM, neighbor-aware nodata interpolation over 500 ground
meters, ellipsoidal GLO-30 fallback for larger water gaps, and 512 px
Terrarium lossless WebP XYZ tiles at zooms 0–14, accompanied by TileJSON.
PMTiles bundles are retained as intermediate outputs.
The workflow has its own environment and code location. It needs no building reconstruction, point clouds, database, running cache downloader, sibling Mapterhorn checkout, or serving infrastructure.
Run in the Docker development UI¶
From the repository root, run:
make docker_dev
This builds the bag3d-terrain code-server image and registers the terrain
code location in the Docker workspace. Refresh the UI after Compose finishes.
The location exposes the terrain_download, terrain_process and terrain
jobs and the terrain asset group. The development overlay mounts its source
directory like the other workflow packages.
The webserver and daemon mount the repository workspace file over the persistent
Dagster-home volume, so existing volumes cannot hide a newly added location.
No volume recreation is needed. For troubleshooting, inspect the bag3d-terrain
service logs and the location status under Deployment / Code locations.
Install and run locally without Docker¶
Use Python 3.12 or later and uv. Install GDAL command-line tools with the COG
driver and LERC codec (gdalbuildvrt, gdalwarp, gdal_translate). Rasterio and
PyProj wheels supply their own Python-side native libraries. On NixOS those
wheels also need the compiler runtime, zlib and Expat available through the
development shell's LD_LIBRARY_PATH.
From this directory:
uv sync --frozen
uv run dg check defs
uv run dg list defs --json
Create a persistent local Dagster instance and an absolute file-store directory. Keep the same instance and file store for acquisition and processing so Dagster can reload the upstream manifest paths through its filesystem IO manager:
mkdir -p /tmp/terrain-dagster
export DAGSTER_HOME=/tmp/terrain-dagster
Create a run configuration, for example /tmp/terrain-run.yaml:
resources:
file_store:
config:
root_dir: /absolute/path/to/terrain-data
terrain:
config:
# Omit bounds for the complete Netherlands dataset.
bounds: [4.35, 52.0, 4.38, 52.025]
interpolation_radius_m: 500
workers: 4
base_url: http://localhost:8001
# Optional if GDAL is not on PATH:
# gdal_bin_dir: /absolute/path/to/gdal/bin
Start with a small bounding box, then omit bounds for a national run:
uv run dg launch --job terrain_download --config-file /tmp/terrain-run.yaml
uv run dg launch --job terrain_process --config-file /tmp/terrain-run.yaml
# Or run acquisition and processing together:
uv run dg launch --job terrain --config-file /tmp/terrain-run.yaml
The launchpad in uv run dg dev exposes the same jobs and settings. The shared
local workspace also includes bag3d.terrain.code_location. BAG3D_FILESTORE
can supply the file-store root instead of run configuration.
The final materialization reports a manifest under
stages/terrain/builds/<content-id>/terrain_xyz/manifest.json. The neighboring
xyz/ directory contains tilejson.json and {z}/{x}/{y}.webp. The base URL is
metadata for the eventual HTTP server; this package does not start or configure
a server. The viewer should use Terrarium encoding and the generated TileJSON.
Asset flow¶
All keys have the terrain/ prefix:
ahn_catalog -> ahn_download -> ahn_rasters -> ahn_filled -> ahn_ellipsoid --+
| ^ |
+-> glo30_download -> glo30_ellipsoid -|--------------+
^ | |
proj_grids_download ----------------------------+-------+ |
v
aggregation_plan -> terrain_tiles -> terrain_overviews -> terrain_bundles
|
v
terrain_xyz
The four acquisition assets are the only network users. They save paginated AHN metadata, raw AHN rasters, regional GLO-30 rasters and these mandatory grids:
nl_nsgi_rdtrans2018.tifnl_nsgi_nlgeo2018.tifus_nga_egm08_25.tif
AHN5 and AHN6 item endpoints are resolved from their current collection listings,
because the provider replaces layer UUIDs. An empty feature page ends pagination
even if the API includes a next link back to the beginning; nonempty pagination
cycles still fail rather than publishing an incomplete catalog.
Entries with an empty or null 5 m DTM URL are skipped with a warning: AHN6 can
list a tile's point cloud before publishing its DTM. Pagination continues past
these entries. Malformed URLs and selections with no available rasters still fail.
Bounding-box selection includes neighboring AHN inputs for interpolation and source transitions. The output extent is the requested box, or the AHN source bounds for national processing; it never expands to the Copernicus footprint.
Downloads are validated, content-addressed and revalidated using HTTP ETag or
Last-Modified headers where provided. Partial downloads are never published.
The bundled regional GLO-30 inventory is a snapshot of Mapterhorn's source
catalog for [0,49,10,56]. Omitted entries in this region identify ocean cells;
network errors are failures, never evidence of ocean. Update the inventory and
its provenance deliberately when the source catalog changes.
Processing preserves the local Mapterhorn behavior:
- Keep valid AHN measurements during interpolation, within the 1 mm COG compression tolerance. Remaining gaps use GLO-30.
- Convert NAP using RDNAPTRANS2018/NLGEO2018 on the native RD grid. Record the ellipsoidal band reference explicitly; assigning a horizontal CRS is not a height conversion. Reject already-converted AHN input.
- Convert GLO-30 EGM2008 heights, including zero-EGM2008 ocean cells, before merging. Both inputs must be ellipsoidal. The AHN conversion follows the existing ETRS89/WGS84 ensemble approximation, without epoch adjustment.
- Mosaic before cubic-spline reprojection onto aligned Web Mercator grids; prefer AHN and blend transitions to the fallback.
- Preserve Mapterhorn's zoom-dependent elevation rounding, 2×2 averaging of decoded heights, and decoded-height interpolation for missing static levels. Do not interpolate encoded RGB channels or apply another viewer geoid offset.
- Fail on unresolved nodata inside the output domain instead of silently encoding ellipsoidal zero. Full XYZ cells can extend beyond the advertised box.
Storage, checkpoints and concurrency¶
Downloads live under stages/terrain/downloads/. Generated stages have separate
content-based directories under stages/terrain/builds/. Assets exchange small
JSON manifest paths, not arrays. Manifests retain content hashes, source URLs,
spatial bounds and height references. Each stage reports counts, bytes and
relevant coverage metadata to Dagster.
Input hashes, settings, processing code and native library versions determine stage identity. A failed work unit resumes from its last verified checkpoint; missing or changed outputs are rebuilt. Source, radius or code changes produce separate stages, avoiding contamination from earlier outputs. Changing the worker count does not invalidate calculations; changing the base URL rewrites TileJSON while retaining identical terrain tiles.
Pools default to four workers and GDAL uses one processing thread per worker. Aggregation areas follow Mapterhorn's maximum 64×64 terrain-tile dimensions; allow several GiB per worker and put intermediate rasters on SSD. Stage-level assets have local work-unit checkpoints rather than one Dagster partition per source tile. Concurrent writes to the same checkpoint/export fail promptly on file locks. Old completed builds are retained; remove them explicitly when no longer needed.
Validation¶
uv run pytest
uv run ruff check src tests
uv run ruff format --check src tests
uv run pyright
uv run dg check defs
An opt-in acceptance test runs all thirteen assets using small synthetic AHN and GLO rasters, real local transformation grids, and GDAL. It then reruns the processing job with requests blocked, verifies water fallback and the complete XYZ coverage, and checks that changing the URL leaves tile files untouched:
TERRAIN_TEST_GRID_DIR=/absolute/path/to/grids uv run pytest -m integration
For national acceptance, run acquisition and processing without bounds.
Compare decoded heights at IJsselmeer (5.5,52.7), Markermeer (5.2,52.5) and
Haringvliet (4.2,51.77) against the previous terrain generated from the same
inputs. The original local rebuild recorded approximately 42.625, 42.875 and
44.375 ellipsoidal meters at zoom 14. These are historical inspection samples,
not fixed truths for changing AHN inputs. Inspect representative source
boundaries and water transitions in the viewer, and record runtime and disk use.
Imported code and maintenance¶
Selected Mapterhorn code lives in _mapterhorn/, with its BSD-3-Clause license.
MAPTERHORN_PROVENANCE.json records the baseline commit, hashes of the original
files, selected functions and local uncommitted inputs. It also covers scripts
used as references when replacing the filesystem queue and CLI orchestration.
The internal terrain engine replaces those wrappers with explicit paths, local file access, content checkpoints, public PMTiles writer APIs and Dagster assets. It carries no runtime Mapterhorn dependency. Review and port upstream fixes explicitly; keep algorithm changes separate from orchestration changes.
AHN and Copernicus data license notices are shipped in data/; the generated
TileJSON and archives retain their source attribution. New workflow code uses
the repository's Apache-2.0 / MIT licensing. Hosting, publishing, schedules and
additional source resolutions are outside this package's current scope.