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 %).
102 lines
3.5 KiB
Elixir
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
|