- Bug #12: Lower rate limit to 15/min - Bug #13: Wrap model loading in Task.start - Bug #14: Fix classify_time_period guard gap at -3.0 - Bug #15: Not applicable (Elixir has no ?? operator) - Bug #16: Remove fallback Repo.get for station preload - Bug #17: Extract cache_key() in ContactMapController - Bug #18: Add has_many :contacts and :beacons to User schema - A&D #4: Move serve_markdown_if_requested after secure headers - Config #1: Move signing_salt to runtime.exs env var - Config #2: Use --check-unused instead of --unused - Config #3: Re-enable UnsafeToAtom Credo check - Config #5: Re-enable LeakyEnvironment Credo check - Add @spec annotations to fix re-enabled Specs violations - Replace String.to_atom with to_existing_atom where guarded
134 lines
4.1 KiB
Elixir
134 lines
4.1 KiB
Elixir
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
|