prop/test/microwaveprop/terrain/elevation_client_test.exs
Graham McIntire 45e2e69361
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)
2026-03-29 16:22:29 -05:00

113 lines
3.5 KiB
Elixir

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