Repository navigation
Download topography without GMT - #216
Merged
Merged
Conversation
`import_topo` needed `GMT`, which is a heavy dependency for one call: it pulls in GDAL_jll, whose HDF5 bounds clash with the PETSc stack (installing GMT alongside LaMEM silently downgrades LaMEM by a minor version), and GMT's own share directory is not reliably relocatable. The grids it fetches are served over plain HTTP, so the only thing GMT was really needed for was decoding them. Do that with `OpenJpeg_jll`, whose tree is small and shares nothing with GDAL. `import_topo` therefore moves out of the GMT extension and into the package itself; `import_GeoTIFF` stays behind, so GMT remains an optional dependency for that. Everything that varies between the sets is read from the server's own index rather than assumed: - tiles are 30, 60, 90, 180, 15, 10 or 1 degrees wide depending on the set; - the integers in them are scaled (earth_relief_15s stores half metres); - some sets exist in only one registration -- SRTM is gridline, 15s is pixel -- and gridline tiles repeat the edge they share with the next tile; - latitude tiles are anchored at the south pole, not at multiples of their size; - SRTM covers land only, writing a flat zero for the sea and omitting all-ocean tiles, so both are filled from the coarser set the server names for it. Checked against GMT over La Palma: every tiled resolution of earth_relief (01m-05m, 30s, 15s) and of earth_gebco and earth_synbath agrees to 0.0 m, as do the Alps and Iceland. 03s agrees exactly on land; in the sea it differs by 0.68 m on average, where GMT's interpolation of the filler differs from ours. The existing test's expected value, sum(Topo.depth.val) = 1076.7045, is reproduced. Coarse resolutions (06m and up) are single NetCDF files and still need `NCDatasets`, which stays a weak dependency. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Your PR requires formatting changes to meet the project's style guidelines. Click here to view the suggested changes.diff --git a/src/import_topo.jl b/src/import_topo.jl
index b953464..eef9b29 100644
--- a/src/import_topo.jl
+++ b/src/import_topo.jl
@@ -57,11 +57,13 @@ function server_entry(dataset::AbstractString, res::AbstractString, reg::Abstrac
length(f) < 11 && continue
name = rstrip(f[2], ['/'])
replace(name, ".grd" => "") == want || continue
- return (name = name,
- scale = something(tryparse(Float64, f[5]), 1.0),
- offset = something(tryparse(Float64, f[6]), 0.0),
- tile_deg = something(tryparse(Int, f[8]), 0),
- filler = strip(f[11]))
+ return (
+ name = name,
+ scale = something(tryparse(Float64, f[5]), 1.0),
+ offset = something(tryparse(Float64, f[6]), 0.0),
+ tile_deg = something(tryparse(Int, f[8]), 0),
+ filler = strip(f[11]),
+ )
end
return nothing
end
@@ -110,8 +112,10 @@ The name of the tile whose south-west corner is at (`lat`, `lon`).
The 3 arcsecond set is the odd one out: it is SRTM repackaged, and is named after that rather
than after the dataset it is served under.
"""
-function tile_filename(lat::Integer, lon::Integer, dataset::AbstractString,
- res::AbstractString, reg::AbstractString)
+function tile_filename(
+ lat::Integer, lon::Integer, dataset::AbstractString,
+ res::AbstractString, reg::AbstractString
+ )
ns = lat < 0 ? "S" : "N"
ew = lon < 0 ? "W" : "E"
corner = string(ns, lpad(abs(lat), 2, '0'), ew, lpad(abs(lon), 3, '0'))
@@ -222,13 +226,15 @@ Read a global `.grd` grid, returning `(lon, lat, z)`. Filled in by the `NCDatase
extension; the tiled resolutions do not go through here.
"""
function read_topo_netcdf(file::AbstractString)
- return error("""
+ return error(
+ """
reading the global grid $(basename(file)) needs NCDatasets. Either
using NCDatasets
or ask for one of the tiled resolutions (05m and finer), which need nothing extra.
- """)
+ """
+ )
end
"""
@@ -238,8 +244,10 @@ Fetch one file, trying each mirror in turn. Returns `true` if it was downloaded,
the file is not on the server -- which is not an error: the 3 arcsecond set covers land only,
so a tile that is entirely ocean simply does not exist.
"""
-function download_tile(relative_url::AbstractString, dest::AbstractString;
- maxattempts::Integer = 5, timeout::Real = TILE_TIMEOUT)
+function download_tile(
+ relative_url::AbstractString, dest::AbstractString;
+ maxattempts::Integer = 5, timeout::Real = TILE_TIMEOUT
+ )
isfile(dest) && filesize(dest) > 0 && return true # cached
tmp = dest * ".part" # never leave a half file behind
@@ -290,8 +298,10 @@ end
Assemble the tiles that cover `limits` and cut out the requested region. Tiles that are not
on the server are taken to be at sea level, which is what the land-only 3 arcsecond set needs.
"""
-function grid_from_tiles(limits, dataset::AbstractString, res::AbstractString, reg::AbstractString;
- maxattempts::Integer = 5)
+function grid_from_tiles(
+ limits, dataset::AbstractString, res::AbstractString, reg::AbstractString;
+ maxattempts::Integer = 5
+ )
lonmin, lonmax, latmin, latmax = limits
# Not every set is published in both registrations: the SRTM-derived ones are gridline
# only, 15s is pixel only. GMT serves whichever the set actually has rather than
@@ -317,8 +327,10 @@ function grid_from_tiles(limits, dataset::AbstractString, res::AbstractString, r
# tile contributes `step` new rows/columns and only the last one of a row contributes
# its final edge as well.
step = tile_reg == "g" ? n - 1 : n
- Z = zeros(Int16, length(lats) * step + (tile_reg == "g"),
- length(lons) * step + (tile_reg == "g"))
+ Z = zeros(
+ Int16, length(lats) * step + (tile_reg == "g"),
+ length(lons) * step + (tile_reg == "g")
+ )
dir = topo_cache_dir()
sub = tile_directory(dataset, res, tile_reg)
@@ -331,8 +343,10 @@ function grid_from_tiles(limits, dataset::AbstractString, res::AbstractString, r
continue
end
A = decode_jp2(file, n)
- Z[((j - 1) * step + 1):((j - 1) * step + n),
- ((i - 1) * step + 1):((i - 1) * step + n)] = A[end:-1:1, :]
+ Z[
+ ((j - 1) * step + 1):((j - 1) * step + n),
+ ((i - 1) * step + 1):((i - 1) * step + n),
+ ] = A[end:-1:1, :]
end
# the coordinates of each sample: cell centres for pixel registration, cell corners
@@ -374,8 +388,10 @@ set that the server names for the purpose.
function fill_gaps!(Z, lon, lat, filler)
any(iszero, Z) || return Z
fdataset, fres, freg = filler
- flon, flat, FZ = grid_from_tiles([minimum(lon), maximum(lon), minimum(lat), maximum(lat)],
- fdataset, fres, freg)
+ flon, flat, FZ = grid_from_tiles(
+ [minimum(lon), maximum(lon), minimum(lat), maximum(lat)],
+ fdataset, fres, freg
+ )
for j in axes(Z, 1), i in axes(Z, 2)
iszero(Z[j, i]) || continue
Z[j, i] = bilinear(flon, flat, FZ, lon[i], lat[j])
@@ -402,7 +418,7 @@ function bilinear(xs::AbstractVector, ys::AbstractVector, A::AbstractMatrix, x::
ty = clamp(ty, 0.0, 1.0)
return (1 - tx) * (1 - ty) * A[j, i] + tx * (1 - ty) * A[j, i + 1] +
- (1 - tx) * ty * A[j + 1, i] + tx * ty * A[j + 1, i + 1]
+ (1 - tx) * ty * A[j + 1, i] + tx * ty * A[j + 1, i + 1]
end
"""
@@ -412,8 +428,10 @@ The coarse resolutions are published as one NetCDF file for the whole globe, whi
enough to download and cut down here. Reading it needs `NCDatasets`, which is a weak
dependency -- the tiled resolutions, which are the interesting ones, need nothing extra.
"""
-function grid_from_single_file(limits, dataset::AbstractString, res::AbstractString, reg::AbstractString;
- maxattempts::Integer = 5)
+function grid_from_single_file(
+ limits, dataset::AbstractString, res::AbstractString, reg::AbstractString;
+ maxattempts::Integer = 5
+ )
lonmin, lonmax, latmin, latmax = limits
name = "$(dataset)_$(res)_$(reg).grd"
file = joinpath(topo_cache_dir(), name)
@@ -459,10 +477,12 @@ julia> Topo = import_topo(lon=[4,20], lat=[37,49])
Tiles are cached, so importing the same region again does not download them a second time.
"""
-function import_topo(limits; dataset::AbstractString = "earth_relief",
- res::AbstractString = "01m", reg::AbstractString = "g",
- file::Union{Nothing, AbstractString} = nothing,
- maxattempts::Integer = 5)
+function import_topo(
+ limits; dataset::AbstractString = "earth_relief",
+ res::AbstractString = "01m", reg::AbstractString = "g",
+ file::Union{Nothing, AbstractString} = nothing,
+ maxattempts::Integer = 5
+ )
# accept the `file="@earth_relief_01m"` spelling that this function used to take
if !isnothing(file)
@@ -505,6 +525,6 @@ function parse_topo_file(file::AbstractString)
m = match(r"^(.*)_(\d+[dms])(?:_([gp]))?$", s)
isnothing(m) && error("cannot make sense of the topography file name \"$file\"")
# a name without a trailing _g/_p leaves the registration open, and GMT then serves
- # whichever the set has, preferring gridline -- the same default as `reg`
- return String(m[1]), String(m[2]), isnothing(m[3]) ? "g" : String(m[3])
+ # whichever the set has, preferring gridline -- the same default as `reg`
+ return String(m[1]), String(m[2]), isnothing(m[3]) ? "g" : String(m[3])
end |
CI caught two: `maxattempts`, which the prefetch script passes and which is now honoured as the number of times each mirror is tried, and the `.grd`/`.nc` suffix that the tutorials append to the file name, which says which format GMT downloaded into rather than which set is wanted. Also a spelling the linter objects to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Downloads.download` waits forever by default, and the retry loop tried each of four mirrors up to five times, so a server that accepts the connection and then stalls blocked indefinitely rather than failing over. That is what the data server does when several CI jobs pull tiles at once: on one run three macOS jobs finished in 4-8 minutes while a fourth on the same platform was still going after an hour. Give each request a timeout, back off between rounds of mirrors, and download through a .part file so a failed transfer cannot leave a truncated tile behind. The prefetch is still worth having -- it serialises the first downloads, which is what keeps the workers off the server -- but it was warming ~/.gmt, which nothing reads any more. Point it, and the CI cache, at the scratch space that `import_topo` actually uses. ~/.gmt stays cached for `import_GeoTIFF`, which does still go through GMT. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mthielma
approved these changes
Sep 29, 2026
mthielma
left a comment
Contributor
There was a problem hiding this comment.
Looks good and should resolve the recurring GMT issues.
Merged
6 of 10 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
import_topolived in the GMT extension, so getting topography meant taking onGMT— a heavy dependency for what is really one call. Two problems follow from it:GMTneedsGDAL_jll, whoseHDF5_jllbounds ([3.0.3 – 300.500.101]or[305.1300.100 – 305.1300.301]) exclude the 2.2.x thatPETSc_jllrequires. InstallingGMTnext toLaMEMtherefore walks LaMEM back a minor version — 0.5.3 → 0.4.16 — without saying why.libgmthas the BinaryBuilder sandbox path/workspace/destdir/sharecompiled in, and accepts it on nothing more thanaccess(). Anyone who has a real/workspace/destdir(Yggdrasil contributors, for instance) getsFile /workspace/destdir/share/google/turbo.cpt was not foundand a failed precompile.What
The grids are served over plain HTTP, so the only thing GMT was really needed for was decoding them.
OpenJpeg_jlldoes that, and its tree — JpegTurbo, Libtiff, LittleCMS, libpng, zlib, zstd, XZ, LERC — shares nothing with GDAL or HDF5. Soimport_topomoves out of the GMT extension and into the package, withOpenJpeg_jllandScratchas hard dependencies.import_GeoTIFFstays in the extension, soGMTremains an optional dependency for it.Tiles are cached in a scratch space, so the same region is not downloaded twice.
What the server does, and what had to be read rather than assumed
Everything that varies between sets comes from
gmt_data_server.txt, not from hardcoded tables:earth_relief_15sstores half metres, so reading them as metres doubles every elevation;Verification against GMT
Over La Palma (
[-18.7, -17.1, 28.0, 29.2]), comparing withgmtreaddirectly:Also 0.0 m for the Alps at 15s and Iceland at 30s, and the existing suite's expected value
sum(Topo.depth.val) = 1076.7045is reproduced exactly, so this is drop-in.The one remaining difference is 03s over water, where GMT's interpolation of the 15s filler differs from the bilinear one here: max 251 m, mean 0.68 m, with 0.6% of cells more than 10 m apart, all at the coastline. On land it is exact — the summit lands at −17.885, 28.754, which is Roque de los Muchachos.
Tests
test/test_import_topo.jl: tile naming including the SRTM special case, pixel-vs-gridline sizes, the sets that exist in one registration only, thefile=spelling, georeferencing (the summit position catches a transposed or mis-assembled grid), that the sea is not flat, and that the keyword and vector forms agree. 27/27 locally.Compatibility with existing callers
Every call form already in the repo, the docs and the tutorials is accepted unchanged:
import_topo([8,9,50,51]), thelat=/lon=keywords,file="@earth_relief_01m", the.grd/.ncsuffix the tutorials append, and themaxattemptsthattopo_prefetch.jlpasses (now honoured as the number of times each mirror is tried).
test_GMT.jl,test_WaterFlow.jlandtopo_prefetch.jlall pass against this implementation.Note
Coarse resolutions (06m and up) are single NetCDF files rather than tiles, and reading those still needs
NCDatasets— which stays a weak dependency. The tiled resolutions, which are the interesting ones, need nothing beyond the new hard dependencies.🤖 Generated with Claude Code