prop/lib/microwaveprop/terrain/elevation_client.ex
Graham McIntire 2ae60d036b
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.
2026-03-30 08:36:21 -05:00

139 lines
4 KiB
Elixir

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)
with {:error, e1} <- fetch_batched(pts, :open_meteo),
{:error, e2} <- fetch_batched(pts, :open_topo) do
{:error, "Elevation fetch failed: #{e1} | #{e2}"}
else
{:ok, elevs} ->
profile =
pts
|> Enum.zip(elevs)
|> Enum.map(fn {pt, elev} ->
%{lat: pt.lat, lon: pt.lon, d: pt.d, elev: elev || 0.0, dist_km: pt.d * dist_km}
end)
{:ok, profile}
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
%{
lat: lat1 + f * (lat2 - lat1),
lon: lon1 + f * (lon2 - lon1),
d: f
}
end
end
defp fetch_batched(pts, source) do
pts
|> Enum.chunk_every(@batch_size)
|> Enum.reduce_while({:ok, []}, fn batch, {:ok, acc} ->
case fetch_batch(source, batch) do
{:ok, elevs} -> {:cont, {:ok, acc ++ elevs}}
{:error, _} = err -> {:halt, err}
end
end)
end
defp fetch_batch(:open_meteo, batch) do
lats = Enum.map_join(batch, ",", &Float.to_string(Float.round(&1.lat, 6)))
lons = Enum.map_join(batch, ",", &Float.to_string(Float.round(&1.lon, 6)))
url = "https://api.open-meteo.com/v1/elevation?latitude=#{lats}&longitude=#{lons}"
case Req.get(url, req_options()) do
{:ok, %{status: 200, body: %{"elevation" => elevs}}} when is_list(elevs) ->
{:ok, elevs}
{:ok, %{status: status}} ->
{:error, "Open-Meteo HTTP #{status}"}
{:error, reason} ->
{:error, "Open-Meteo error: #{inspect(reason)}"}
end
end
defp fetch_batch(:open_topo, batch) do
locations =
Enum.map_join(batch, "|", fn p ->
"#{Float.round(p.lat, 6)},#{Float.round(p.lon, 6)}"
end)
url = "https://api.opentopodata.org/v1/srtm90m?locations=#{URI.encode(locations)}"
case Req.get(url, req_options()) do
{:ok, %{status: 200, body: %{"status" => "OK", "results" => results}}} ->
{:ok, Enum.map(results, fn r -> r["elevation"] || 0.0 end)}
{:ok, %{status: status}} ->
{:error, "OpenTopo HTTP #{status}"}
{:error, reason} ->
{:error, "OpenTopo error: #{inspect(reason)}"}
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
defp req_options do
defaults = [retry: &retry?/2, max_retries: 5, retry_delay: &retry_delay/1]
overrides = Application.get_env(:microwaveprop, :elevation_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
end