prop/lib/microwaveprop/terrain/elevation_client.ex
Graham McIntire 2da74c5cd8
feat(telemetry): wide instrumentation + bump hrrr to 2 per pod
Config:
- runtime.exs hrrr queue 1 → 2 (6 concurrent HRRR jobs across 3 pods)

New helper Microwaveprop.Instrument.span/3 wraps :telemetry.span with
a [:microwaveprop | event_suffix] prefix so metrics can key off it.

Spans added (each emits duration + result tag):
- HrrrClient.fetch_grid / fetch_profile / fetch_idx
- NexradClient.fetch_frame + decode_png
- IemClient.fetch_asos / fetch_raob
- GefsClient.fetch_grid_profiles
- NarrClient.fetch_profile_at
- UwyoSoundingClient.fetch_sounding
- ElevationClient.fetch_elevation_profile
- Weather.upsert_hrrr_profiles_batch / upsert_gefs_profiles_batch
- Propagation.replace_scores
- PropagationGridWorker.compute_scores_algorithm
- TerrainAnalysis.analyse
- CommonVolumeRadarWorker.aggregate_stats

Telemetry catalog (MicrowavepropWeb.Telemetry):
- Oban job duration / queue_time / count / exception by (worker, queue, state)
- Per-span summary metrics for every instrumented phase above
- Periodic (10s) poller emits oban queue depth by (queue, state) — drops
  into the /admin/dashboard Metrics tab immediately

Also drops the now-redundant "fetching n0q frame" and "fetching <url>"
info lines from CommonVolumeRadarWorker / NexradClient; the span events
cover that and the worker's "ingested" line stays for per-job signal.
2026-04-18 16:33:34 -05:00

146 lines
4.2 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)
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