prop/lib/microwaveprop/workers/terrain_profile_worker.ex
Graham McIntire 4e6c87eca2
feat(telemetry): broaden Instrument span coverage
Adds spans to 15 previously-unmeasured hot paths so every question we
might ask while tuning has a histogram to answer it:

External I/O:
- iem.fetch_iemre (gridded weather reanalysis)
- mrms.list_latest / mrms.download (precip radar)
- rtma.fetch_observation
- ncei.fetch_metar (historical 5-min METAR backfill)
- solar.fetch_indices (GFZ solar indices)
- swpc.fetch (SWPC Kp/F10.7/X-ray)
- giro.fetch (ionosonde)
- qrz.request, geocoder.geocode (callsign enrichment)
- srtm.download_tile (terrain tile download + gunzip)
- hrrr.download_grib_ranges (parallel byte-range fetch phase)

Subprocess:
- wgrib2.extract_grid / extract_grid_from_file / extract_grid_from_file_mapped

LiveView hot paths:
- propagation.scores_at (map score fetch + cache hit/miss counter)
- propagation.point_forecast (sparkline)
- propagation.point_detail (click-to-inspect)
- propagation.daily_outlook_at (/map outlook strip)

Worker-level end-to-end:
- worker.terrain_profile
- worker.mechanism_classify
- worker.mrms_fetch

Each event is registered in Microwaveprop.PromEx.InstrumentPlugin as
a Prometheus histogram (default / long buckets as appropriate) plus
a counter for the scores_at cache hit/miss ratio. Prometheus at
10.0.15.25 will start seeing the new series on the next scrape after
deploy.
2026-04-18 17:25:33 -05:00

100 lines
3.1 KiB
Elixir

defmodule Microwaveprop.Workers.TerrainProfileWorker do
@moduledoc false
use Oban.Worker,
queue: :terrain,
max_attempts: 20,
unique: [period: 300, states: [:available, :scheduled, :executing, :retryable]]
alias Microwaveprop.Radio
alias Microwaveprop.Terrain
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Terrain.TerrainAnalysis
alias Microwaveprop.Weather
@impl Oban.Worker
def backoff(%Oban.Job{attempt: attempt}) do
min(120 * Integer.pow(2, attempt - 1), _six_hours = 21_600)
end
@impl Oban.Worker
def perform(%Oban.Job{args: %{"contact_id" => contact_id}}) do
Microwaveprop.Instrument.span([:worker, :terrain_profile], %{contact_id: contact_id}, fn ->
if Terrain.has_terrain_profile?(contact_id) do
Radio.set_enrichment_status!([contact_id], :terrain_status, :complete)
:ok
else
analyse_terrain(contact_id)
end
end)
end
defp analyse_terrain(contact_id) do
contact = Radio.get_contact!(contact_id)
with %{"lat" => lat1} <- contact.pos1,
lon1 when is_number(lon1) <- contact.pos1["lon"],
%{"lat" => lat2} <- contact.pos2,
lon2 when is_number(lon2) <- contact.pos2["lon"] do
fetch_and_store_profile(contact_id, contact, lat1, lon1, lat2, lon2)
else
_ ->
Radio.set_enrichment_status!([contact_id], :terrain_status, :unavailable)
:ok
end
end
defp fetch_and_store_profile(contact_id, contact, lat1, lon1, lat2, lon2) do
dist_km = Decimal.to_float(contact.distance_km || Decimal.new(0))
freq_ghz = Decimal.to_float(contact.band) / 1000
k = lookup_k_factor(contact)
case ElevationClient.fetch_elevation_profile(lat1, lon1, lat2, lon2, 64, download: true) do
{:ok, profile} ->
store_terrain_result(contact_id, profile, dist_km, freq_ghz, k)
{:error, reason} ->
{:error, reason}
end
end
defp store_terrain_result(contact_id, profile, dist_km, freq_ghz, k) do
analysis = TerrainAnalysis.analyse(profile, dist_km, freq_ghz, k_factor: k)
path_points =
Enum.map(profile, fn p ->
%{"lat" => p.lat, "lon" => p.lon, "d" => p.d, "elev" => p.elev, "dist_km" => p.dist_km}
end)
Terrain.upsert_terrain_profile(%{
contact_id: contact_id,
sample_count: length(profile),
path_points: path_points,
max_elevation_m: analysis.max_elevation_m,
min_clearance_m: analysis.min_clearance_m,
diffraction_db: analysis.diffraction_db,
fresnel_hit_count: analysis.fresnel_hit_count,
obstructed_count: analysis.obstructed_count,
verdict: analysis.verdict
})
Radio.set_enrichment_status!([contact_id], :terrain_status, :complete)
Phoenix.PubSub.broadcast(
Microwaveprop.PubSub,
"contact_enrichment:#{contact_id}",
{:terrain_ready, contact_id}
)
:ok
end
defp lookup_k_factor(contact) do
case Weather.hrrr_for_contact(contact) do
%{min_refractivity_gradient: grad} when not is_nil(grad) ->
TerrainAnalysis.k_factor(grad)
_ ->
4 / 3
end
end
end