defmodule Microwaveprop.Terrain.ElevationClient do @moduledoc false alias Microwaveprop.Terrain.Srtm require Logger @batch_size 100 @spec fetch_elevation_profile(float(), float(), float(), float(), pos_integer(), keyword()) :: {:ok, list(map())} | {:error, String.t()} def fetch_elevation_profile(lat1, lon1, lat2, lon2, n \\ 64, opts \\ []) do Microwaveprop.Instrument.span( [:elevation, :fetch_profile], %{sample_count: n, source: if(srtm_tiles_dir(), do: :srtm, else: :api)}, fn -> case srtm_tiles_dir() do nil -> Logger.debug("SRTM tiles_dir not configured, using API") fetch_elevation_profile_api(lat1, lon1, lat2, lon2, n) tiles_dir -> srtm_opts = Keyword.merge([download: true], opts) Srtm.fetch_elevation_profile(lat1, lon1, lat2, lon2, tiles_dir, n, srtm_opts) 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) @spec sample_path(float, float, float, float, non_neg_integer) :: [%{lat: float, lon: float, d: float, elev_m: float}] 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: Microwaveprop.Geo.haversine_km(lat1, lon1, lat2, lon2) 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