318 lines
11 KiB
Elixir
318 lines
11 KiB
Elixir
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, distance_km: dist}) do
|
||
muf_mhz = Microwaveprop.Propagation.SporadicE.single_hop_muf(foes, dist)
|
||
|
||
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 (5–11 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
|