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 %).
109 lines
3.5 KiB
Elixir
109 lines
3.5 KiB
Elixir
defmodule Microwaveprop.Propagation.RainScatterClassifier do
|
|
@moduledoc """
|
|
Rule-based classifier that labels a contact with its most likely
|
|
propagation mechanism: rain scatter vs tropospheric ducting vs plain
|
|
troposcatter vs unknown.
|
|
|
|
Takes a small input map so callers can assemble it from whatever data
|
|
they have (schema rows at submit time, aggregated SQL in backfill,
|
|
etc.) without this module depending on Ecto.
|
|
|
|
## Inputs
|
|
|
|
%{
|
|
band_mhz: integer(),
|
|
distance_km: float(),
|
|
# common-volume radar stats (nil if no radar sweep covers the CV)
|
|
radar: nil | %{
|
|
max_dbz: float() | nil,
|
|
heavy_rain_pixel_count: non_neg_integer(),
|
|
coverage_pct: float() | nil
|
|
},
|
|
# true if HRRR detected a trapping layer at *either* endpoint
|
|
duct_either_endpoint: boolean()
|
|
}
|
|
|
|
## Outputs
|
|
|
|
* `:likely_rainscatter` — 5-11 GHz, path ≤ 800 km, heavy rain (≥ 35 dBZ
|
|
with several pixels) inside the common volume, no duct at either end.
|
|
* `:rainscatter_possible` — light rain (25-35 dBZ) in the CV, no duct;
|
|
could be rainscatter, could be weak tropo.
|
|
* `:tropo_duct` — HRRR ducting signature at either endpoint dominates.
|
|
* `:troposcatter` — no duct, no meaningful rain → default tropospheric
|
|
forward scatter, which carries most "clear-air" microwave contacts.
|
|
* `:unknown` — not enough information (no radar sweep, poor coverage).
|
|
|
|
These labels are coarse on purpose: with post-hoc HRRR + n0q data you
|
|
can't prove the mechanism, but you *can* separate the cohorts enough
|
|
to calibrate band-weights and to show the likely mode on a QSO detail
|
|
page.
|
|
"""
|
|
|
|
# Rainscatter is feasible roughly 5-11 GHz. Below, scattering cross-section
|
|
# is too small; above, absorption kills the signal before it scatters.
|
|
@rs_band_min_mhz 5_000
|
|
@rs_band_max_mhz 11_000
|
|
|
|
# Geometry limit: common volume stops existing beyond ~2R = 800 km.
|
|
@rs_max_distance_km 800.0
|
|
|
|
# Reflectivity thresholds (dBZ) on the n0q composite.
|
|
@light_rain_dbz 25.0
|
|
@heavy_rain_dbz 35.0
|
|
|
|
# How many heavy pixels count as a "real" thunderstorm cell in the CV.
|
|
@heavy_pixel_floor 3
|
|
|
|
# Minimum radar coverage pct to trust the reading. Below this the CV
|
|
# scan didn't have enough valid pixels to say either way.
|
|
@min_coverage_pct 25.0
|
|
|
|
@type result ::
|
|
:likely_rainscatter
|
|
| :rainscatter_possible
|
|
| :tropo_duct
|
|
| :troposcatter
|
|
| :unknown
|
|
|
|
@spec classify(map()) :: result()
|
|
def classify(%{duct_either_endpoint: true}), do: :tropo_duct
|
|
|
|
def classify(%{band_mhz: band, distance_km: dist} = input) do
|
|
if rainscatter_geometry?(band, dist) do
|
|
classify_from_radar(input)
|
|
else
|
|
fallback_non_rainscatter(input)
|
|
end
|
|
end
|
|
|
|
defp rainscatter_geometry?(band_mhz, distance_km) do
|
|
band_mhz >= @rs_band_min_mhz and band_mhz <= @rs_band_max_mhz and
|
|
distance_km <= @rs_max_distance_km
|
|
end
|
|
|
|
defp classify_from_radar(%{radar: nil}), do: :unknown
|
|
|
|
defp classify_from_radar(%{radar: radar}) do
|
|
coverage = radar[:coverage_pct] || 0.0
|
|
max_dbz = radar[:max_dbz] || 0.0
|
|
heavy = radar[:heavy_rain_pixel_count] || 0
|
|
|
|
cond do
|
|
coverage < @min_coverage_pct ->
|
|
:unknown
|
|
|
|
max_dbz >= @heavy_rain_dbz and heavy >= @heavy_pixel_floor ->
|
|
:likely_rainscatter
|
|
|
|
max_dbz >= @light_rain_dbz ->
|
|
:rainscatter_possible
|
|
|
|
true ->
|
|
:troposcatter
|
|
end
|
|
end
|
|
|
|
defp fallback_non_rainscatter(%{radar: nil}), do: :unknown
|
|
defp fallback_non_rainscatter(_), do: :troposcatter
|
|
end
|