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 %).
97 lines
3.4 KiB
Elixir
97 lines
3.4 KiB
Elixir
defmodule Microwaveprop.Propagation.CommonVolume do
|
|
@moduledoc """
|
|
Geometry for the "common volume" of a microwave QSO — the lens-shaped
|
|
intersection of two circles of radius R around the endpoints.
|
|
|
|
Rain-scatter requires both stations' beams to intersect a precipitating
|
|
cell, so the cell has to live inside both 400 km-radius neighborhoods.
|
|
This module provides the geometric primitives a classifier needs:
|
|
|
|
* `in_common_volume?/4` — is a point inside both disks?
|
|
* `bounding_box/3` — lat/lon rectangle that contains the lens
|
|
* `area_km2/3` — area of the intersection, for coverage normalization
|
|
|
|
Distances are spherical-great-circle (haversine) at mean Earth radius.
|
|
"""
|
|
|
|
@earth_radius_km 6371.0
|
|
@km_per_deg_lat 111.32
|
|
|
|
@type latlon :: {float(), float()}
|
|
@type bbox :: %{min_lat: float(), max_lat: float(), min_lon: float(), max_lon: float()}
|
|
|
|
@doc "Is `point` within `radius_km` of both endpoints?"
|
|
@spec in_common_volume?(latlon(), latlon(), latlon(), float()) :: boolean()
|
|
def in_common_volume?(pos1, pos2, point, radius_km) do
|
|
haversine_km(pos1, point) <= radius_km and haversine_km(pos2, point) <= radius_km
|
|
end
|
|
|
|
@doc """
|
|
Lat/lon rectangle containing the common volume. Returns `:empty` when
|
|
the two circles don't overlap.
|
|
"""
|
|
@spec bounding_box(latlon(), latlon(), float()) :: bbox() | :empty
|
|
def bounding_box({lat1, lon1} = pos1, {lat2, lon2} = pos2, radius_km) do
|
|
if haversine_km(pos1, pos2) > 2 * radius_km do
|
|
:empty
|
|
else
|
|
lat_pad = radius_km / @km_per_deg_lat
|
|
|
|
# Approximate longitude degrees-per-km at the higher-latitude endpoint
|
|
# (narrower at higher lat, so more conservative).
|
|
ref_lat = max(abs(lat1), abs(lat2))
|
|
km_per_deg_lon = @km_per_deg_lat * max(:math.cos(ref_lat * :math.pi() / 180.0), 0.01)
|
|
lon_pad = radius_km / km_per_deg_lon
|
|
|
|
%{
|
|
min_lat: max(lat1 - lat_pad, lat2 - lat_pad),
|
|
max_lat: min(lat1 + lat_pad, lat2 + lat_pad),
|
|
min_lon: max(lon1 - lon_pad, lon2 - lon_pad),
|
|
max_lon: min(lon1 + lon_pad, lon2 + lon_pad)
|
|
}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Area (km²) of the lens-shaped intersection of two circles of radius
|
|
`radius_km` centered at `pos1` and `pos2`. Returns `0.0` when the
|
|
circles don't overlap. Uses the standard two-circle lens formula
|
|
with the great-circle distance between centers.
|
|
"""
|
|
@spec area_km2(latlon(), latlon(), float()) :: float()
|
|
def area_km2(pos1, pos2, radius_km) do
|
|
d = haversine_km(pos1, pos2)
|
|
|
|
cond do
|
|
d >= 2 * radius_km ->
|
|
0.0
|
|
|
|
d <= 0.0 ->
|
|
:math.pi() * radius_km * radius_km
|
|
|
|
true ->
|
|
r = radius_km
|
|
# Lens area = 2 * [r² * arccos(d/2r) - (d/4) * sqrt(4r² - d²)]
|
|
part_arc = r * r * :math.acos(d / (2 * r))
|
|
part_tri = d / 4 * :math.sqrt(4 * r * r - d * d)
|
|
2 * (part_arc - part_tri)
|
|
end
|
|
end
|
|
|
|
@doc "Great-circle distance (km) between two lat/lon points."
|
|
@spec haversine_km(latlon(), latlon()) :: float()
|
|
def haversine_km({lat1, lon1}, {lat2, lon2}) do
|
|
rlat1 = lat1 * :math.pi() / 180.0
|
|
rlat2 = lat2 * :math.pi() / 180.0
|
|
dlat = (lat2 - lat1) * :math.pi() / 180.0
|
|
dlon = (lon2 - lon1) * :math.pi() / 180.0
|
|
|
|
a =
|
|
:math.sin(dlat / 2) * :math.sin(dlat / 2) +
|
|
:math.cos(rlat1) * :math.cos(rlat2) *
|
|
:math.sin(dlon / 2) * :math.sin(dlon / 2)
|
|
|
|
c = 2.0 * :math.atan2(:math.sqrt(a), :math.sqrt(1.0 - a))
|
|
@earth_radius_km * c
|
|
end
|
|
end
|