test: cover propagation/rain_scatter
24 characterization tests for bistatic radar geometry: find_scatter_cells filtering (dBZ threshold, distance floor/ceiling), bearing math, frequency scaling, and classify/1 buckets.
This commit is contained in:
parent
cb2937f12c
commit
4826cdb2c8
1 changed files with 216 additions and 0 deletions
216
test/microwaveprop/propagation/rain_scatter_test.exs
Normal file
216
test/microwaveprop/propagation/rain_scatter_test.exs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
defmodule Microwaveprop.Propagation.RainScatterTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Microwaveprop.Propagation.RainScatter
|
||||
|
||||
# DFW as a reference observer for geometry tests.
|
||||
@obs_lat 32.78
|
||||
@obs_lon -96.80
|
||||
|
||||
describe "find_scatter_cells/4" do
|
||||
test "returns an empty list for an empty cell list" do
|
||||
assert RainScatter.find_scatter_cells([], @obs_lat, @obs_lon, 10.0) == []
|
||||
end
|
||||
|
||||
test "filters cells below the 25 dBZ minimum reflectivity threshold" do
|
||||
# A 24.9 dBZ cell at 50 km is dropped; exactly 25.0 dBZ survives.
|
||||
below = RainScatter.find_scatter_cells([{33.23, @obs_lon, 24.9}], @obs_lat, @obs_lon, 10.0)
|
||||
at_threshold = RainScatter.find_scatter_cells([{33.23, @obs_lon, 25.0}], @obs_lat, @obs_lon, 10.0)
|
||||
|
||||
assert below == []
|
||||
assert [%{dbz: 25.0}] = at_threshold
|
||||
end
|
||||
|
||||
test "filters cells closer than 10 km from the observer" do
|
||||
# 0.089 deg lat ≈ 9.9 km: dropped. 0.09 deg lat ≈ 10.0 km: kept.
|
||||
too_close = RainScatter.find_scatter_cells([{@obs_lat + 0.089, @obs_lon, 50.0}], @obs_lat, @obs_lon, 10.0)
|
||||
at_edge = RainScatter.find_scatter_cells([{@obs_lat + 0.09, @obs_lon, 50.0}], @obs_lat, @obs_lon, 10.0)
|
||||
|
||||
assert too_close == []
|
||||
assert [%{distance_km: 10.0}] = at_edge
|
||||
end
|
||||
|
||||
test "filters cells farther than the 300 km maximum scatter range" do
|
||||
# 2.5 deg lat ≈ 278 km: kept. 2.70 deg lat ≈ 300.1 km: dropped.
|
||||
in_range = RainScatter.find_scatter_cells([{@obs_lat + 2.5, @obs_lon, 50.0}], @obs_lat, @obs_lon, 10.0)
|
||||
out_of_range = RainScatter.find_scatter_cells([{@obs_lat + 2.70, @obs_lon, 50.0}], @obs_lat, @obs_lon, 10.0)
|
||||
|
||||
assert [%{distance_km: 278.0}] = in_range
|
||||
assert out_of_range == []
|
||||
end
|
||||
|
||||
test "drops a cell co-located with the observer (distance ~0, below 10 km floor)" do
|
||||
# Same lat/lon as observer — distance 0, filtered by the >= 10 km rule.
|
||||
assert RainScatter.find_scatter_cells([{@obs_lat, @obs_lon, 55.0}], @obs_lat, @obs_lon, 10.0) == []
|
||||
end
|
||||
|
||||
test "computes haversine distance matching a known geodesic (1° ≈ 111.19 km at the equator)" do
|
||||
# One degree of longitude at the equator should be ~111.2 km via haversine.
|
||||
[cell] = RainScatter.find_scatter_cells([{0.0, 1.0, 40.0}], 0.0, 0.0, 10.0)
|
||||
assert_in_delta cell.distance_km, 111.2, 0.2
|
||||
end
|
||||
|
||||
test "reports cardinal bearings of 0° (N), 90° (E), 180° (S), and 270° (W)" do
|
||||
north = RainScatter.find_scatter_cells([{@obs_lat + 1.0, @obs_lon, 50.0}], @obs_lat, @obs_lon, 10.0)
|
||||
east = RainScatter.find_scatter_cells([{@obs_lat, @obs_lon + 1.3, 50.0}], @obs_lat, @obs_lon, 10.0)
|
||||
south = RainScatter.find_scatter_cells([{@obs_lat - 1.0, @obs_lon, 50.0}], @obs_lat, @obs_lon, 10.0)
|
||||
west = RainScatter.find_scatter_cells([{@obs_lat, @obs_lon - 1.3, 50.0}], @obs_lat, @obs_lon, 10.0)
|
||||
|
||||
assert [%{bearing: b_n}] = north
|
||||
assert [%{bearing: b_e}] = east
|
||||
assert [%{bearing: b_s}] = south
|
||||
assert [%{bearing: b_w}] = west
|
||||
assert b_n == 0.0
|
||||
assert b_e == 90.0
|
||||
assert b_s == 180.0
|
||||
assert b_w == 270.0
|
||||
end
|
||||
|
||||
test "bearing is always in [0, 360) — cells to the NW return bearings near 300-330°" do
|
||||
[cell] = RainScatter.find_scatter_cells([{@obs_lat + 0.5, @obs_lon - 0.7, 50.0}], @obs_lat, @obs_lon, 10.0)
|
||||
assert cell.bearing >= 0.0 and cell.bearing < 360.0
|
||||
assert cell.bearing > 270.0 and cell.bearing < 360.0
|
||||
end
|
||||
|
||||
test "rounds output fields: lat/lon to 3 decimals, dBZ to 1, distance to 1, bearing to 0, scatter_db to 1" do
|
||||
[cell] =
|
||||
RainScatter.find_scatter_cells(
|
||||
[{33.12345, -96.54321, 42.777}],
|
||||
@obs_lat,
|
||||
@obs_lon,
|
||||
10.0
|
||||
)
|
||||
|
||||
assert cell.lat == 33.123
|
||||
assert cell.lon == -96.543
|
||||
assert cell.dbz == 42.8
|
||||
# Distance rounded to one decimal place.
|
||||
assert cell.distance_km == Float.round(cell.distance_km, 1)
|
||||
# Bearing rounded to a whole-number degree.
|
||||
assert cell.bearing == Float.round(cell.bearing, 0)
|
||||
# scatter_db rounded to one decimal place.
|
||||
assert cell.scatter_db == Float.round(cell.scatter_db, 1)
|
||||
end
|
||||
|
||||
test "sorts returned cells strongest-first by scatter_db (descending)" do
|
||||
# A closer 30 dBZ cell vs. a farther but stronger 55 dBZ cell — the
|
||||
# 55 dBZ cell wins because dBZ dominates the range loss at this geometry.
|
||||
cells = [
|
||||
# 50 km, weak
|
||||
{33.23, @obs_lon, 30.0},
|
||||
# 165 km, strong
|
||||
{@obs_lat, -95.00, 55.0}
|
||||
]
|
||||
|
||||
[first, second] = RainScatter.find_scatter_cells(cells, @obs_lat, @obs_lon, 10.0)
|
||||
assert first.scatter_db >= second.scatter_db
|
||||
assert first.dbz == 55.0
|
||||
assert second.dbz == 30.0
|
||||
end
|
||||
|
||||
test "caps the returned list at 20 cells even when more qualify" do
|
||||
# 25 qualifying cells, spaced along a northward line 50-300 km out.
|
||||
many = for i <- 1..25, do: {@obs_lat + 0.5 + i * 0.05, @obs_lon, 40.0}
|
||||
result = RainScatter.find_scatter_cells(many, @obs_lat, @obs_lon, 10.0)
|
||||
|
||||
assert length(result) == 20
|
||||
end
|
||||
|
||||
test "frequency gain is monotonically increasing up to ~50 GHz then plateaus" do
|
||||
cells = [{33.23, @obs_lon, 40.0}]
|
||||
|
||||
f1 = hd(RainScatter.find_scatter_cells(cells, @obs_lat, @obs_lon, 1.0)).scatter_db
|
||||
f3 = hd(RainScatter.find_scatter_cells(cells, @obs_lat, @obs_lon, 3.0)).scatter_db
|
||||
f10 = hd(RainScatter.find_scatter_cells(cells, @obs_lat, @obs_lon, 10.0)).scatter_db
|
||||
f24 = hd(RainScatter.find_scatter_cells(cells, @obs_lat, @obs_lon, 24.0)).scatter_db
|
||||
f50 = hd(RainScatter.find_scatter_cells(cells, @obs_lat, @obs_lon, 50.0)).scatter_db
|
||||
f100 = hd(RainScatter.find_scatter_cells(cells, @obs_lat, @obs_lon, 100.0)).scatter_db
|
||||
f241 = hd(RainScatter.find_scatter_cells(cells, @obs_lat, @obs_lon, 241.0)).scatter_db
|
||||
|
||||
assert f1 < f3
|
||||
assert f3 < f10
|
||||
assert f10 < f24
|
||||
assert f24 < f50
|
||||
# Frequency gain is clamped at 50 GHz — 50, 100, and 241 GHz all give the same scatter_db.
|
||||
assert f50 == f100
|
||||
assert f100 == f241
|
||||
end
|
||||
|
||||
test "frequency factor clamps very low frequencies at 0.5 GHz — 0.1 and 0.5 GHz are equivalent" do
|
||||
cells = [{33.23, @obs_lon, 40.0}]
|
||||
below_floor = hd(RainScatter.find_scatter_cells(cells, @obs_lat, @obs_lon, 0.1)).scatter_db
|
||||
at_floor = hd(RainScatter.find_scatter_cells(cells, @obs_lat, @obs_lon, 0.5)).scatter_db
|
||||
|
||||
assert below_floor == at_floor
|
||||
end
|
||||
|
||||
test "scatter_db decreases with distance for fixed dBZ and frequency" do
|
||||
near = hd(RainScatter.find_scatter_cells([{@obs_lat + 0.5, @obs_lon, 40.0}], @obs_lat, @obs_lon, 10.0)).scatter_db
|
||||
mid = hd(RainScatter.find_scatter_cells([{@obs_lat + 1.5, @obs_lon, 40.0}], @obs_lat, @obs_lon, 10.0)).scatter_db
|
||||
far = hd(RainScatter.find_scatter_cells([{@obs_lat + 2.5, @obs_lon, 40.0}], @obs_lat, @obs_lon, 10.0)).scatter_db
|
||||
|
||||
assert near > mid
|
||||
assert mid > far
|
||||
end
|
||||
|
||||
test "scatter_db increases with dBZ for fixed distance and frequency" do
|
||||
weak = hd(RainScatter.find_scatter_cells([{33.23, @obs_lon, 30.0}], @obs_lat, @obs_lon, 10.0)).scatter_db
|
||||
mid = hd(RainScatter.find_scatter_cells([{33.23, @obs_lon, 45.0}], @obs_lat, @obs_lon, 10.0)).scatter_db
|
||||
strong = hd(RainScatter.find_scatter_cells([{33.23, @obs_lon, 60.0}], @obs_lat, @obs_lon, 10.0)).scatter_db
|
||||
|
||||
assert weak < mid
|
||||
assert mid < strong
|
||||
# 1 dBZ of additional reflectivity adds ~1 dB of scatter_db.
|
||||
assert_in_delta strong - weak, 30.0, 0.1
|
||||
end
|
||||
|
||||
test "returned maps have the documented keys only" do
|
||||
[cell] = RainScatter.find_scatter_cells([{33.23, @obs_lon, 40.0}], @obs_lat, @obs_lon, 10.0)
|
||||
assert cell |> Map.keys() |> Enum.sort() == [:bearing, :dbz, :distance_km, :lat, :lon, :scatter_db]
|
||||
end
|
||||
end
|
||||
|
||||
describe "classify/1" do
|
||||
test "returns :none for an empty list" do
|
||||
assert RainScatter.classify([]) == :none
|
||||
end
|
||||
|
||||
test "returns :excellent when the top cell scatter_db is at or above -10" do
|
||||
assert RainScatter.classify([%{scatter_db: 0.0}]) == :excellent
|
||||
assert RainScatter.classify([%{scatter_db: -10.0}]) == :excellent
|
||||
end
|
||||
|
||||
test "returns :good when the top cell scatter_db is in [-20, -10)" do
|
||||
assert RainScatter.classify([%{scatter_db: -10.1}]) == :good
|
||||
assert RainScatter.classify([%{scatter_db: -15.0}]) == :good
|
||||
assert RainScatter.classify([%{scatter_db: -20.0}]) == :good
|
||||
end
|
||||
|
||||
test "returns :marginal when the top cell scatter_db is in [-30, -20)" do
|
||||
assert RainScatter.classify([%{scatter_db: -20.1}]) == :marginal
|
||||
assert RainScatter.classify([%{scatter_db: -25.0}]) == :marginal
|
||||
assert RainScatter.classify([%{scatter_db: -30.0}]) == :marginal
|
||||
end
|
||||
|
||||
test "returns :none when the top cell scatter_db is below -30" do
|
||||
assert RainScatter.classify([%{scatter_db: -30.1}]) == :none
|
||||
assert RainScatter.classify([%{scatter_db: -50.0}]) == :none
|
||||
end
|
||||
|
||||
test "classifies based on the head (strongest) cell only, ignoring weaker tail cells" do
|
||||
# List is assumed pre-sorted; a strong head dominates regardless of the tail.
|
||||
cells = [%{scatter_db: -5.0}, %{scatter_db: -40.0}, %{scatter_db: -100.0}]
|
||||
assert RainScatter.classify(cells) == :excellent
|
||||
end
|
||||
|
||||
test "end-to-end: a realistic storm at 50 km, 40 dBZ, 10 GHz classifies as :excellent" do
|
||||
cells = RainScatter.find_scatter_cells([{33.23, @obs_lon, 40.0}], @obs_lat, @obs_lon, 10.0)
|
||||
assert RainScatter.classify(cells) == :excellent
|
||||
end
|
||||
|
||||
test "end-to-end: no cells above threshold → :none" do
|
||||
cells = RainScatter.find_scatter_cells([{33.23, @obs_lon, 20.0}], @obs_lat, @obs_lon, 10.0)
|
||||
assert RainScatter.classify(cells) == :none
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue