prop/test/microwaveprop/propagation/common_volume_test.exs
Graham McIntire 33f5d4edbe
feat(rainscatter): classify QSO propagation mechanism from common-volume radar
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 %).
2026-04-17 15:57:59 -05:00

102 lines
3.5 KiB
Elixir

defmodule Microwaveprop.Propagation.CommonVolumeTest do
use ExUnit.Case, async: true
alias Microwaveprop.Propagation.CommonVolume
describe "in_common_volume?/4" do
test "point at midpoint of two nearby stations is in the common volume" do
pos1 = {32.9, -97.0}
pos2 = {33.1, -96.8}
midpoint = {33.0, -96.9}
assert CommonVolume.in_common_volume?(pos1, pos2, midpoint, 400.0)
end
test "point far from both stations is not in the common volume" do
pos1 = {32.9, -97.0}
pos2 = {33.1, -96.8}
far = {50.0, -50.0}
refute CommonVolume.in_common_volume?(pos1, pos2, far, 400.0)
end
test "point inside circle-1 but outside circle-2 is not in the common volume" do
pos1 = {33.0, -97.0}
# ~900 km east so radius=400 circles don't overlap there
pos2 = {33.0, -87.0}
near_pos1 = {33.0, -96.5}
assert CommonVolume.in_common_volume?(pos1, pos1, near_pos1, 400.0)
refute CommonVolume.in_common_volume?(pos1, pos2, near_pos1, 400.0)
end
test "endpoints themselves are in the common volume when stations are < 2R apart" do
pos1 = {32.9, -97.0}
pos2 = {33.1, -96.8}
assert CommonVolume.in_common_volume?(pos1, pos2, pos1, 400.0)
assert CommonVolume.in_common_volume?(pos1, pos2, pos2, 400.0)
end
test "nothing is in the common volume when stations are > 2R apart" do
pos1 = {33.0, -97.0}
# ~2200 km east
pos2 = {33.0, -73.0}
refute CommonVolume.in_common_volume?(pos1, pos2, pos1, 400.0)
end
end
describe "bounding_box/3" do
test "returns min/max lat/lon covering the entire common volume" do
pos1 = {32.9, -97.0}
pos2 = {33.1, -96.8}
bbox = CommonVolume.bounding_box(pos1, pos2, 400.0)
# Sanity: midpoint is inside bbox
assert bbox.min_lat <= 33.0 and bbox.max_lat >= 33.0
assert bbox.min_lon <= -96.9 and bbox.max_lon >= -96.9
# Bbox shouldn't extend past either individual circle bbox
r_deg_lat = 400.0 / 111.32
assert bbox.min_lat >= (pos1 |> elem(0) |> min(elem(pos2, 0))) - r_deg_lat - 0.01
assert bbox.max_lat <= (pos1 |> elem(0) |> max(elem(pos2, 0))) + r_deg_lat + 0.01
end
test "returns an empty bbox when the circles don't overlap" do
pos1 = {33.0, -97.0}
pos2 = {33.0, -73.0}
bbox = CommonVolume.bounding_box(pos1, pos2, 400.0)
assert bbox == :empty
end
end
describe "area_km2/3" do
test "two coincident circles -> area of full circle" do
pos = {33.0, -97.0}
r = 400.0
assert_in_delta CommonVolume.area_km2(pos, pos, r), :math.pi() * r * r, 1.0
end
test "circles just touching -> small area compared to full circle" do
# ~800 km apart, radius 400 -> barely overlapping
pos1 = {33.0, -97.0}
pos2 = {33.0, -88.48}
area = CommonVolume.area_km2(pos1, pos2, 400.0)
full = :math.pi() * 400.0 * 400.0
assert area >= 0.0
assert area / full < 0.01
end
test "non-overlapping circles -> zero area" do
pos1 = {33.0, -97.0}
pos2 = {33.0, -73.0}
assert CommonVolume.area_km2(pos1, pos2, 400.0) == 0.0
end
test "partially overlapping circles -> positive area less than a full circle" do
pos1 = {33.0, -97.0}
# ~400 km apart -> roughly 1/2 circle overlap
pos2 = {33.0, -92.7}
area = CommonVolume.area_km2(pos1, pos2, 400.0)
full = :math.pi() * 400.0 * 400.0
assert area > 0.0
assert area < full
end
end
end