Adds a per-contact enrichment pipeline that determines whether a QSO was
most likely carried by rain scatter, tropospheric ducting, or ordinary
troposcatter — using IEM n0q composite reflectivity sampled inside the
lens-shaped intersection of 400 km-radius disks around each endpoint.
Pieces:
* Microwaveprop.Propagation.CommonVolume — lens geometry (haversine,
in-CV test, bbox, area).
* contact_common_volume_radar table (1:1 per contact) storing
aggregate dBZ stats inside the CV + radar_status column on contacts.
* Microwaveprop.Workers.CommonVolumeRadarWorker — Oban :radar queue,
fetches the n0q frame at QSO time, iterates pixels inside the CV
bbox, aggregates rain/heavy/core-pixel counts, max/mean dBZ, and
coverage percentage.
* Microwaveprop.Propagation.RainScatterClassifier — rule-based mapper
from (band, distance, radar stats, duct flags) to one of
:likely_rainscatter | :rainscatter_possible | :tropo_duct |
:troposcatter | :unknown.
* ContactWeatherEnqueueWorker learns a :radar enrichment type and
enqueues the CV worker on contact submission; pre-2014 contacts
(outside IEM n0q coverage) are pinned to :unavailable.
* `mix radar_backfill` bulk-enqueues historical contacts with
--year / --limit / --dry-run.
* Contact detail page renders a mechanism badge with supporting
stats (common-volume area, max dBZ, heavy-rain pixel count,
coverage %).
62 lines
2.4 KiB
Elixir
62 lines
2.4 KiB
Elixir
defmodule Microwaveprop.Propagation.RainScatterClassifierTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Microwaveprop.Propagation.RainScatterClassifier
|
|
|
|
defp inputs(overrides \\ %{}) do
|
|
base = %{
|
|
band_mhz: 10_000,
|
|
distance_km: 250.0,
|
|
radar: %{max_dbz: 42.0, heavy_rain_pixel_count: 12, coverage_pct: 80.0},
|
|
duct_either_endpoint: false
|
|
}
|
|
|
|
Map.merge(base, overrides)
|
|
end
|
|
|
|
describe "classify/1" do
|
|
test "10 GHz, short path, heavy rain in CV, no duct -> :likely_rainscatter" do
|
|
assert RainScatterClassifier.classify(inputs()) == :likely_rainscatter
|
|
end
|
|
|
|
test "duct at either endpoint overrides -> :tropo_duct even with rain" do
|
|
assert RainScatterClassifier.classify(inputs(%{duct_either_endpoint: true})) == :tropo_duct
|
|
end
|
|
|
|
test "no rain, no duct, 10 GHz -> :troposcatter" do
|
|
inp = inputs(%{radar: %{max_dbz: 5.0, heavy_rain_pixel_count: 0, coverage_pct: 90.0}})
|
|
assert RainScatterClassifier.classify(inp) == :troposcatter
|
|
end
|
|
|
|
test "path too long (> 800 km) -> :troposcatter regardless of rain" do
|
|
assert RainScatterClassifier.classify(inputs(%{distance_km: 1000.0})) == :troposcatter
|
|
end
|
|
|
|
test "band above rainscatter window (24 GHz) -> not rainscatter" do
|
|
result = RainScatterClassifier.classify(inputs(%{band_mhz: 24_000}))
|
|
assert result in [:tropo_duct, :troposcatter, :unknown]
|
|
refute result == :likely_rainscatter
|
|
end
|
|
|
|
test "band below 5 GHz -> not rainscatter (scattering efficiency drops fast)" do
|
|
result = RainScatterClassifier.classify(inputs(%{band_mhz: 1_296}))
|
|
refute result == :likely_rainscatter
|
|
end
|
|
|
|
test "no radar coverage at all -> :unknown (can't distinguish)" do
|
|
inp = inputs(%{radar: nil, duct_either_endpoint: false})
|
|
assert RainScatterClassifier.classify(inp) == :unknown
|
|
end
|
|
|
|
test "radar coverage is too spotty -> :unknown" do
|
|
inp = inputs(%{radar: %{max_dbz: 42.0, heavy_rain_pixel_count: 1, coverage_pct: 5.0}})
|
|
assert RainScatterClassifier.classify(inp) == :unknown
|
|
end
|
|
|
|
test "light rain (25-35 dBZ) counts as rainscatter-possible, not probable" do
|
|
inp = inputs(%{radar: %{max_dbz: 28.0, heavy_rain_pixel_count: 0, coverage_pct: 80.0}})
|
|
# 28 dBZ -> rainscatter_possible; absence of heavy cells is the discriminator
|
|
assert RainScatterClassifier.classify(inp) == :rainscatter_possible
|
|
end
|
|
end
|
|
end
|