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:
parent
653efa450a
commit
2ae60d036b
7 changed files with 427 additions and 0 deletions
|
|
@ -73,6 +73,9 @@ config :microwaveprop, MicrowavepropWeb.Endpoint,
|
|||
# Enable dev routes for dashboard and mailbox
|
||||
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
|
||||
config :phoenix, :plug_init_mode, :runtime
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ if config_env() == :prod do
|
|||
secret_key_base: secret_key_base
|
||||
|
||||
config :microwaveprop, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
|
||||
config :microwaveprop, srtm_tiles_dir: "/srtm"
|
||||
|
||||
# ## SSL Support
|
||||
#
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ config :microwaveprop, iem_req_options: [plug: {Req.Test, Microwaveprop.Weather.
|
|||
|
||||
# Route HTTP requests through Req.Test stubs
|
||||
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
|
||||
config :phoenix, :plug_init_mode, :runtime
|
||||
|
|
|
|||
|
|
@ -1,11 +1,26 @@
|
|||
defmodule Microwaveprop.Terrain.ElevationClient do
|
||||
@moduledoc false
|
||||
|
||||
alias Microwaveprop.Terrain.Srtm
|
||||
|
||||
@batch_size 100
|
||||
|
||||
@spec fetch_elevation_profile(float(), float(), float(), float(), pos_integer()) ::
|
||||
{:ok, list(map())} | {:error, String.t()}
|
||||
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)
|
||||
dist_km = haversine_km(lat1, lon1, lat2, lon2)
|
||||
|
||||
|
|
@ -25,6 +40,8 @@ defmodule Microwaveprop.Terrain.ElevationClient do
|
|||
end
|
||||
end
|
||||
|
||||
defp srtm_tiles_dir, do: Application.get_env(:microwaveprop, :srtm_tiles_dir)
|
||||
|
||||
def sample_path(lat1, lon1, lat2, lon2, n) do
|
||||
for i <- 0..n do
|
||||
f = i / n
|
||||
|
|
|
|||
175
lib/microwaveprop/terrain/srtm.ex
Normal file
175
lib/microwaveprop/terrain/srtm.ex
Normal 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
|
||||
|
|
@ -111,3 +111,53 @@ defmodule Microwaveprop.Terrain.ElevationClientTest do
|
|||
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
|
||||
|
|
|
|||
180
test/microwaveprop/terrain/srtm_test.exs
Normal file
180
test/microwaveprop/terrain/srtm_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue