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