Add SRTM terrain path profiles for QSOs

Fetch elevation data along the path between two stations via the
Open-Meteo Elevation API (with Open-Topo-Data fallback), compute
Fresnel zone clearance, earth bulge, and knife-edge diffraction loss,
and store the results per QSO.

- terrain_profiles table with migration and TerrainProfile schema
- ElevationClient with batched API calls and fallback
- TerrainAnalysis with Fresnel/diffraction physics (ITU-R P.526-15)
- TerrainProfileWorker on Oban :terrain queue
- QsoWeatherEnqueueWorker enqueues terrain jobs automatically
- QSO show page displays verdict badge and collapsible elevation table
- Reorder show page: terrain, soundings, solar, HRRR, surface obs
- Fix Dockerfile wgrib2 build (add cmake dependency)
This commit is contained in:
Graham McIntire 2026-03-29 16:22:29 -05:00
parent 42b2ac920b
commit 45e2e69361
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
20 changed files with 1275 additions and 41 deletions

View file

@ -22,7 +22,7 @@ FROM ${BUILDER_IMAGE} AS builder
# install build dependencies and build wgrib2
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential git gfortran wget \
&& apt-get install -y --no-install-recommends build-essential git gfortran wget cmake \
&& rm -rf /var/lib/apt/lists/*
RUN wget -q https://www.ftp.cpc.ncep.noaa.gov/wd51we/wgrib2/wgrib2.tgz \

View file

@ -44,7 +44,7 @@ config :microwaveprop, MicrowavepropWeb.Endpoint,
config :microwaveprop, Oban,
repo: Microwaveprop.Repo,
queues: [solar: 1, weather: 3, enqueue: 1, hrrr: 2],
queues: [solar: 1, weather: 3, enqueue: 1, hrrr: 2, terrain: 2],
plugins: [
{Oban.Plugins.Pruner, max_age: 3600 * 24},
{Oban.Plugins.Cron,

View file

@ -28,6 +28,7 @@ config :microwaveprop, MicrowavepropWeb.Endpoint,
# Run Oban jobs inline during tests
config :microwaveprop, Oban, testing: :inline
config :microwaveprop, elevation_req_options: [plug: {Req.Test, Microwaveprop.Terrain.ElevationClient}, retry: false]
config :microwaveprop, iem_req_options: [plug: {Req.Test, Microwaveprop.Weather.IemClient}, retry: false]
# Route HTTP requests through Req.Test stubs

View file

@ -71,6 +71,20 @@ defmodule Microwaveprop.Radio do
|> Repo.update_all(set: [hrrr_queued: true])
end
def unprocessed_terrain_qsos(limit \\ 500) do
Qso
|> where([q], q.terrain_queued == false and not is_nil(q.pos1) and not is_nil(q.pos2))
|> order_by([q], asc: q.qso_timestamp)
|> limit(^limit)
|> Repo.all()
end
def mark_terrain_queued!(qso_ids) do
Qso
|> where([q], q.id in ^qso_ids)
|> Repo.update_all(set: [terrain_queued: true])
end
@earth_radius_km 6371.0
def haversine_km(lat1, lon1, lat2, lon2) do

View file

@ -20,6 +20,7 @@ defmodule Microwaveprop.Radio.Qso do
field :distance_km, :decimal
field :weather_queued, :boolean, default: false
field :hrrr_queued, :boolean, default: false
field :terrain_queued, :boolean, default: false
timestamps(type: :utc_datetime)
end

View file

@ -0,0 +1,28 @@
defmodule Microwaveprop.Terrain do
@moduledoc false
import Ecto.Query
alias Microwaveprop.Repo
alias Microwaveprop.Terrain.TerrainProfile
def upsert_terrain_profile(attrs) do
%TerrainProfile{}
|> TerrainProfile.changeset(attrs)
|> Repo.insert(
on_conflict: {:replace_all_except, [:id, :qso_id, :inserted_at]},
conflict_target: [:qso_id],
returning: true
)
end
def get_terrain_profile(qso_id) do
Repo.get_by(TerrainProfile, qso_id: qso_id)
end
def has_terrain_profile?(qso_id) do
TerrainProfile
|> where([t], t.qso_id == ^qso_id)
|> Repo.exists?()
end
end

View file

@ -0,0 +1,114 @@
defmodule Microwaveprop.Terrain.ElevationClient do
@moduledoc false
@batch_size 100
@spec fetch_elevation_profile(float(), float(), float(), float(), pos_integer()) ::
{:ok, list(map())} | {:error, String.t()}
def fetch_elevation_profile(lat1, lon1, lat2, lon2, n \\ 64) 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
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: :transient, max_retries: 2, retry_delay: &retry_delay/1]
overrides = Application.get_env(:microwaveprop, :elevation_req_options, [])
Keyword.merge(defaults, overrides)
end
defp retry_delay(n) do
base = Integer.pow(2, n) * 1_000
jitter = :rand.uniform(500)
base + jitter
end
end

View file

@ -0,0 +1,146 @@
defmodule Microwaveprop.Terrain.TerrainAnalysis do
@moduledoc false
@earth_radius_m 6_371_000.0
@doc """
Analyse an elevation profile for LOS clearance, Fresnel zone penetration,
and knife-edge diffraction loss.
K=4/3 standard atmosphere. Antenna heights default to 0m (conservative).
"""
def analyse(profile, dist_km, freq_ghz, ant_ht_a_m \\ 0.0, ant_ht_b_m \\ 0.0) do
lambda_m = 0.3 / freq_ghz
n = length(profile) - 1
first = hd(profile)
last = List.last(profile)
elev1 = first.elev + ant_ht_a_m
elev2 = last.elev + ant_ht_b_m
points =
profile
|> Enum.with_index()
|> Enum.map(fn {p, i} ->
frac = i / n
d1_m = frac * dist_km * 1000
d2_m = (1 - frac) * dist_km * 1000
beam = beam_height(frac, elev1, elev2)
bulge = earth_bulge(frac, dist_km)
eff_terrain = p.elev + bulge
r1 = fresnel_radius(d1_m, d2_m, lambda_m)
clearance = beam - eff_terrain
f1_clear = if r1 > 0, do: clearance - r1, else: clearance
is_endpoint = i == 0 or i == n
%{
lat: p.lat,
lon: p.lon,
d: p.d,
elev: p.elev,
dist_km: p.dist_km,
beam: beam,
eff_terrain: eff_terrain,
bulge: bulge,
r1: r1,
clearance: clearance,
f1_clear: f1_clear,
obstructed: not is_endpoint and clearance < 0,
fresnel_penetrated: not is_endpoint and r1 > 0 and f1_clear < 0 and clearance >= 0
}
end)
interior = Enum.slice(points, 1..-2//1)
obstructed = Enum.filter(interior, & &1.obstructed)
fresnel_hit = Enum.filter(interior, & &1.fresnel_penetrated)
{diffraction_db, verdict} = compute_verdict(obstructed, fresnel_hit)
elev_vals = Enum.map(profile, & &1.elev)
clearance_vals = Enum.map(interior, & &1.clearance)
max_elevation_m = Enum.max(elev_vals, fn -> 0.0 end)
min_clearance_m = if clearance_vals == [], do: 999.0, else: Enum.min(clearance_vals)
%{
points: points,
diffraction_db: diffraction_db,
verdict: verdict,
max_elevation_m: max_elevation_m,
min_clearance_m: min_clearance_m,
obstructed_count: length(obstructed),
fresnel_hit_count: length(fresnel_hit)
}
end
def fresnel_radius(d1_m, d2_m, lambda_m) do
if d1_m <= 0 or d2_m <= 0 do
0
else
:math.sqrt(lambda_m * d1_m * d2_m / (d1_m + d2_m))
end
end
def earth_bulge(frac, dist_km, k \\ 4 / 3) do
d1 = frac * dist_km * 1000
d2 = (1 - frac) * dist_km * 1000
d1 * d2 / (2 * k * @earth_radius_m)
end
def knife_edge_loss(v) when v <= -0.7787, do: 0
def knife_edge_loss(v) when v <= 0 do
max(0, -20 * :math.log10(0.5 - 0.62 * v))
end
def knife_edge_loss(v) when v <= 1 do
max(0, -20 * :math.log10(0.5 * :math.exp(-0.95 * v)))
end
def knife_edge_loss(v) when v <= 2.4 do
inner = max(0, 0.1184 - (0.38 - 0.1 * v) ** 2)
max(0, -20 * :math.log10(0.4 - :math.sqrt(inner)))
end
def knife_edge_loss(v) do
# Asymptotic: loss = 20·log10(v) + 13 dB
max(0, 20 * :math.log10(v) + 13.0)
end
defp beam_height(frac, elev1, elev2) do
elev1 + frac * (elev2 - elev1)
end
defp compute_verdict(obstructed, _fresnel_hit) when obstructed != [] do
worst = Enum.min_by(obstructed, & &1.clearance)
v_blocked =
if worst.r1 > 0 do
abs(worst.clearance) / worst.r1 + 0.5
else
2.0
end
{max(0, knife_edge_loss(v_blocked)), "BLOCKED"}
end
defp compute_verdict(_obstructed, fresnel_hit) when fresnel_hit != [] do
worst = Enum.min_by(fresnel_hit, & &1.f1_clear)
v_fresnel =
if worst.r1 > 0 do
-worst.f1_clear / worst.r1
else
0
end
db = max(0, knife_edge_loss(v_fresnel))
verdict = if db > 3, do: "FRESNEL_PARTIAL", else: "FRESNEL_MINOR"
{db, verdict}
end
defp compute_verdict(_obstructed, _fresnel_hit) do
{0, "CLEAR"}
end
end

View file

@ -0,0 +1,34 @@
defmodule Microwaveprop.Terrain.TerrainProfile do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "terrain_profiles" do
belongs_to :qso, Microwaveprop.Radio.Qso
field :sample_count, :integer
field :path_points, {:array, :map}
field :max_elevation_m, :float
field :min_clearance_m, :float
field :diffraction_db, :float
field :fresnel_hit_count, :integer
field :obstructed_count, :integer
field :verdict, :string
timestamps(type: :utc_datetime)
end
@required_fields ~w(qso_id sample_count path_points verdict)a
@optional_fields ~w(max_elevation_m min_clearance_m diffraction_db fresnel_hit_count obstructed_count)a
def changeset(profile, attrs) do
profile
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> foreign_key_constraint(:qso_id)
|> unique_constraint([:qso_id])
end
end

View file

@ -6,6 +6,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
alias Microwaveprop.Weather
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Workers.HrrrFetchWorker
alias Microwaveprop.Workers.TerrainProfileWorker
alias Microwaveprop.Workers.WeatherFetchWorker
@asos_radius_km 150
@ -15,6 +16,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
def perform(%Oban.Job{}) do
enqueue_weather_jobs()
enqueue_hrrr_jobs()
enqueue_terrain_jobs()
:ok
end
@ -51,6 +53,29 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
end
end
defp enqueue_terrain_jobs do
qsos = Radio.unprocessed_terrain_qsos()
if qsos != [] do
jobs = build_terrain_jobs(qsos)
if jobs != [] do
Oban.insert_all(jobs)
end
qso_ids = Enum.map(qsos, & &1.id)
Radio.mark_terrain_queued!(qso_ids)
end
end
def build_terrain_jobs(qsos) do
qsos
|> Enum.map(fn qso ->
TerrainProfileWorker.new(%{"qso_id" => qso.id})
end)
|> Enum.uniq_by(fn changeset -> changeset.changes.args end)
end
def build_weather_jobs(qsos) do
qsos
|> Enum.flat_map(&jobs_for_qso/1)

View file

@ -0,0 +1,57 @@
defmodule Microwaveprop.Workers.TerrainProfileWorker do
@moduledoc false
use Oban.Worker, queue: :terrain, max_attempts: 3
alias Microwaveprop.Radio
alias Microwaveprop.Terrain
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Terrain.TerrainAnalysis
@impl Oban.Worker
def perform(%Oban.Job{args: %{"qso_id" => qso_id}}) do
if Terrain.has_terrain_profile?(qso_id) do
:ok
else
qso = Radio.get_qso!(qso_id)
lat1 = qso.pos1["lat"]
lon1 = qso.pos1["lon"] || qso.pos1["lng"]
lat2 = qso.pos2["lat"]
lon2 = qso.pos2["lon"] || qso.pos2["lng"]
dist_km = Decimal.to_float(qso.distance_km)
freq_ghz = Decimal.to_float(qso.band) / 1000
case ElevationClient.fetch_elevation_profile(lat1, lon1, lat2, lon2) do
{:ok, profile} ->
analysis = TerrainAnalysis.analyse(profile, dist_km, freq_ghz)
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(%{
qso_id: qso_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
})
:ok
{:error, reason} ->
{:error, reason}
end
end
end
end

View file

@ -3,6 +3,7 @@ defmodule MicrowavepropWeb.QsoLive.Show do
use MicrowavepropWeb, :live_view
alias Microwaveprop.Radio
alias Microwaveprop.Terrain
alias Microwaveprop.Weather
@impl true
@ -12,6 +13,7 @@ defmodule MicrowavepropWeb.QsoLive.Show do
weather = load_weather(qso)
solar = load_solar(qso)
hrrr = Weather.hrrr_for_qso(qso)
terrain = Terrain.get_terrain_profile(qso.id)
{:ok,
assign(socket,
@ -21,6 +23,8 @@ defmodule MicrowavepropWeb.QsoLive.Show do
soundings: weather.soundings,
solar: solar,
hrrr: hrrr,
terrain: terrain,
terrain_expanded: false,
hrrr_profile_expanded: false,
obs_sort_by: "station_name",
obs_sort_order: "asc",
@ -49,6 +53,10 @@ defmodule MicrowavepropWeb.QsoLive.Show do
{:noreply, assign(socket, hrrr_profile_expanded: !socket.assigns.hrrr_profile_expanded)}
end
def handle_event("toggle_terrain", _params, socket) do
{:noreply, assign(socket, terrain_expanded: !socket.assigns.terrain_expanded)}
end
def handle_event("toggle_profile", %{"id" => id}, socket) do
expanded =
if MapSet.member?(socket.assigns.expanded_soundings, id) do
@ -158,37 +166,70 @@ defmodule MicrowavepropWeb.QsoLive.Show do
<div class="divider" />
<h2 class="text-base font-semibold mb-2">Surface Observations</h2>
<%= if @surface_observations == [] do %>
<p class="text-sm text-base-content/50 italic">No surface observations found nearby.</p>
<h2 class="text-base font-semibold mb-2" id="terrain-heading">Terrain Profile</h2>
<%= if @terrain do %>
<div class="mb-6 border border-base-300 rounded-lg" id="terrain-profile">
<button
phx-click="toggle_terrain"
class="w-full flex items-center justify-between p-4 hover:bg-base-200 transition-colors"
>
<div class="flex items-center gap-4 text-sm flex-wrap">
<span class={[
"badge badge-sm",
terrain_verdict_class(@terrain.verdict)
]}>
{@terrain.verdict}
</span>
<span>{@terrain.sample_count} samples</span>
<span>Max elev: {format_number(@terrain.max_elevation_m)} m</span>
<span>Min clearance: {format_number(@terrain.min_clearance_m)} m</span>
<%= if @terrain.diffraction_db && @terrain.diffraction_db > 0 do %>
<span>Diffraction: {format_number(@terrain.diffraction_db)} dB</span>
<% end %>
<%= if @terrain.obstructed_count && @terrain.obstructed_count > 0 do %>
<span class="text-error">{@terrain.obstructed_count} obstructed</span>
<% end %>
<%= if @terrain.fresnel_hit_count && @terrain.fresnel_hit_count > 0 do %>
<span class="text-warning">{@terrain.fresnel_hit_count} Fresnel hits</span>
<% end %>
</div>
<.icon
name={if @terrain_expanded, do: "hero-chevron-up", else: "hero-chevron-down"}
class="w-5 h-5"
/>
</button>
<%= if @terrain_expanded do %>
<div class="px-4 pb-4">
<div class="overflow-x-auto">
<table class="table table-xs table-zebra">
<thead>
<tr>
<th>#</th>
<th>Lat</th>
<th>Lon</th>
<th>Dist (km)</th>
<th>Elev (m)</th>
</tr>
</thead>
<tbody>
<%= for {pt, idx} <- Enum.with_index(@terrain.path_points) do %>
<tr>
<td>{idx}</td>
<td>{format_number(pt["lat"])}</td>
<td>{format_number(pt["lon"])}</td>
<td>{format_number(pt["dist_km"])}</td>
<td>{format_number(pt["elev"])}</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
</div>
<% else %>
<.table
id="surface-obs"
rows={@surface_observations}
row_id={fn obs -> "obs-#{obs.id}" end}
sort_by={@obs_sort_by}
sort_order={@obs_sort_order}
sort_target="obs"
>
<:col :let={obs} label="Station" sort_field="station_name">
{obs.station.name || obs.station.station_code}
</:col>
<:col :let={obs} label="Time" sort_field="observed_at">
{Calendar.strftime(obs.observed_at, "%H:%M")}
</:col>
<:col :let={obs} label="Temp (F)" sort_field="temp_f">{obs.temp_f || ""}</:col>
<:col :let={obs} label="Dewpoint (F)" sort_field="dewpoint_f">
{obs.dewpoint_f || ""}
</:col>
<:col :let={obs} label="RH%" sort_field="relative_humidity">
{format_number(obs.relative_humidity)}
</:col>
<:col :let={obs} label="Wind">{format_wind(obs)}</:col>
<:col :let={obs} label="Pressure (mb)" sort_field="sea_level_pressure_mb">
{obs.sea_level_pressure_mb || ""}
</:col>
<:col :let={obs} label="Sky">{obs.sky_condition || ""}</:col>
</.table>
<p class="text-sm text-base-content/50 italic">No terrain profile available.</p>
<% end %>
<div class="divider" />
@ -262,6 +303,20 @@ defmodule MicrowavepropWeb.QsoLive.Show do
<div class="divider" />
<h2 class="text-base font-semibold mb-2">Solar Conditions</h2>
<%= if @solar do %>
<.list>
<:item title="SFI">{@solar.sfi || ""}</:item>
<:item title="Sunspot Number">{@solar.sunspot_number || ""}</:item>
<:item title="Ap Index">{@solar.ap_index || ""}</:item>
<:item title="Kp Values">{format_kp(@solar.kp_values)}</:item>
</.list>
<% else %>
<p class="text-sm text-base-content/50 italic">No solar data available for this date.</p>
<% end %>
<div class="divider" />
<h2 class="text-base font-semibold mb-2">HRRR Model Profile</h2>
<%= if @hrrr do %>
<div class="mb-6 border border-base-300 rounded-lg">
@ -333,16 +388,37 @@ defmodule MicrowavepropWeb.QsoLive.Show do
<div class="divider" />
<h2 class="text-base font-semibold mb-2">Solar Conditions</h2>
<%= if @solar do %>
<.list>
<:item title="SFI">{@solar.sfi || ""}</:item>
<:item title="Sunspot Number">{@solar.sunspot_number || ""}</:item>
<:item title="Ap Index">{@solar.ap_index || ""}</:item>
<:item title="Kp Values">{format_kp(@solar.kp_values)}</:item>
</.list>
<h2 class="text-base font-semibold mb-2">Surface Observations</h2>
<%= if @surface_observations == [] do %>
<p class="text-sm text-base-content/50 italic">No surface observations found nearby.</p>
<% else %>
<p class="text-sm text-base-content/50 italic">No solar data available for this date.</p>
<.table
id="surface-obs"
rows={@surface_observations}
row_id={fn obs -> "obs-#{obs.id}" end}
sort_by={@obs_sort_by}
sort_order={@obs_sort_order}
sort_target="obs"
>
<:col :let={obs} label="Station" sort_field="station_name">
{obs.station.name || obs.station.station_code}
</:col>
<:col :let={obs} label="Time" sort_field="observed_at">
{Calendar.strftime(obs.observed_at, "%H:%M")}
</:col>
<:col :let={obs} label="Temp (F)" sort_field="temp_f">{obs.temp_f || ""}</:col>
<:col :let={obs} label="Dewpoint (F)" sort_field="dewpoint_f">
{obs.dewpoint_f || ""}
</:col>
<:col :let={obs} label="RH%" sort_field="relative_humidity">
{format_number(obs.relative_humidity)}
</:col>
<:col :let={obs} label="Wind">{format_wind(obs)}</:col>
<:col :let={obs} label="Pressure (mb)" sort_field="sea_level_pressure_mb">
{obs.sea_level_pressure_mb || ""}
</:col>
<:col :let={obs} label="Sky">{obs.sky_condition || ""}</:col>
</.table>
<% end %>
</Layouts.app>
"""
@ -367,4 +443,10 @@ defmodule MicrowavepropWeb.QsoLive.Show do
defp format_kp(nil), do: ""
defp format_kp(values) when is_list(values), do: Enum.map_join(values, ", ", &format_number/1)
defp terrain_verdict_class("CLEAR"), do: "badge-success"
defp terrain_verdict_class("FRESNEL_MINOR"), do: "badge-warning"
defp terrain_verdict_class("FRESNEL_PARTIAL"), do: "badge-warning"
defp terrain_verdict_class("BLOCKED"), do: "badge-error"
defp terrain_verdict_class(_), do: "badge-ghost"
end

View file

@ -0,0 +1,30 @@
defmodule Microwaveprop.Repo.Migrations.CreateTerrainProfilesAndAddTerrainQueued do
use Ecto.Migration
def change do
create table(:terrain_profiles, primary_key: false) do
add :id, :binary_id, primary_key: true
add :qso_id, references(:qsos, type: :binary_id, on_delete: :delete_all), null: false
add :sample_count, :integer, null: false
add :path_points, {:array, :map}, default: [], null: false
add :max_elevation_m, :float
add :min_clearance_m, :float
add :diffraction_db, :float
add :fresnel_hit_count, :integer
add :obstructed_count, :integer
add :verdict, :string, null: false
timestamps(type: :utc_datetime)
end
create unique_index(:terrain_profiles, [:qso_id])
alter table(:qsos) do
add :terrain_queued, :boolean, default: false, null: false
end
execute "UPDATE qsos SET terrain_queued = false", "SELECT 1"
create index(:qsos, [:terrain_queued], where: "terrain_queued = false")
end
end

View file

@ -281,4 +281,68 @@ defmodule Microwaveprop.RadioTest do
assert Radio.mark_hrrr_queued!([]) == {0, nil}
end
end
describe "unprocessed_terrain_qsos/1" do
test "returns QSOs where terrain_queued is false and both pos1 and pos2 are not nil" do
q1 = create_qso(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
_q2 = create_qso(%{station1: "NOPOS1", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
_q3 = create_qso(%{station1: "NOPOS2", pos2: nil, qso_timestamp: ~U[2026-03-28 14:00:00Z]})
results = Radio.unprocessed_terrain_qsos()
ids = Enum.map(results, & &1.id)
assert q1.id in ids
assert length(ids) == 1
end
test "excludes QSOs already marked as terrain_queued" do
q = create_qso()
Radio.mark_terrain_queued!([q.id])
assert Radio.unprocessed_terrain_qsos() == []
end
test "orders by qso_timestamp ascending" do
q_late = create_qso(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
q_early = create_qso(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
results = Radio.unprocessed_terrain_qsos()
ids = Enum.map(results, & &1.id)
assert ids == [q_early.id, q_late.id]
end
test "respects limit parameter" do
for i <- 1..5 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
create_qso(%{qso_timestamp: ts})
end
assert length(Radio.unprocessed_terrain_qsos(3)) == 3
end
end
describe "mark_terrain_queued!/1" do
test "sets terrain_queued to true for given IDs" do
q1 = create_qso(%{station1: "A1A"})
q2 = create_qso(%{station1: "B2B"})
Radio.mark_terrain_queued!([q1.id, q2.id])
assert Repo.get!(Qso, q1.id).terrain_queued == true
assert Repo.get!(Qso, q2.id).terrain_queued == true
end
test "does not affect other QSOs" do
q1 = create_qso(%{station1: "A1A"})
q2 = create_qso(%{station1: "B2B"})
Radio.mark_terrain_queued!([q1.id])
assert Repo.get!(Qso, q1.id).terrain_queued == true
assert Repo.get!(Qso, q2.id).terrain_queued == false
end
test "handles empty list" do
assert Radio.mark_terrain_queued!([]) == {0, nil}
end
end
end

View file

@ -0,0 +1,113 @@
defmodule Microwaveprop.Terrain.ElevationClientTest do
use ExUnit.Case, async: true
alias Microwaveprop.Terrain.ElevationClient
describe "sample_path/5" do
test "generates N+1 evenly spaced points" do
points = ElevationClient.sample_path(32.9, -97.0, 30.3, -97.7, 4)
assert length(points) == 5
first = hd(points)
last = List.last(points)
assert_in_delta first.lat, 32.9, 0.001
assert_in_delta first.lon, -97.0, 0.001
assert_in_delta first.d, 0.0, 0.001
assert_in_delta last.lat, 30.3, 0.001
assert_in_delta last.lon, -97.7, 0.001
assert_in_delta last.d, 1.0, 0.001
end
test "midpoint is correct" do
points = ElevationClient.sample_path(0.0, 0.0, 10.0, 10.0, 2)
mid = Enum.at(points, 1)
assert_in_delta mid.lat, 5.0, 0.001
assert_in_delta mid.lon, 5.0, 0.001
assert_in_delta mid.d, 0.5, 0.001
end
end
describe "fetch_elevation_profile/5" do
test "returns profile with elevations from Open-Meteo" do
Req.Test.stub(ElevationClient, fn conn ->
Req.Test.json(conn, %{"elevation" => [200.0, 250.0, 180.0]})
end)
assert {:ok, profile} =
ElevationClient.fetch_elevation_profile(32.9, -97.0, 30.3, -97.7, 2)
assert length(profile) == 3
first = hd(profile)
assert Map.has_key?(first, :lat)
assert Map.has_key?(first, :lon)
assert Map.has_key?(first, :elev)
assert Map.has_key?(first, :dist_km)
assert first.elev == 200.0
assert_in_delta first.dist_km, 0.0, 0.001
end
test "batches requests when more than 100 points" do
call_count = :counters.new(1, [:atomics])
Req.Test.stub(ElevationClient, fn conn ->
:counters.add(call_count, 1, 1)
# Return 101 elevations for first batch, or however many requested
params = Plug.Conn.fetch_query_params(conn).query_params
lat_count = params["latitude"] |> String.split(",") |> length()
elevations = List.duplicate(100.0, lat_count)
Req.Test.json(conn, %{"elevation" => elevations})
end)
assert {:ok, profile} =
ElevationClient.fetch_elevation_profile(32.9, -97.0, 30.3, -97.7, 150)
# 151 points → 2 batches (100 + 51)
assert length(profile) == 151
assert :counters.get(call_count, 1) == 2
end
test "falls back to Open-Topo-Data on Open-Meteo failure" do
call_count = :counters.new(1, [:atomics])
Req.Test.stub(ElevationClient, fn conn ->
:counters.add(call_count, 1, 1)
current = :counters.get(call_count, 1)
if current == 1 do
# First call (Open-Meteo) fails
Plug.Conn.send_resp(conn, 500, "error")
else
# Second call (Open-Topo-Data) succeeds
Req.Test.json(conn, %{
"status" => "OK",
"results" => [
%{"elevation" => 100.0},
%{"elevation" => 150.0},
%{"elevation" => 120.0}
]
})
end
end)
assert {:ok, profile} =
ElevationClient.fetch_elevation_profile(32.9, -97.0, 30.3, -97.7, 2)
assert length(profile) == 3
assert hd(profile).elev == 100.0
end
test "returns error when both APIs fail" do
Req.Test.stub(ElevationClient, fn conn ->
Plug.Conn.send_resp(conn, 500, "error")
end)
assert {:error, _reason} =
ElevationClient.fetch_elevation_profile(32.9, -97.0, 30.3, -97.7, 2)
end
end
end

View file

@ -0,0 +1,175 @@
defmodule Microwaveprop.Terrain.TerrainAnalysisTest do
use ExUnit.Case, async: true
alias Microwaveprop.Terrain.TerrainAnalysis
describe "fresnel_radius/3" do
test "returns 0 when d1 or d2 is 0" do
assert TerrainAnalysis.fresnel_radius(0, 1000, 0.023) == 0
assert TerrainAnalysis.fresnel_radius(1000, 0, 0.023) == 0
end
test "computes correct Fresnel radius for known values" do
# 1296 MHz -> lambda = 0.2315 m
lambda = 0.3 / 1.296
d1 = 50_000.0
d2 = 50_000.0
r = TerrainAnalysis.fresnel_radius(d1, d2, lambda)
assert_in_delta r, 76.1, 1.0
end
end
describe "earth_bulge/3" do
test "returns 0 at endpoints" do
assert TerrainAnalysis.earth_bulge(0.0, 100.0) == 0.0
assert TerrainAnalysis.earth_bulge(1.0, 100.0) == 0.0
end
test "maximum bulge at midpoint of 100 km path" do
bulge = TerrainAnalysis.earth_bulge(0.5, 100.0)
assert_in_delta bulge, 147.0, 2.0
end
test "bulge increases with path length" do
bulge_50 = TerrainAnalysis.earth_bulge(0.5, 50.0)
bulge_100 = TerrainAnalysis.earth_bulge(0.5, 100.0)
assert bulge_100 > bulge_50
end
end
describe "knife_edge_loss/1" do
test "returns 0 for v <= -0.7787 (clear path)" do
assert TerrainAnalysis.knife_edge_loss(-1.0) == 0
assert TerrainAnalysis.knife_edge_loss(-0.8) == 0
end
test "returns ~6 dB for v = 0 (grazing)" do
loss = TerrainAnalysis.knife_edge_loss(0.0)
assert_in_delta loss, 6.0, 0.5
end
test "loss increases with v > 0" do
loss_05 = TerrainAnalysis.knife_edge_loss(0.5)
loss_10 = TerrainAnalysis.knife_edge_loss(1.0)
loss_20 = TerrainAnalysis.knife_edge_loss(2.0)
assert loss_05 > 0
assert loss_10 > loss_05
assert loss_20 > loss_10
end
test "asymptotic formula for v > 2.4" do
loss = TerrainAnalysis.knife_edge_loss(3.0)
assert_in_delta loss, 22.5, 0.5
end
end
describe "analyse/5" do
test "returns CLEAR for flat terrain with antenna heights" do
# 10 km path, flat terrain at 0m, antennas at 30m each
# Earth bulge at midpoint ~ 1.5m, beam at 30m, plenty of clearance
profile =
for i <- 0..10 do
f = i / 10
%{lat: 32.9 + f * 0.09, lon: -97.0, d: f, elev: 0.0, dist_km: f * 10.0}
end
result = TerrainAnalysis.analyse(profile, 10.0, 1.296, 30.0, 30.0)
assert result.verdict == "CLEAR"
assert result.obstructed_count == 0
assert result.diffraction_db == 0
end
test "returns BLOCKED for high obstacle" do
# 50 km path, endpoints at 100m, 500m peak in middle
profile =
for i <- 0..10 do
f = i / 10
elev = if i == 5, do: 500.0, else: 100.0
%{lat: 32.9 + f, lon: -97.0, d: f, elev: elev, dist_km: f * 50.0}
end
result = TerrainAnalysis.analyse(profile, 50.0, 1.296)
assert result.verdict == "BLOCKED"
assert result.obstructed_count >= 1
assert result.diffraction_db > 0
end
test "max_elevation_m reflects the terrain peak" do
profile =
for i <- 0..4 do
f = i / 4
elev = if i == 2, do: 500.0, else: 100.0
%{lat: 32.9 + f, lon: -97.0, d: f, elev: elev, dist_km: f * 50.0}
end
result = TerrainAnalysis.analyse(profile, 50.0, 1.296)
assert_in_delta result.max_elevation_m, 500.0, 0.1
end
test "elevated endpoints clear over flat terrain" do
# 20 km path, terrain at 0m, endpoints at 200m (hilltops)
# Earth bulge midpoint ~ 5.9m, beam at 200m, terrain + bulge = 5.9m
profile =
for i <- 0..10 do
f = i / 10
%{lat: 32.9 + f * 0.18, lon: -97.0, d: f, elev: 0.0, dist_km: f * 20.0}
end
# Use antenna heights to represent elevated positions
result = TerrainAnalysis.analyse(profile, 20.0, 1.296, 200.0, 200.0)
assert result.verdict == "CLEAR"
end
test "returns fresnel verdict for moderate ridge" do
# 20 km path, endpoints at 200m elevation, small ridge at midpoint
# Beam at midpoint = 200m, earth bulge ~ 5.9m
# Ridge at 160m + bulge 5.9m = 165.9m effective -> clearance = 200 - 165.9 = 34.1m
# Fresnel r1 at midpoint 1296 MHz 20km = sqrt(0.2315*10000*10000/20000) ~ 34m
# f1_clear = 34.1 - 34 = 0.1m -> just barely clear
# Need ridge slightly higher: 165m + 5.9 = 170.9 -> clearance 29.1, f1_clear = -4.9 -> penetrated
profile =
for i <- 0..10 do
f = i / 10
elev = if i == 5, do: 165.0, else: 0.0
%{lat: 32.9 + f * 0.18, lon: -97.0, d: f, elev: elev, dist_km: f * 20.0}
end
result = TerrainAnalysis.analyse(profile, 20.0, 1.296, 200.0, 200.0)
assert result.verdict in ["FRESNEL_MINOR", "FRESNEL_PARTIAL"]
assert result.fresnel_hit_count >= 1
end
test "antenna heights raise beam above terrain" do
# 10 km path, 30m hill, no antenna height -> blocked (beam at 0m, terrain+bulge > 0)
# With 50m antennas -> clear
profile =
for i <- 0..10 do
f = i / 10
elev = if i == 5, do: 30.0, else: 0.0
%{lat: 32.9 + f * 0.09, lon: -97.0, d: f, elev: elev, dist_km: f * 10.0}
end
result_low = TerrainAnalysis.analyse(profile, 10.0, 1.296, 0.0, 0.0)
result_high = TerrainAnalysis.analyse(profile, 10.0, 1.296, 100.0, 100.0)
assert result_low.verdict == "BLOCKED"
assert result_high.verdict == "CLEAR"
end
test "min_clearance_m can be negative for obstructed paths" do
profile =
for i <- 0..4 do
f = i / 4
elev = if i == 2, do: 500.0, else: 100.0
%{lat: 32.9 + f, lon: -97.0, d: f, elev: elev, dist_km: f * 50.0}
end
result = TerrainAnalysis.analyse(profile, 50.0, 1.296)
assert result.min_clearance_m < 0
end
end
end

View file

@ -0,0 +1,94 @@
defmodule Microwaveprop.Terrain.TerrainProfileTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Terrain.TerrainProfile
@qso_attrs %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: ~U[2026-03-28 18:00:00Z],
mode: "CW",
band: Decimal.new("1296"),
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7}
}
defp create_qso do
{:ok, qso} =
%Qso{}
|> Qso.changeset(@qso_attrs)
|> Repo.insert()
qso
end
describe "changeset/2" do
test "valid with required fields" do
qso = create_qso()
attrs = %{
qso_id: qso.id,
sample_count: 65,
path_points: [%{"lat" => 32.9, "lon" => -97.0, "elev" => 200.0, "dist_km" => 0.0}],
verdict: "CLEAR"
}
changeset = TerrainProfile.changeset(%TerrainProfile{}, attrs)
assert changeset.valid?
end
test "invalid without required fields" do
changeset = TerrainProfile.changeset(%TerrainProfile{}, %{})
refute changeset.valid?
errors = errors_on(changeset)
assert errors[:qso_id]
assert errors[:sample_count]
assert errors[:path_points]
assert errors[:verdict]
end
test "valid with all optional fields" do
qso = create_qso()
attrs = %{
qso_id: qso.id,
sample_count: 65,
path_points: [%{"lat" => 32.9, "lon" => -97.0, "elev" => 200.0, "dist_km" => 0.0}],
verdict: "BLOCKED",
max_elevation_m: 450.0,
min_clearance_m: -12.5,
diffraction_db: 8.3,
fresnel_hit_count: 3,
obstructed_count: 1
}
changeset = TerrainProfile.changeset(%TerrainProfile{}, attrs)
assert changeset.valid?
end
test "enforces unique qso_id constraint on insert" do
qso = create_qso()
attrs = %{
qso_id: qso.id,
sample_count: 65,
path_points: [],
verdict: "CLEAR"
}
{:ok, _} =
%TerrainProfile{}
|> TerrainProfile.changeset(attrs)
|> Repo.insert()
{:error, changeset} =
%TerrainProfile{}
|> TerrainProfile.changeset(attrs)
|> Repo.insert()
assert errors_on(changeset)[:qso_id]
end
end
end

View file

@ -0,0 +1,96 @@
defmodule Microwaveprop.TerrainTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Terrain
@qso_attrs %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: ~U[2026-03-28 18:00:00Z],
mode: "CW",
band: Decimal.new("1296"),
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7}
}
defp create_qso do
{:ok, qso} =
%Qso{}
|> Qso.changeset(@qso_attrs)
|> Repo.insert()
qso
end
defp terrain_attrs(qso) do
%{
qso_id: qso.id,
sample_count: 65,
path_points: [
%{"lat" => 32.9, "lon" => -97.0, "elev" => 200.0, "dist_km" => 0.0},
%{"lat" => 30.3, "lon" => -97.7, "elev" => 180.0, "dist_km" => 295.0}
],
verdict: "CLEAR",
max_elevation_m: 450.0,
min_clearance_m: 120.0,
diffraction_db: 0.0,
fresnel_hit_count: 0,
obstructed_count: 0
}
end
describe "upsert_terrain_profile/1" do
test "inserts a new terrain profile" do
qso = create_qso()
attrs = terrain_attrs(qso)
assert {:ok, profile} = Terrain.upsert_terrain_profile(attrs)
assert profile.qso_id == qso.id
assert profile.sample_count == 65
assert profile.verdict == "CLEAR"
end
test "upserts on conflict (same qso_id)" do
qso = create_qso()
attrs = terrain_attrs(qso)
{:ok, _} = Terrain.upsert_terrain_profile(attrs)
updated_attrs = Map.merge(attrs, %{verdict: "BLOCKED", diffraction_db: 6.5})
{:ok, profile} = Terrain.upsert_terrain_profile(updated_attrs)
assert profile.verdict == "BLOCKED"
assert profile.diffraction_db == 6.5
end
end
describe "get_terrain_profile/1" do
test "returns profile for a QSO" do
qso = create_qso()
{:ok, _} = Terrain.upsert_terrain_profile(terrain_attrs(qso))
profile = Terrain.get_terrain_profile(qso.id)
assert profile.qso_id == qso.id
end
test "returns nil when no profile exists" do
qso = create_qso()
assert Terrain.get_terrain_profile(qso.id) == nil
end
end
describe "has_terrain_profile?/1" do
test "returns true when profile exists" do
qso = create_qso()
{:ok, _} = Terrain.upsert_terrain_profile(terrain_attrs(qso))
assert Terrain.has_terrain_profile?(qso.id)
end
test "returns false when no profile exists" do
qso = create_qso()
refute Terrain.has_terrain_profile?(qso.id)
end
end
end

View file

@ -2,6 +2,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Weather
alias Microwaveprop.Weather.IemClient
alias Microwaveprop.Workers.QsoWeatherEnqueueWorker
@ -128,6 +129,13 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
end
end)
# Stub elevation API for terrain profile workers
Req.Test.stub(ElevationClient, fn conn ->
params = Plug.Conn.fetch_query_params(conn).query_params
lat_count = params["latitude"] |> String.split(",") |> length()
Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
end)
:ok
end
@ -228,4 +236,61 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
assert QsoWeatherEnqueueWorker.build_hrrr_jobs([qso]) == []
end
end
describe "build_terrain_jobs/1" do
test "builds one terrain job per QSO" do
qso = create_qso()
jobs = QsoWeatherEnqueueWorker.build_terrain_jobs([qso])
assert length(jobs) == 1
job = hd(jobs)
assert job.changes.args["qso_id"] == qso.id
end
test "deduplicates by qso_id" do
qso = create_qso()
jobs = QsoWeatherEnqueueWorker.build_terrain_jobs([qso, qso])
assert length(jobs) == 1
end
end
describe "perform/1 terrain enqueue" do
setup do
Req.Test.stub(IemClient, fn conn ->
case conn.request_path do
"/cgi-bin/request/asos.py" -> Req.Test.text(conn, "#DEBUG,\nstation,valid,tmpf\n")
_ -> Req.Test.json(conn, %{"profiles" => []})
end
end)
Req.Test.stub(ElevationClient, fn conn ->
params = Plug.Conn.fetch_query_params(conn).query_params
lat_count = params["latitude"] |> String.split(",") |> length()
Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
end)
:ok
end
test "enqueues terrain jobs and marks QSOs as terrain_queued" do
qso = create_qso()
assert qso.terrain_queued == false
assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
updated = Repo.get!(Qso, qso.id)
assert updated.terrain_queued == true
end
test "skips terrain for QSOs missing pos2" do
_qso = create_qso(%{pos2: nil})
assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
all_qsos = Repo.all(Qso)
refute Enum.any?(all_qsos, & &1.terrain_queued)
end
end
end

View file

@ -0,0 +1,95 @@
defmodule Microwaveprop.Workers.TerrainProfileWorkerTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Terrain
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Workers.TerrainProfileWorker
@qso_attrs %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: ~U[2026-03-28 18:00:00Z],
mode: "CW",
band: Decimal.new("1296"),
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("295")
}
defp create_qso(attrs \\ %{}) do
{:ok, qso} =
%Qso{}
|> Qso.changeset(Map.merge(@qso_attrs, attrs))
|> Repo.insert()
qso
end
defp stub_elevation_api do
Req.Test.stub(ElevationClient, fn conn ->
params = Plug.Conn.fetch_query_params(conn).query_params
lat_count = params["latitude"] |> String.split(",") |> length()
elevations = List.duplicate(200.0, lat_count)
Req.Test.json(conn, %{"elevation" => elevations})
end)
end
describe "perform/1" do
test "fetches elevation and stores terrain profile" do
stub_elevation_api()
qso = create_qso()
refute Terrain.has_terrain_profile?(qso.id)
job = TerrainProfileWorker.new(%{"qso_id" => qso.id})
assert :ok = TerrainProfileWorker.perform(%Oban.Job{args: job.changes.args})
assert Terrain.has_terrain_profile?(qso.id)
profile = Terrain.get_terrain_profile(qso.id)
assert profile.sample_count == 65
assert profile.verdict in ["CLEAR", "FRESNEL_MINOR", "FRESNEL_PARTIAL", "BLOCKED"]
assert is_list(profile.path_points)
end
test "skips if terrain profile already exists" do
stub_elevation_api()
qso = create_qso()
{:ok, _} =
Terrain.upsert_terrain_profile(%{
qso_id: qso.id,
sample_count: 65,
path_points: [],
verdict: "CLEAR"
})
job = TerrainProfileWorker.new(%{"qso_id" => qso.id})
assert :ok = TerrainProfileWorker.perform(%Oban.Job{args: job.changes.args})
end
test "returns error when elevation API fails" do
Req.Test.stub(ElevationClient, fn conn ->
Plug.Conn.send_resp(conn, 500, "error")
end)
qso = create_qso()
job = TerrainProfileWorker.new(%{"qso_id" => qso.id})
assert {:error, _} = TerrainProfileWorker.perform(%Oban.Job{args: job.changes.args})
refute Terrain.has_terrain_profile?(qso.id)
end
test "uses correct frequency from band" do
stub_elevation_api()
qso = create_qso(%{band: Decimal.new("10368")})
job = TerrainProfileWorker.new(%{"qso_id" => qso.id})
assert :ok = TerrainProfileWorker.perform(%Oban.Job{args: job.changes.args})
profile = Terrain.get_terrain_profile(qso.id)
assert profile.verdict
end
end
end