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