Add SRTM auto-download on tile miss from AWS Terrain Tiles

When a local .hgt tile is missing, download it from the public AWS S3
skadi bucket, decompress with zlib, and write to the tiles directory
before retrying the lookup. Falls back to Open-Meteo/OpenTopo APIs if
the download fails.
This commit is contained in:
Graham McIntire 2026-03-30 08:36:21 -05:00
parent 653efa450a
commit 2ae60d036b
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
7 changed files with 427 additions and 0 deletions

View file

@ -73,6 +73,9 @@ config :microwaveprop, MicrowavepropWeb.Endpoint,
# Enable dev routes for dashboard and mailbox # Enable dev routes for dashboard and mailbox
config :microwaveprop, dev_routes: true config :microwaveprop, dev_routes: true
# Use local SRTM1 tiles for elevation lookups instead of the Open-Meteo API
config :microwaveprop, srtm_tiles_dir: Path.expand("~/srtm/tiles")
# Initialize plugs at runtime for faster development compilation # Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime config :phoenix, :plug_init_mode, :runtime

View file

@ -66,6 +66,7 @@ if config_env() == :prod do
secret_key_base: secret_key_base secret_key_base: secret_key_base
config :microwaveprop, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") config :microwaveprop, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
config :microwaveprop, srtm_tiles_dir: "/srtm"
# ## SSL Support # ## SSL Support
# #

View file

@ -34,6 +34,7 @@ config :microwaveprop, iem_req_options: [plug: {Req.Test, Microwaveprop.Weather.
# Route HTTP requests through Req.Test stubs # Route HTTP requests through Req.Test stubs
config :microwaveprop, solar_req_options: [plug: {Req.Test, Microwaveprop.Weather.SolarClient}] config :microwaveprop, solar_req_options: [plug: {Req.Test, Microwaveprop.Weather.SolarClient}]
config :microwaveprop, srtm_req_options: [plug: {Req.Test, Microwaveprop.Terrain.Srtm}, retry: false]
# Initialize plugs at runtime for faster test compilation # Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime config :phoenix, :plug_init_mode, :runtime

View file

@ -1,11 +1,26 @@
defmodule Microwaveprop.Terrain.ElevationClient do defmodule Microwaveprop.Terrain.ElevationClient do
@moduledoc false @moduledoc false
alias Microwaveprop.Terrain.Srtm
@batch_size 100 @batch_size 100
@spec fetch_elevation_profile(float(), float(), float(), float(), pos_integer()) :: @spec fetch_elevation_profile(float(), float(), float(), float(), pos_integer()) ::
{:ok, list(map())} | {:error, String.t()} {:ok, list(map())} | {:error, String.t()}
def fetch_elevation_profile(lat1, lon1, lat2, lon2, n \\ 64) do def fetch_elevation_profile(lat1, lon1, lat2, lon2, n \\ 64) do
case srtm_tiles_dir() do
nil ->
fetch_elevation_profile_api(lat1, lon1, lat2, lon2, n)
tiles_dir ->
case Srtm.fetch_elevation_profile(lat1, lon1, lat2, lon2, tiles_dir, n) do
{:ok, _profile} = ok -> ok
{:error, _} -> fetch_elevation_profile_api(lat1, lon1, lat2, lon2, n)
end
end
end
defp fetch_elevation_profile_api(lat1, lon1, lat2, lon2, n) do
pts = sample_path(lat1, lon1, lat2, lon2, n) pts = sample_path(lat1, lon1, lat2, lon2, n)
dist_km = haversine_km(lat1, lon1, lat2, lon2) dist_km = haversine_km(lat1, lon1, lat2, lon2)
@ -25,6 +40,8 @@ defmodule Microwaveprop.Terrain.ElevationClient do
end end
end end
defp srtm_tiles_dir, do: Application.get_env(:microwaveprop, :srtm_tiles_dir)
def sample_path(lat1, lon1, lat2, lon2, n) do def sample_path(lat1, lon1, lat2, lon2, n) do
for i <- 0..n do for i <- 0..n do
f = i / n f = i / n

View file

@ -0,0 +1,175 @@
defmodule Microwaveprop.Terrain.Srtm do
@moduledoc false
@samples 3601
@void -32_768
@base_url "https://elevation-tiles-prod.s3.amazonaws.com/skadi"
@spec tile_filename(float(), float()) :: String.t()
def tile_filename(lat, lon) do
lat_floor = floor(lat)
lon_floor = floor(lon)
lat_prefix = if lat_floor >= 0, do: "N", else: "S"
lon_prefix = if lon_floor >= 0, do: "E", else: "W"
lat_str = lat_floor |> abs() |> Integer.to_string() |> String.pad_leading(2, "0")
lon_str = lon_floor |> abs() |> Integer.to_string() |> String.pad_leading(3, "0")
"#{lat_prefix}#{lat_str}#{lon_prefix}#{lon_str}.hgt"
end
@spec download_tile(float(), float(), String.t()) :: {:ok, String.t()} | {:error, term()}
def download_tile(lat, lon, tiles_dir) do
filename = tile_filename(lat, lon)
lat_dir = String.slice(filename, 0, 3)
url = "#{@base_url}/#{lat_dir}/#{filename}.gz"
path = Path.join(tiles_dir, filename)
case Req.get(url, req_options()) do
{:ok, %{status: 200, body: body}} ->
decompressed = :zlib.gunzip(body)
File.write!(path, decompressed)
{:ok, path}
{:ok, %{status: 404}} ->
{:error, :not_available}
{:ok, %{status: status}} ->
{:error, "SRTM download HTTP #{status}"}
{:error, reason} ->
{:error, "SRTM download error: #{inspect(reason)}"}
end
end
@spec lookup(float(), float(), String.t()) ::
{:ok, integer()} | {:error, :no_tile} | {:error, :void}
def lookup(lat, lon, tiles_dir) do
path = Path.join(tiles_dir, tile_filename(lat, lon))
case :file.open(path, [:read, :binary, :raw]) do
{:ok, fd} ->
read_elevation(fd, lat, lon)
{:error, :enoent} ->
if File.dir?(tiles_dir) do
case download_tile(lat, lon, tiles_dir) do
{:ok, _path} ->
case :file.open(path, [:read, :binary, :raw]) do
{:ok, fd} -> read_elevation(fd, lat, lon)
{:error, _} -> {:error, :no_tile}
end
{:error, _} ->
{:error, :no_tile}
end
else
{:error, :no_tile}
end
end
end
@spec fetch_elevation_profile(float(), float(), float(), float(), String.t(), pos_integer()) ::
{:ok, list(map())} | {:error, term()}
def fetch_elevation_profile(lat1, lon1, lat2, lon2, tiles_dir, n \\ 64) do
pts = sample_path(lat1, lon1, lat2, lon2, n)
dist_km = haversine_km(lat1, lon1, lat2, lon2)
results =
Enum.reduce_while(pts, {:ok, []}, fn pt, {:ok, acc} ->
case lookup(pt.lat, pt.lon, tiles_dir) do
{:ok, elev} ->
entry = %{
lat: pt.lat,
lon: pt.lon,
d: pt.d,
elev: elev,
dist_km: pt.d * dist_km
}
{:cont, {:ok, acc ++ [entry]}}
{:error, reason} ->
{:halt, {:error, reason}}
end
end)
results
end
defp read_elevation(fd, lat, lon) do
row = round((floor(lat) + 1 - lat) * (@samples - 1))
col = round((lon - floor(lon)) * (@samples - 1))
offset = (row * @samples + col) * 2
result =
case :file.pread(fd, offset, 2) do
{:ok, <<elev::signed-big-integer-size(16)>>} when elev == @void ->
{:error, :void}
{:ok, <<elev::signed-big-integer-size(16)>>} ->
{:ok, elev}
_ ->
{:error, :void}
end
:file.close(fd)
result
end
defp req_options do
defaults = [
compressed: false,
decode_body: false,
retry: &retry?/2,
max_retries: 3,
retry_delay: &retry_delay/1
]
overrides = Application.get_env(:microwaveprop, :srtm_req_options, [])
Keyword.merge(defaults, overrides)
end
defp retry?(_request, response) do
case response do
%Req.Response{status: status} when status in [429, 500, 502, 503, 504] -> true
%{__exception__: true} -> true
_ -> false
end
end
defp retry_delay(n) do
base = Integer.pow(2, n) * 1_000
jitter = :rand.uniform(1_000)
base + jitter
end
defp sample_path(lat1, lon1, lat2, lon2, n) do
for i <- 0..n do
f = i / n
%{
lat: lat1 + f * (lat2 - lat1),
lon: lon1 + f * (lon2 - lon1),
d: f
}
end
end
defp haversine_km(lat1, lon1, lat2, lon2) do
dlat = deg_to_rad(lat2 - lat1)
dlon = deg_to_rad(lon2 - lon1)
rlat1 = deg_to_rad(lat1)
rlat2 = deg_to_rad(lat2)
a =
:math.sin(dlat / 2) ** 2 +
:math.cos(rlat1) * :math.cos(rlat2) * :math.sin(dlon / 2) ** 2
2 * 6371.0 * :math.asin(:math.sqrt(a))
end
defp deg_to_rad(deg), do: deg * :math.pi() / 180
end

View file

@ -111,3 +111,53 @@ defmodule Microwaveprop.Terrain.ElevationClientTest do
end end
end end
end end
defmodule Microwaveprop.Terrain.ElevationClientSrtmTest do
use ExUnit.Case, async: false
alias Microwaveprop.Terrain.ElevationClient
@tiles_dir Path.expand("~/srtm/tiles")
@has_tiles File.exists?(Path.join(@tiles_dir, "N32W097.hgt"))
setup do
prev = Application.get_env(:microwaveprop, :srtm_tiles_dir)
on_exit(fn ->
if prev,
do: Application.put_env(:microwaveprop, :srtm_tiles_dir, prev),
else: Application.delete_env(:microwaveprop, :srtm_tiles_dir)
end)
:ok
end
describe "SRTM integration" do
@tag :srtm
@tag skip: !@has_tiles && "SRTM tiles not available"
test "uses local SRTM tiles when configured" do
Application.put_env(:microwaveprop, :srtm_tiles_dir, @tiles_dir)
assert {:ok, profile} =
ElevationClient.fetch_elevation_profile(32.78, -96.8, 32.5, -96.5, 4)
assert length(profile) == 5
assert hd(profile).elev > 0
end
@tag :srtm
test "falls back to API when SRTM tiles missing" do
Application.put_env(:microwaveprop, :srtm_tiles_dir, "/nonexistent/tiles")
Req.Test.stub(ElevationClient, fn conn ->
Req.Test.json(conn, %{"elevation" => [200.0, 250.0, 180.0]})
end)
assert {:ok, profile} =
ElevationClient.fetch_elevation_profile(32.9, -97.0, 30.3, -97.7, 2)
assert length(profile) == 3
assert hd(profile).elev == 200.0
end
end
end

View file

@ -0,0 +1,180 @@
defmodule Microwaveprop.Terrain.SrtmTest do
use ExUnit.Case, async: true
alias Microwaveprop.Terrain.Srtm
@tiles_dir Path.expand("~/srtm/tiles")
@has_tiles File.exists?(Path.join(@tiles_dir, "N32W097.hgt"))
# A full SRTM tile: 3601 * 3601 samples, each 2 bytes big-endian signed
@samples 3601
@test_elevation 150
@synthetic_hgt :binary.copy(<<@test_elevation::signed-big-integer-size(16)>>, @samples * @samples)
describe "tile_filename/2" do
test "northern hemisphere, western hemisphere" do
assert Srtm.tile_filename(32.78, -96.8) == "N32W097.hgt"
end
test "southern hemisphere, eastern hemisphere" do
assert Srtm.tile_filename(-0.5, 10.3) == "S01E010.hgt"
end
test "equator, prime meridian boundary" do
assert Srtm.tile_filename(0.5, -0.5) == "N00W001.hgt"
end
test "exact integer coordinates" do
assert Srtm.tile_filename(32.0, -97.0) == "N32W097.hgt"
end
test "negative lon floor wrapping" do
assert Srtm.tile_filename(35.2, -106.7) == "N35W107.hgt"
end
end
describe "download_tile/3" do
test "downloads and decompresses tile on success" do
tmp_dir = Path.join(System.tmp_dir!(), "srtm_test_#{System.unique_integer([:positive])}")
File.mkdir_p!(tmp_dir)
on_exit(fn -> File.rm_rf!(tmp_dir) end)
gzipped = :zlib.gzip(@synthetic_hgt)
Req.Test.stub(Srtm, fn conn ->
Plug.Conn.send_resp(conn, 200, gzipped)
end)
assert {:ok, path} = Srtm.download_tile(32.0, -97.0, tmp_dir)
assert path == Path.join(tmp_dir, "N32W097.hgt")
assert File.exists?(path)
assert File.read!(path) == @synthetic_hgt
end
test "returns {:error, :not_available} on 404" do
tmp_dir = Path.join(System.tmp_dir!(), "srtm_test_#{System.unique_integer([:positive])}")
File.mkdir_p!(tmp_dir)
on_exit(fn -> File.rm_rf!(tmp_dir) end)
Req.Test.stub(Srtm, fn conn ->
Plug.Conn.send_resp(conn, 404, "Not Found")
end)
assert {:error, :not_available} = Srtm.download_tile(80.0, 0.0, tmp_dir)
end
test "returns error message on other HTTP status" do
tmp_dir = Path.join(System.tmp_dir!(), "srtm_test_#{System.unique_integer([:positive])}")
File.mkdir_p!(tmp_dir)
on_exit(fn -> File.rm_rf!(tmp_dir) end)
Req.Test.stub(Srtm, fn conn ->
Plug.Conn.send_resp(conn, 500, "Internal Server Error")
end)
assert {:error, "SRTM download HTTP 500"} = Srtm.download_tile(32.0, -97.0, tmp_dir)
end
end
describe "lookup/3 with auto-download" do
test "auto-downloads missing tile and returns elevation" do
tmp_dir = Path.join(System.tmp_dir!(), "srtm_test_#{System.unique_integer([:positive])}")
File.mkdir_p!(tmp_dir)
on_exit(fn -> File.rm_rf!(tmp_dir) end)
gzipped = :zlib.gzip(@synthetic_hgt)
Req.Test.stub(Srtm, fn conn ->
Plug.Conn.send_resp(conn, 200, gzipped)
end)
assert {:ok, @test_elevation} = Srtm.lookup(32.0, -97.0, tmp_dir)
end
test "returns {:error, :no_tile} when download fails" do
tmp_dir = Path.join(System.tmp_dir!(), "srtm_test_#{System.unique_integer([:positive])}")
File.mkdir_p!(tmp_dir)
on_exit(fn -> File.rm_rf!(tmp_dir) end)
Req.Test.stub(Srtm, fn conn ->
Plug.Conn.send_resp(conn, 404, "Not Found")
end)
assert {:error, :no_tile} = Srtm.lookup(80.0, 0.0, tmp_dir)
end
end
describe "lookup/3" do
@describetag :srtm
@tag skip: !@has_tiles && "SRTM tiles not available at #{@tiles_dir}"
test "returns elevation for Dallas area" do
assert {:ok, elev} = Srtm.lookup(32.78, -96.8, @tiles_dir)
# Dallas is roughly 120-200m elevation
assert is_number(elev)
assert elev > 100
assert elev < 300
end
@tag skip: !@has_tiles && "SRTM tiles not available at #{@tiles_dir}"
test "returns {:error, :no_tile} for missing tile" do
Req.Test.stub(Srtm, fn conn ->
Plug.Conn.send_resp(conn, 404, "Not Found")
end)
assert {:error, :no_tile} = Srtm.lookup(80.0, 0.0, @tiles_dir)
end
end
describe "fetch_elevation_profile/6" do
@describetag :srtm
@tag skip: !@has_tiles && "SRTM tiles not available at #{@tiles_dir}"
test "returns profile with correct number of points" do
# Dallas to a point within the same tile
assert {:ok, profile} =
Srtm.fetch_elevation_profile(32.78, -96.8, 32.5, -96.5, @tiles_dir, 10)
assert length(profile) == 11
first = hd(profile)
assert Map.has_key?(first, :lat)
assert Map.has_key?(first, :lon)
assert Map.has_key?(first, :d)
assert Map.has_key?(first, :elev)
assert Map.has_key?(first, :dist_km)
end
@tag skip: !@has_tiles && "SRTM tiles not available at #{@tiles_dir}"
test "all elevations are positive for Dallas area path" do
assert {:ok, profile} =
Srtm.fetch_elevation_profile(32.78, -96.8, 32.5, -96.5, @tiles_dir, 8)
Enum.each(profile, fn pt ->
assert pt.elev > 0, "Expected positive elevation, got #{pt.elev}"
end)
end
@tag skip: !@has_tiles && "SRTM tiles not available at #{@tiles_dir}"
test "first and last dist_km are correct" do
assert {:ok, profile} =
Srtm.fetch_elevation_profile(32.78, -96.8, 32.5, -96.5, @tiles_dir, 4)
first = hd(profile)
last = List.last(profile)
assert_in_delta first.dist_km, 0.0, 0.01
assert last.dist_km > 0
end
test "returns error when tiles dir does not exist" do
assert {:error, _} =
Srtm.fetch_elevation_profile(32.78, -96.8, 32.5, -96.5, "/nonexistent", 4)
end
end
end