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 Microwaveprop.Instrument.span([:srtm, :download_tile], %{lat: lat, lon: lon}, fn -> do_download_tile(lat, lon, tiles_dir) end) end defp do_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} -> maybe_download_and_read(lat, lon, path, tiles_dir, opts) end end defp maybe_download_and_read(lat, lon, path, tiles_dir, opts) do if opts[:download] && File.dir?(tiles_dir) do download_and_read(lat, lon, path, tiles_dir) else {:error, :no_tile} end end defp download_and_read(lat, lon, path, 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 end @spec fetch_elevation_profile(float(), float(), float(), float(), String.t(), pos_integer(), keyword()) :: {:ok, list(map())} 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, <>} when elev == @void -> {:error, :void} {:ok, <>} -> {: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: Microwaveprop.Geo.haversine_km(lat1, lon1, lat2, lon2) end