SRTM profiles no longer abort on missing tiles — water/ocean areas return 0m elevation instead of failing the entire profile and falling back to the rate-limited API. Elevation client and viewshed now pass download: true to auto-fetch missing tiles from S3. NFS mount changed to writable so downloaded tiles persist across pods.
176 lines
5 KiB
Elixir
176 lines
5 KiB
Elixir
defmodule Microwaveprop.Terrain.Srtm do
|
|
@moduledoc false
|
|
|
|
require Logger
|
|
|
|
@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)
|
|
|
|
Logger.info("Downloading SRTM tile #{filename} from S3")
|
|
|
|
case Req.get(url, req_options()) do
|
|
{:ok, %{status: 200, body: body}} ->
|
|
decompressed = :zlib.gunzip(body)
|
|
File.write!(path, decompressed)
|
|
Logger.info("SRTM tile #{filename} downloaded (#{byte_size(decompressed)} bytes)")
|
|
{:ok, path}
|
|
|
|
{:ok, %{status: 404}} ->
|
|
Logger.warning("SRTM tile #{filename} not available on S3 (404)")
|
|
{:error, :not_available}
|
|
|
|
{:ok, %{status: status}} ->
|
|
Logger.error("SRTM tile #{filename} download failed: HTTP #{status}")
|
|
{:error, "SRTM download HTTP #{status}"}
|
|
|
|
{:error, reason} ->
|
|
Logger.error("SRTM tile #{filename} download error: #{inspect(reason)}")
|
|
{:error, "SRTM download error: #{inspect(reason)}"}
|
|
end
|
|
end
|
|
|
|
@spec lookup(float(), float(), String.t(), keyword()) ::
|
|
{:ok, integer()} | {:error, :no_tile} | {:error, :void}
|
|
def lookup(lat, lon, tiles_dir, opts \\ []) 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 opts[:download] && File.dir?(tiles_dir) do
|
|
case download_tile(lat, lon, tiles_dir) do
|
|
{:ok, _} ->
|
|
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(), keyword()) ::
|
|
{:ok, list(map())} | {:error, term()}
|
|
def fetch_elevation_profile(lat1, lon1, lat2, lon2, tiles_dir, n \\ 64, opts \\ []) do
|
|
pts = sample_path(lat1, lon1, lat2, lon2, n)
|
|
dist_km = haversine_km(lat1, lon1, lat2, lon2)
|
|
|
|
profile =
|
|
Enum.map(pts, fn pt ->
|
|
elev =
|
|
case lookup(pt.lat, pt.lon, tiles_dir, opts) do
|
|
{:ok, elev} -> elev
|
|
# Missing tile (water/ocean) or void value — treat as sea level
|
|
{:error, _} -> 0
|
|
end
|
|
|
|
%{lat: pt.lat, lon: pt.lon, d: pt.d, elev: elev, dist_km: pt.d * dist_km}
|
|
end)
|
|
|
|
{:ok, profile}
|
|
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
|