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:
parent
42b2ac920b
commit
45e2e69361
20 changed files with 1275 additions and 41 deletions
|
|
@ -22,7 +22,7 @@ FROM ${BUILDER_IMAGE} AS builder
|
||||||
|
|
||||||
# install build dependencies and build wgrib2
|
# install build dependencies and build wgrib2
|
||||||
RUN apt-get update \
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN wget -q https://www.ftp.cpc.ncep.noaa.gov/wd51we/wgrib2/wgrib2.tgz \
|
RUN wget -q https://www.ftp.cpc.ncep.noaa.gov/wd51we/wgrib2/wgrib2.tgz \
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ config :microwaveprop, MicrowavepropWeb.Endpoint,
|
||||||
|
|
||||||
config :microwaveprop, Oban,
|
config :microwaveprop, Oban,
|
||||||
repo: Microwaveprop.Repo,
|
repo: Microwaveprop.Repo,
|
||||||
queues: [solar: 1, weather: 3, enqueue: 1, hrrr: 2],
|
queues: [solar: 1, weather: 3, enqueue: 1, hrrr: 2, terrain: 2],
|
||||||
plugins: [
|
plugins: [
|
||||||
{Oban.Plugins.Pruner, max_age: 3600 * 24},
|
{Oban.Plugins.Pruner, max_age: 3600 * 24},
|
||||||
{Oban.Plugins.Cron,
|
{Oban.Plugins.Cron,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ config :microwaveprop, MicrowavepropWeb.Endpoint,
|
||||||
|
|
||||||
# Run Oban jobs inline during tests
|
# Run Oban jobs inline during tests
|
||||||
config :microwaveprop, Oban, testing: :inline
|
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]
|
config :microwaveprop, iem_req_options: [plug: {Req.Test, Microwaveprop.Weather.IemClient}, retry: false]
|
||||||
|
|
||||||
# Route HTTP requests through Req.Test stubs
|
# Route HTTP requests through Req.Test stubs
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,20 @@ defmodule Microwaveprop.Radio do
|
||||||
|> Repo.update_all(set: [hrrr_queued: true])
|
|> Repo.update_all(set: [hrrr_queued: true])
|
||||||
end
|
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
|
@earth_radius_km 6371.0
|
||||||
|
|
||||||
def haversine_km(lat1, lon1, lat2, lon2) do
|
def haversine_km(lat1, lon1, lat2, lon2) do
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ defmodule Microwaveprop.Radio.Qso do
|
||||||
field :distance_km, :decimal
|
field :distance_km, :decimal
|
||||||
field :weather_queued, :boolean, default: false
|
field :weather_queued, :boolean, default: false
|
||||||
field :hrrr_queued, :boolean, default: false
|
field :hrrr_queued, :boolean, default: false
|
||||||
|
field :terrain_queued, :boolean, default: false
|
||||||
|
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
28
lib/microwaveprop/terrain.ex
Normal file
28
lib/microwaveprop/terrain.ex
Normal 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
|
||||||
114
lib/microwaveprop/terrain/elevation_client.ex
Normal file
114
lib/microwaveprop/terrain/elevation_client.ex
Normal 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
|
||||||
146
lib/microwaveprop/terrain/terrain_analysis.ex
Normal file
146
lib/microwaveprop/terrain/terrain_analysis.ex
Normal 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
|
||||||
34
lib/microwaveprop/terrain/terrain_profile.ex
Normal file
34
lib/microwaveprop/terrain/terrain_profile.ex
Normal 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
|
||||||
|
|
@ -6,6 +6,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
|
||||||
alias Microwaveprop.Weather
|
alias Microwaveprop.Weather
|
||||||
alias Microwaveprop.Weather.HrrrClient
|
alias Microwaveprop.Weather.HrrrClient
|
||||||
alias Microwaveprop.Workers.HrrrFetchWorker
|
alias Microwaveprop.Workers.HrrrFetchWorker
|
||||||
|
alias Microwaveprop.Workers.TerrainProfileWorker
|
||||||
alias Microwaveprop.Workers.WeatherFetchWorker
|
alias Microwaveprop.Workers.WeatherFetchWorker
|
||||||
|
|
||||||
@asos_radius_km 150
|
@asos_radius_km 150
|
||||||
|
|
@ -15,6 +16,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
|
||||||
def perform(%Oban.Job{}) do
|
def perform(%Oban.Job{}) do
|
||||||
enqueue_weather_jobs()
|
enqueue_weather_jobs()
|
||||||
enqueue_hrrr_jobs()
|
enqueue_hrrr_jobs()
|
||||||
|
enqueue_terrain_jobs()
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
@ -51,6 +53,29 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
|
||||||
end
|
end
|
||||||
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
|
def build_weather_jobs(qsos) do
|
||||||
qsos
|
qsos
|
||||||
|> Enum.flat_map(&jobs_for_qso/1)
|
|> Enum.flat_map(&jobs_for_qso/1)
|
||||||
|
|
|
||||||
57
lib/microwaveprop/workers/terrain_profile_worker.ex
Normal file
57
lib/microwaveprop/workers/terrain_profile_worker.ex
Normal 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
|
||||||
|
|
@ -3,6 +3,7 @@ defmodule MicrowavepropWeb.QsoLive.Show do
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
alias Microwaveprop.Radio
|
alias Microwaveprop.Radio
|
||||||
|
alias Microwaveprop.Terrain
|
||||||
alias Microwaveprop.Weather
|
alias Microwaveprop.Weather
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -12,6 +13,7 @@ defmodule MicrowavepropWeb.QsoLive.Show do
|
||||||
weather = load_weather(qso)
|
weather = load_weather(qso)
|
||||||
solar = load_solar(qso)
|
solar = load_solar(qso)
|
||||||
hrrr = Weather.hrrr_for_qso(qso)
|
hrrr = Weather.hrrr_for_qso(qso)
|
||||||
|
terrain = Terrain.get_terrain_profile(qso.id)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
assign(socket,
|
assign(socket,
|
||||||
|
|
@ -21,6 +23,8 @@ defmodule MicrowavepropWeb.QsoLive.Show do
|
||||||
soundings: weather.soundings,
|
soundings: weather.soundings,
|
||||||
solar: solar,
|
solar: solar,
|
||||||
hrrr: hrrr,
|
hrrr: hrrr,
|
||||||
|
terrain: terrain,
|
||||||
|
terrain_expanded: false,
|
||||||
hrrr_profile_expanded: false,
|
hrrr_profile_expanded: false,
|
||||||
obs_sort_by: "station_name",
|
obs_sort_by: "station_name",
|
||||||
obs_sort_order: "asc",
|
obs_sort_order: "asc",
|
||||||
|
|
@ -49,6 +53,10 @@ defmodule MicrowavepropWeb.QsoLive.Show do
|
||||||
{:noreply, assign(socket, hrrr_profile_expanded: !socket.assigns.hrrr_profile_expanded)}
|
{:noreply, assign(socket, hrrr_profile_expanded: !socket.assigns.hrrr_profile_expanded)}
|
||||||
end
|
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
|
def handle_event("toggle_profile", %{"id" => id}, socket) do
|
||||||
expanded =
|
expanded =
|
||||||
if MapSet.member?(socket.assigns.expanded_soundings, id) do
|
if MapSet.member?(socket.assigns.expanded_soundings, id) do
|
||||||
|
|
@ -158,37 +166,70 @@ defmodule MicrowavepropWeb.QsoLive.Show do
|
||||||
|
|
||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
|
|
||||||
<h2 class="text-base font-semibold mb-2">Surface Observations</h2>
|
<h2 class="text-base font-semibold mb-2" id="terrain-heading">Terrain Profile</h2>
|
||||||
<%= if @surface_observations == [] do %>
|
<%= if @terrain do %>
|
||||||
<p class="text-sm text-base-content/50 italic">No surface observations found nearby.</p>
|
<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 %>
|
<% else %>
|
||||||
<.table
|
<p class="text-sm text-base-content/50 italic">No terrain profile available.</p>
|
||||||
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 %>
|
<% end %>
|
||||||
|
|
||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
|
|
@ -262,6 +303,20 @@ defmodule MicrowavepropWeb.QsoLive.Show do
|
||||||
|
|
||||||
<div class="divider" />
|
<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>
|
<h2 class="text-base font-semibold mb-2">HRRR Model Profile</h2>
|
||||||
<%= if @hrrr do %>
|
<%= if @hrrr do %>
|
||||||
<div class="mb-6 border border-base-300 rounded-lg">
|
<div class="mb-6 border border-base-300 rounded-lg">
|
||||||
|
|
@ -333,16 +388,37 @@ defmodule MicrowavepropWeb.QsoLive.Show do
|
||||||
|
|
||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
|
|
||||||
<h2 class="text-base font-semibold mb-2">Solar Conditions</h2>
|
<h2 class="text-base font-semibold mb-2">Surface Observations</h2>
|
||||||
<%= if @solar do %>
|
<%= if @surface_observations == [] do %>
|
||||||
<.list>
|
<p class="text-sm text-base-content/50 italic">No surface observations found nearby.</p>
|
||||||
<: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 %>
|
<% 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 %>
|
<% end %>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
|
|
@ -367,4 +443,10 @@ defmodule MicrowavepropWeb.QsoLive.Show do
|
||||||
|
|
||||||
defp format_kp(nil), do: "—"
|
defp format_kp(nil), do: "—"
|
||||||
defp format_kp(values) when is_list(values), do: Enum.map_join(values, ", ", &format_number/1)
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -281,4 +281,68 @@ defmodule Microwaveprop.RadioTest do
|
||||||
assert Radio.mark_hrrr_queued!([]) == {0, nil}
|
assert Radio.mark_hrrr_queued!([]) == {0, nil}
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
113
test/microwaveprop/terrain/elevation_client_test.exs
Normal file
113
test/microwaveprop/terrain/elevation_client_test.exs
Normal 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
|
||||||
175
test/microwaveprop/terrain/terrain_analysis_test.exs
Normal file
175
test/microwaveprop/terrain/terrain_analysis_test.exs
Normal 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
|
||||||
94
test/microwaveprop/terrain/terrain_profile_test.exs
Normal file
94
test/microwaveprop/terrain/terrain_profile_test.exs
Normal 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
|
||||||
96
test/microwaveprop/terrain_test.exs
Normal file
96
test/microwaveprop/terrain_test.exs
Normal 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
|
||||||
|
|
@ -2,6 +2,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
use Microwaveprop.DataCase, async: true
|
use Microwaveprop.DataCase, async: true
|
||||||
|
|
||||||
alias Microwaveprop.Radio.Qso
|
alias Microwaveprop.Radio.Qso
|
||||||
|
alias Microwaveprop.Terrain.ElevationClient
|
||||||
alias Microwaveprop.Weather
|
alias Microwaveprop.Weather
|
||||||
alias Microwaveprop.Weather.IemClient
|
alias Microwaveprop.Weather.IemClient
|
||||||
alias Microwaveprop.Workers.QsoWeatherEnqueueWorker
|
alias Microwaveprop.Workers.QsoWeatherEnqueueWorker
|
||||||
|
|
@ -128,6 +129,13 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
end
|
end
|
||||||
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
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -228,4 +236,61 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
assert QsoWeatherEnqueueWorker.build_hrrr_jobs([qso]) == []
|
assert QsoWeatherEnqueueWorker.build_hrrr_jobs([qso]) == []
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
95
test/microwaveprop/workers/terrain_profile_worker_test.exs
Normal file
95
test/microwaveprop/workers/terrain_profile_worker_test.exs
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue