diff --git a/Dockerfile b/Dockerfile index d17863d6..2eda5970 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/config/config.exs b/config/config.exs index 06d9b973..ba47cae6 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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, diff --git a/config/test.exs b/config/test.exs index e49ddac0..6a5bb192 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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 diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex index 5f2c2e49..d3608f52 100644 --- a/lib/microwaveprop/radio.ex +++ b/lib/microwaveprop/radio.ex @@ -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 diff --git a/lib/microwaveprop/radio/qso.ex b/lib/microwaveprop/radio/qso.ex index d87c649f..5dc3bec4 100644 --- a/lib/microwaveprop/radio/qso.ex +++ b/lib/microwaveprop/radio/qso.ex @@ -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 diff --git a/lib/microwaveprop/terrain.ex b/lib/microwaveprop/terrain.ex new file mode 100644 index 00000000..e36bd6eb --- /dev/null +++ b/lib/microwaveprop/terrain.ex @@ -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 diff --git a/lib/microwaveprop/terrain/elevation_client.ex b/lib/microwaveprop/terrain/elevation_client.ex new file mode 100644 index 00000000..c883fc1c --- /dev/null +++ b/lib/microwaveprop/terrain/elevation_client.ex @@ -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 diff --git a/lib/microwaveprop/terrain/terrain_analysis.ex b/lib/microwaveprop/terrain/terrain_analysis.ex new file mode 100644 index 00000000..e9109163 --- /dev/null +++ b/lib/microwaveprop/terrain/terrain_analysis.ex @@ -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 diff --git a/lib/microwaveprop/terrain/terrain_profile.ex b/lib/microwaveprop/terrain/terrain_profile.ex new file mode 100644 index 00000000..b4a71f6d --- /dev/null +++ b/lib/microwaveprop/terrain/terrain_profile.ex @@ -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 diff --git a/lib/microwaveprop/workers/qso_weather_enqueue_worker.ex b/lib/microwaveprop/workers/qso_weather_enqueue_worker.ex index 3782f720..db84d907 100644 --- a/lib/microwaveprop/workers/qso_weather_enqueue_worker.ex +++ b/lib/microwaveprop/workers/qso_weather_enqueue_worker.ex @@ -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) diff --git a/lib/microwaveprop/workers/terrain_profile_worker.ex b/lib/microwaveprop/workers/terrain_profile_worker.ex new file mode 100644 index 00000000..2978be62 --- /dev/null +++ b/lib/microwaveprop/workers/terrain_profile_worker.ex @@ -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 diff --git a/lib/microwaveprop_web/live/qso_live/show.ex b/lib/microwaveprop_web/live/qso_live/show.ex index ff2f136d..9260dc8f 100644 --- a/lib/microwaveprop_web/live/qso_live/show.ex +++ b/lib/microwaveprop_web/live/qso_live/show.ex @@ -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
-

Surface Observations

- <%= if @surface_observations == [] do %> -

No surface observations found nearby.

+

Terrain Profile

+ <%= if @terrain do %> +
+ + + <%= if @terrain_expanded do %> +
+
+ + + + + + + + + + + + <%= for {pt, idx} <- Enum.with_index(@terrain.path_points) do %> + + + + + + + + <% end %> + +
#LatLonDist (km)Elev (m)
{idx}{format_number(pt["lat"])}{format_number(pt["lon"])}{format_number(pt["dist_km"])}{format_number(pt["elev"])}
+
+
+ <% end %> +
<% 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 :let={obs} label="Time" sort_field="observed_at"> - {Calendar.strftime(obs.observed_at, "%H:%M")} - - <:col :let={obs} label="Temp (F)" sort_field="temp_f">{obs.temp_f || "—"} - <:col :let={obs} label="Dewpoint (F)" sort_field="dewpoint_f"> - {obs.dewpoint_f || "—"} - - <:col :let={obs} label="RH%" sort_field="relative_humidity"> - {format_number(obs.relative_humidity)} - - <:col :let={obs} label="Wind">{format_wind(obs)} - <:col :let={obs} label="Pressure (mb)" sort_field="sea_level_pressure_mb"> - {obs.sea_level_pressure_mb || "—"} - - <:col :let={obs} label="Sky">{obs.sky_condition || "—"} - +

No terrain profile available.

<% end %>
@@ -262,6 +303,20 @@ defmodule MicrowavepropWeb.QsoLive.Show do
+

Solar Conditions

+ <%= if @solar do %> + <.list> + <:item title="SFI">{@solar.sfi || "—"} + <:item title="Sunspot Number">{@solar.sunspot_number || "—"} + <:item title="Ap Index">{@solar.ap_index || "—"} + <:item title="Kp Values">{format_kp(@solar.kp_values)} + + <% else %> +

No solar data available for this date.

+ <% end %> + +
+

HRRR Model Profile

<%= if @hrrr do %>
@@ -333,16 +388,37 @@ defmodule MicrowavepropWeb.QsoLive.Show do
-

Solar Conditions

- <%= if @solar do %> - <.list> - <:item title="SFI">{@solar.sfi || "—"} - <:item title="Sunspot Number">{@solar.sunspot_number || "—"} - <:item title="Ap Index">{@solar.ap_index || "—"} - <:item title="Kp Values">{format_kp(@solar.kp_values)} - +

Surface Observations

+ <%= if @surface_observations == [] do %> +

No surface observations found nearby.

<% else %> -

No solar data available for this date.

+ <.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 :let={obs} label="Time" sort_field="observed_at"> + {Calendar.strftime(obs.observed_at, "%H:%M")} + + <:col :let={obs} label="Temp (F)" sort_field="temp_f">{obs.temp_f || "—"} + <:col :let={obs} label="Dewpoint (F)" sort_field="dewpoint_f"> + {obs.dewpoint_f || "—"} + + <:col :let={obs} label="RH%" sort_field="relative_humidity"> + {format_number(obs.relative_humidity)} + + <:col :let={obs} label="Wind">{format_wind(obs)} + <:col :let={obs} label="Pressure (mb)" sort_field="sea_level_pressure_mb"> + {obs.sea_level_pressure_mb || "—"} + + <:col :let={obs} label="Sky">{obs.sky_condition || "—"} + <% end %> """ @@ -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 diff --git a/priv/repo/migrations/20260329210536_create_terrain_profiles_and_add_terrain_queued.exs b/priv/repo/migrations/20260329210536_create_terrain_profiles_and_add_terrain_queued.exs new file mode 100644 index 00000000..185bdedb --- /dev/null +++ b/priv/repo/migrations/20260329210536_create_terrain_profiles_and_add_terrain_queued.exs @@ -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 diff --git a/test/microwaveprop/radio_test.exs b/test/microwaveprop/radio_test.exs index 99128661..bbfb41d4 100644 --- a/test/microwaveprop/radio_test.exs +++ b/test/microwaveprop/radio_test.exs @@ -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 diff --git a/test/microwaveprop/terrain/elevation_client_test.exs b/test/microwaveprop/terrain/elevation_client_test.exs new file mode 100644 index 00000000..37f56918 --- /dev/null +++ b/test/microwaveprop/terrain/elevation_client_test.exs @@ -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 diff --git a/test/microwaveprop/terrain/terrain_analysis_test.exs b/test/microwaveprop/terrain/terrain_analysis_test.exs new file mode 100644 index 00000000..63e1e478 --- /dev/null +++ b/test/microwaveprop/terrain/terrain_analysis_test.exs @@ -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 diff --git a/test/microwaveprop/terrain/terrain_profile_test.exs b/test/microwaveprop/terrain/terrain_profile_test.exs new file mode 100644 index 00000000..e8d4c90f --- /dev/null +++ b/test/microwaveprop/terrain/terrain_profile_test.exs @@ -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 diff --git a/test/microwaveprop/terrain_test.exs b/test/microwaveprop/terrain_test.exs new file mode 100644 index 00000000..9da90bc4 --- /dev/null +++ b/test/microwaveprop/terrain_test.exs @@ -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 diff --git a/test/microwaveprop/workers/qso_weather_enqueue_worker_test.exs b/test/microwaveprop/workers/qso_weather_enqueue_worker_test.exs index cb2bcdd6..ccd11805 100644 --- a/test/microwaveprop/workers/qso_weather_enqueue_worker_test.exs +++ b/test/microwaveprop/workers/qso_weather_enqueue_worker_test.exs @@ -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 diff --git a/test/microwaveprop/workers/terrain_profile_worker_test.exs b/test/microwaveprop/workers/terrain_profile_worker_test.exs new file mode 100644 index 00000000..77f7844b --- /dev/null +++ b/test/microwaveprop/workers/terrain_profile_worker_test.exs @@ -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