prop/lib/microwaveprop/propagation/mechanism_classifier.ex

318 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, 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 (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