prop/lib/microwaveprop/propagation/mechanism_classifier.ex
Graham McIntire 622edee180
feat(propagation): per-contact mechanism classification
Classifies every contact's likely non-LOS propagation mechanism and
persists the result on contacts.propagation_mechanism. Mechanism is
determined in priority order:

  1. user_declared_prop_mode (ADIF PROP_MODE from the operator log)
  2. EME — moon-ephemeris check, ≥2m band, >1800 km path
  3. aurora — Kp≥5 + high-lat path, 50-432 MHz
  4. sporadic-E — foEs × 5 ≥ band_mhz, 400-2500 km path
  5. meteor_scatter — ±3 days of a shower peak, VHF/UHF
  6. rain_scatter — common-volume radar heavy rain, 5-11 GHz ≤800 km
  7. tropo_duct — HRRR native_best_duct ≥ band or ducting_detected
  8. line_of_sight — ≤50 km path
  9. troposcatter — default

Persisted via MechanismClassifyWorker (queue: :mechanism, unique on
contact_id). Submit-time enqueue path includes :mechanism by default;
BackfillEnqueueWorker cron now handles :mechanism alongside existing
types so prod continuously backfills any contact with
propagation_mechanism_status in (:pending, :queued, :failed). Also
added :radar to the cron's type list so common-volume radar backfill
runs automatically rather than only via `mix radar_backfill`.

New modules:
- Microwaveprop.Propagation.MoonEphemeris — Meeus low-precision moon
  position, accuracy ±1° — enough for the mutual-visibility EME test
- Microwaveprop.Propagation.MechanismClassifier — plug-in priority
  chain over the evidence map
- Microwaveprop.Workers.MechanismClassifyWorker — assembles inputs
  from HRRR / native profiles / common-volume radar / solar_indices /
  ionosonde + calls the classifier

ADIF importer now reads PROP_MODE into user_declared_prop_mode so
operator-tagged mechanisms (EME/ES/MS/RS/AS/AUR) become ground truth.
2026-04-18 10:42:08 -05:00

320 lines
11 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Microwaveprop.Propagation.MechanismClassifier do
@moduledoc """
Classifies the propagation mechanism of an individual contact from
whatever evidence we have: user-declared ADIF PROP_MODE (if any),
moon ephemeris, HRRR / native-duct profile, common-volume radar,
ionosonde foEs, Kp index, and active meteor showers.
Evaluates mechanisms in priority order and returns the first match.
User-declared PROP_MODE always wins when present — operator ground
truth beats any inference we can do post-hoc.
## Input map
%{
band_mhz: integer(),
distance_km: float(),
qso_timestamp: DateTime.t(),
pos1: %{"lat" => float(), "lon" => float()},
pos2: %{"lat" => float(), "lon" => float()},
user_declared_prop_mode: String.t() | nil,
radar: nil | %{max_dbz: float() | nil, heavy_rain_pixel_count: integer(), coverage_pct: float() | nil},
duct_either_endpoint: boolean(),
native_best_duct_ghz: float() | nil,
kp_index: integer() | nil,
foes_mhz: float() | nil,
active_meteor_shower: String.t() | nil
}
## Output
%{mechanism: atom(), confidence: :high | :medium | :low}
Mechanisms (atoms) returned:
- `:eme` — moon bounce
- `:aurora` — aurora scatter
- `:sporadic_e` — sporadic-E ionospheric
- `:meteor_scatter` — meteor trail ionization
- `:aircraft_scatter` — bistatic radar off aircraft
- `:rain_scatter` — precipitation scatter
- `:rain_scatter_possible` — light rain in the common volume
- `:tropo_duct` — surface or elevated refractivity duct
- `:line_of_sight` — within radio horizon
- `:troposcatter` — forward scatter off tropospheric refractive fluctuations (default)
- `:unknown` — not enough data to classify
"""
alias Microwaveprop.Propagation.MoonEphemeris
# ADIF PROP_MODE values we recognize. Anything else falls through to
# physics-based classification.
@user_declared_map %{
"EME" => :eme,
"ES" => :sporadic_e,
"SPE" => :sporadic_e,
"F2" => :f2,
"MS" => :meteor_scatter,
"METEOR" => :meteor_scatter,
"RS" => :rain_scatter,
"RAIN" => :rain_scatter,
"AS" => :aircraft_scatter,
"AIRCRAFT" => :aircraft_scatter,
"AUR" => :aurora,
"AURE" => :aurora,
"TR" => :troposcatter,
"TROPO" => :troposcatter,
"LOS" => :line_of_sight
}
# Band windows for each mechanism. "2 m" means practical EME floor;
# "below 432" means we drop EME for 6 m and below.
@eme_min_band_mhz 144
@eme_min_distance_km 1_800.0
# Es: 50-432 MHz typical. foEs → MUF ≈ 5 × foEs (single hop at 1500 km).
# For a band to propagate via Es, foEs must support it.
@es_band_min_mhz 50
@es_band_max_mhz 432
# Single-hop Es path range (geometry-limited by reflection from ~100 km ionosphere).
@es_min_distance_km 400.0
@es_max_distance_km 2_500.0
# Aurora: VHF primarily. Kp threshold conventionally ≥ 5 for auroral
# scatter to be plausible; stronger events (Kp ≥ 7) make it likely.
@aurora_band_min_mhz 50
@aurora_band_max_mhz 432
@aurora_kp_floor 5
@aurora_min_latitude 40.0
# Meteor scatter: VHF/UHF range, peaks during active showers.
@ms_band_min_mhz 50
@ms_band_max_mhz 432
@ms_min_distance_km 600.0
@ms_max_distance_km 2_300.0
# Aircraft scatter thresholds intentionally omitted until we land ADS-B
# history ingestion. Without flight data we can only classify aircraft
# scatter when the operator declared PROP_MODE=AS.
# Rain scatter: see `RainScatterClassifier` for derivation.
@rs_band_min_mhz 5_000
@rs_band_max_mhz 11_000
@rs_max_distance_km 800.0
@rs_light_dbz 25.0
@rs_heavy_dbz 35.0
@rs_heavy_pixel_floor 3
@rs_min_coverage_pct 25.0
@typedoc "Inputs expected by `classify/1`"
@type inputs :: %{
required(:band_mhz) => integer(),
required(:distance_km) => number(),
required(:qso_timestamp) => DateTime.t(),
required(:pos1) => %{String.t() => number()},
required(:pos2) => %{String.t() => number()},
optional(:user_declared_prop_mode) => String.t() | nil,
optional(:radar) => map() | nil,
optional(:duct_either_endpoint) => boolean(),
optional(:native_best_duct_ghz) => float() | nil,
optional(:kp_index) => integer() | nil,
optional(:foes_mhz) => float() | nil,
optional(:active_meteor_shower) => String.t() | nil
}
@type mechanism ::
:eme
| :aurora
| :sporadic_e
| :f2
| :meteor_scatter
| :aircraft_scatter
| :rain_scatter
| :rain_scatter_possible
| :tropo_duct
| :line_of_sight
| :troposcatter
| :unknown
@type result :: %{mechanism: mechanism(), confidence: :high | :medium | :low}
@spec classify(inputs()) :: result()
def classify(inputs) do
inputs
|> apply_defaults()
|> evaluate()
end
defp apply_defaults(inputs) do
Map.merge(
%{
user_declared_prop_mode: nil,
radar: nil,
duct_either_endpoint: false,
native_best_duct_ghz: nil,
kp_index: nil,
foes_mhz: nil,
active_meteor_shower: nil
},
inputs
)
end
# Priority: user-declared → EME → aurora → Es → meteor_scatter →
# aircraft_scatter (we don't have flight data yet, so this only fires
# when user declared) → rain_scatter → tropo_duct → line_of_sight →
# troposcatter.
defp evaluate(inputs) do
with :no_match <- try_user_declared(inputs),
:no_match <- try_eme(inputs),
:no_match <- try_aurora(inputs),
:no_match <- try_sporadic_e(inputs),
:no_match <- try_meteor_scatter(inputs),
:no_match <- try_rain_scatter(inputs),
:no_match <- try_tropo_duct(inputs),
:no_match <- try_line_of_sight(inputs) do
%{mechanism: :troposcatter, confidence: :low}
else
%{mechanism: _, confidence: _} = result -> result
end
end
# — User-declared ADIF PROP_MODE — always wins when present —
defp try_user_declared(%{user_declared_prop_mode: nil}), do: :no_match
defp try_user_declared(%{user_declared_prop_mode: raw}) when is_binary(raw) do
key = raw |> String.trim() |> String.upcase()
case Map.fetch(@user_declared_map, key) do
{:ok, mechanism} -> %{mechanism: mechanism, confidence: :high}
:error -> :no_match
end
end
# — EME (moon bounce) — calc-only, no new data source —
defp try_eme(%{band_mhz: band}) when band < @eme_min_band_mhz, do: :no_match
defp try_eme(%{distance_km: dist}) when dist < @eme_min_distance_km, do: :no_match
defp try_eme(inputs) do
lat1 = get_lat(inputs.pos1)
lon1 = get_lon(inputs.pos1)
lat2 = get_lat(inputs.pos2)
lon2 = get_lon(inputs.pos2)
if MoonEphemeris.above_horizon?(lat1, lon1, inputs.qso_timestamp) and
MoonEphemeris.above_horizon?(lat2, lon2, inputs.qso_timestamp) do
%{mechanism: :eme, confidence: :high}
else
# Very long VHF+ path but moon not mutually visible — physically
# implausible as EME, fall through.
:no_match
end
end
# — Aurora (Kp + high-lat N-S path) —
defp try_aurora(%{band_mhz: band}) when band > @aurora_band_max_mhz, do: :no_match
defp try_aurora(%{band_mhz: band}) when band < @aurora_band_min_mhz, do: :no_match
defp try_aurora(%{kp_index: nil}), do: :no_match
defp try_aurora(%{kp_index: kp}) when kp < @aurora_kp_floor, do: :no_match
defp try_aurora(inputs) do
lat1 = get_lat(inputs.pos1)
lat2 = get_lat(inputs.pos2)
midpoint_lat = abs((lat1 + lat2) / 2)
if midpoint_lat >= @aurora_min_latitude do
# Kp ≥ 7 is "likely" aurora; Kp 5-6 is "possible" aurora (medium).
confidence = if inputs.kp_index >= 7, do: :high, else: :medium
%{mechanism: :aurora, confidence: confidence}
else
:no_match
end
end
# — Sporadic-E (foEs strong enough for the band) —
defp try_sporadic_e(%{band_mhz: band}) when band > @es_band_max_mhz, do: :no_match
defp try_sporadic_e(%{band_mhz: band}) when band < @es_band_min_mhz, do: :no_match
defp try_sporadic_e(%{distance_km: dist}) when dist < @es_min_distance_km or dist > @es_max_distance_km, do: :no_match
defp try_sporadic_e(%{foes_mhz: nil}), do: :no_match
defp try_sporadic_e(%{foes_mhz: foes, band_mhz: band}) do
# Es MUF at 1500-2000 km obliquity is roughly 5 × foEs. If that
# exceeds the working band frequency, Es can support the path.
muf_mhz = 5.0 * foes
if muf_mhz >= band do
confidence = if muf_mhz >= 1.5 * band, do: :high, else: :medium
%{mechanism: :sporadic_e, confidence: confidence}
else
:no_match
end
end
# — Meteor scatter (during active shower, on VHF/UHF) —
defp try_meteor_scatter(%{active_meteor_shower: nil}), do: :no_match
defp try_meteor_scatter(%{band_mhz: band}) when band > @ms_band_max_mhz, do: :no_match
defp try_meteor_scatter(%{band_mhz: band}) when band < @ms_band_min_mhz, do: :no_match
defp try_meteor_scatter(%{distance_km: dist}) when dist < @ms_min_distance_km or dist > @ms_max_distance_km,
do: :no_match
defp try_meteor_scatter(_inputs), do: %{mechanism: :meteor_scatter, confidence: :medium}
# — Rain scatter (511 GHz, path ≤ 800 km, heavy rain in CV) —
defp try_rain_scatter(%{radar: nil}), do: :no_match
defp try_rain_scatter(%{band_mhz: band}) when band < @rs_band_min_mhz or band > @rs_band_max_mhz, do: :no_match
defp try_rain_scatter(%{distance_km: dist}) when dist > @rs_max_distance_km, do: :no_match
# Duct signature dominates — covered by try_tropo_duct below.
defp try_rain_scatter(%{duct_either_endpoint: true}), do: :no_match
defp try_rain_scatter(%{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 < @rs_min_coverage_pct ->
:no_match
max_dbz >= @rs_heavy_dbz and heavy >= @rs_heavy_pixel_floor ->
%{mechanism: :rain_scatter, confidence: :high}
max_dbz >= @rs_light_dbz ->
%{mechanism: :rain_scatter_possible, confidence: :medium}
true ->
:no_match
end
end
# — Tropospheric duct (HRRR native best_duct supports the band, or legacy flag) —
defp try_tropo_duct(%{duct_either_endpoint: true}), do: %{mechanism: :tropo_duct, confidence: :medium}
defp try_tropo_duct(%{native_best_duct_ghz: ghz, band_mhz: band}) when is_number(ghz) and ghz * 1_000 >= band,
do: %{mechanism: :tropo_duct, confidence: :high}
defp try_tropo_duct(_), do: :no_match
# — Line of sight — conservative 50 km radio-horizon approximation.
defp try_line_of_sight(%{distance_km: dist}) when dist <= 50.0, do: %{mechanism: :line_of_sight, confidence: :high}
defp try_line_of_sight(_), do: :no_match
defp get_lat(%{"lat" => lat}), do: lat / 1.0
defp get_lat(%{lat: lat}), do: lat / 1.0
defp get_lon(%{"lon" => lon}), do: lon / 1.0
defp get_lon(%{lon: lon}), do: lon / 1.0
end