defmodule Microwaveprop.Propagation.MechanismClassifierTest do use ExUnit.Case, async: true alias Microwaveprop.Propagation.MechanismClassifier defp inputs(overrides \\ %{}) do # Base: a plain 10 GHz tropo-scatter contact with everything nil. base = %{ band_mhz: 10_000, distance_km: 250.0, qso_timestamp: ~U[2024-08-15 19:00:00Z], pos1: %{"lat" => 32.9, "lon" => -97.0}, pos2: %{"lat" => 30.3, "lon" => -97.7}, 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 } Map.merge(base, overrides) end describe "user-declared mechanism (ADIF PROP_MODE) — highest priority" do test "trusts ADIF 'EME' even on 10 GHz short path" do result = MechanismClassifier.classify(inputs(%{user_declared_prop_mode: "EME"})) assert result.mechanism == :eme assert result.confidence == :high end test "trusts ADIF 'ES' even when ionosonde data absent" do result = MechanismClassifier.classify(inputs(%{band_mhz: 144, user_declared_prop_mode: "ES"})) assert result.mechanism == :sporadic_e assert result.confidence == :high end test "trusts ADIF 'MS' for meteor scatter" do result = MechanismClassifier.classify(inputs(%{band_mhz: 144, user_declared_prop_mode: "MS"})) assert result.mechanism == :meteor_scatter end test "trusts ADIF 'RS' for rain scatter" do result = MechanismClassifier.classify(inputs(%{user_declared_prop_mode: "RS"})) assert result.mechanism == :rain_scatter end test "trusts ADIF 'AS' for aircraft scatter" do result = MechanismClassifier.classify(inputs(%{band_mhz: 1_296, user_declared_prop_mode: "AS"})) assert result.mechanism == :aircraft_scatter end test "unknown user-declared mode falls through to physics classification" do result = MechanismClassifier.classify(inputs(%{user_declared_prop_mode: "GIBBERISH"})) # No rain, no duct, no user hint → default to troposcatter assert result.mechanism == :troposcatter end end describe "EME detection (calc-only)" do test "2m 5000 km path with moon visible at both ends -> :eme" do # A 5000 km path on 2m at a time when the moon can be visible at both # ends is basically always EME. result = MechanismClassifier.classify( inputs(%{ band_mhz: 144, distance_km: 5_000.0, # A time chosen so the moon is above both stations — handled by # MoonEphemeris inside the classifier; test just asserts the # classification flows through when distance/band say "EME" qso_timestamp: ~U[2024-08-15 04:00:00Z] }) ) assert result.mechanism == :eme end test "short 300 km 2m path -> NOT EME" do result = MechanismClassifier.classify(inputs(%{band_mhz: 144, distance_km: 300.0})) refute result.mechanism == :eme end test "6 m (50 MHz) is below EME's practical band range -> NOT EME" do result = MechanismClassifier.classify(inputs(%{band_mhz: 50, distance_km: 5_000.0})) refute result.mechanism == :eme end end describe "sporadic-E (ionosonde-driven)" do test "6 m 1500 km path, foEs=12 MHz (MUF=60) -> :sporadic_e" do result = MechanismClassifier.classify(inputs(%{band_mhz: 50, distance_km: 1_500.0, foes_mhz: 12.0})) assert result.mechanism == :sporadic_e end test "2 m 1500 km path, foEs=30 MHz (MUF=150) -> :sporadic_e (extraordinary event)" do result = MechanismClassifier.classify(inputs(%{band_mhz: 144, distance_km: 1_500.0, foes_mhz: 30.0})) assert result.mechanism == :sporadic_e end test "6 m 1500 km with foEs=8 MHz (MUF=40 < 50) -> NOT Es (MUF too low)" do result = MechanismClassifier.classify(inputs(%{band_mhz: 50, distance_km: 1_500.0, foes_mhz: 8.0})) refute result.mechanism == :sporadic_e end test "6 m short 200 km path -> NOT Es (below single-hop Es minimum)" do result = MechanismClassifier.classify(inputs(%{band_mhz: 50, distance_km: 200.0, foes_mhz: 8.0})) refute result.mechanism == :sporadic_e end test "6 m 1500 km with weak foEs=3 MHz -> NOT Es (MUF too low for 6m)" do result = MechanismClassifier.classify(inputs(%{band_mhz: 50, distance_km: 1_500.0, foes_mhz: 3.0})) refute result.mechanism == :sporadic_e end end describe "aurora (Kp-driven + high-lat path)" do test "2 m 500 km N-S path, Kp=7 -> :aurora" do result = MechanismClassifier.classify( inputs(%{ band_mhz: 144, distance_km: 500.0, pos1: %{"lat" => 45.0, "lon" => -95.0}, pos2: %{"lat" => 49.0, "lon" => -95.0}, kp_index: 7 }) ) assert result.mechanism == :aurora end test "10 GHz microwave is far above typical aurora band range -> NOT aurora" do result = MechanismClassifier.classify( inputs(%{ band_mhz: 10_000, kp_index: 8, pos1: %{"lat" => 45.0, "lon" => -95.0}, pos2: %{"lat" => 49.0, "lon" => -95.0} }) ) refute result.mechanism == :aurora end test "quiet Kp=2 on VHF -> NOT aurora even on a polar path" do result = MechanismClassifier.classify( inputs(%{ band_mhz: 144, kp_index: 2, pos1: %{"lat" => 45.0, "lon" => -95.0}, pos2: %{"lat" => 49.0, "lon" => -95.0} }) ) refute result.mechanism == :aurora end end describe "meteor scatter" do test "6 m 900 km path during Perseids -> :meteor_scatter (no other mechanism fires)" do result = MechanismClassifier.classify( inputs(%{ band_mhz: 50, distance_km: 900.0, qso_timestamp: ~U[2024-08-12 10:00:00Z], active_meteor_shower: "Perseids" }) ) assert result.mechanism == :meteor_scatter end test "no active shower -> NOT meteor_scatter" do result = MechanismClassifier.classify(inputs(%{band_mhz: 50, distance_km: 900.0, active_meteor_shower: nil})) refute result.mechanism == :meteor_scatter end test "10 GHz is far above practical meteor scatter -> NOT meteor_scatter" do result = MechanismClassifier.classify(inputs(%{band_mhz: 10_000, active_meteor_shower: "Geminids"})) refute result.mechanism == :meteor_scatter end end describe "rain scatter (delegates to existing logic)" do test "10 GHz 250 km, heavy rain in CV, no duct -> :rain_scatter" do result = MechanismClassifier.classify( inputs(%{ radar: %{max_dbz: 42.0, heavy_rain_pixel_count: 12, coverage_pct: 80.0} }) ) assert result.mechanism == :rain_scatter end test "light rain (25-35 dBZ) -> :rain_scatter_possible" do result = MechanismClassifier.classify( inputs(%{ radar: %{max_dbz: 28.0, heavy_rain_pixel_count: 0, coverage_pct: 80.0} }) ) assert result.mechanism == :rain_scatter_possible end end describe "tropo duct (HRRR native-profile best duct ≥ target band)" do test "10 GHz with native_best_duct_ghz=24 -> :tropo_duct (duct supports target)" do result = MechanismClassifier.classify(inputs(%{native_best_duct_ghz: 24.0})) assert result.mechanism == :tropo_duct end test "10 GHz with native_best_duct_ghz=5 -> does NOT classify as duct at 10 GHz" do # The sub-10-GHz duct doesn't support 10 GHz propagation. result = MechanismClassifier.classify(inputs(%{native_best_duct_ghz: 5.0})) refute result.mechanism == :tropo_duct end test "legacy flag duct_either_endpoint=true still classifies as duct" do result = MechanismClassifier.classify(inputs(%{duct_either_endpoint: true})) assert result.mechanism == :tropo_duct end end describe "line of sight" do test "very short 5 km path -> :line_of_sight" do result = MechanismClassifier.classify(inputs(%{distance_km: 5.0})) assert result.mechanism == :line_of_sight end test "normal 250 km path -> NOT line_of_sight" do result = MechanismClassifier.classify(inputs()) refute result.mechanism == :line_of_sight end end describe "troposcatter (default / fallback)" do test "10 GHz 250 km, no rain, no duct, no hint -> :troposcatter" do result = MechanismClassifier.classify(inputs()) assert result.mechanism == :troposcatter end end describe "confidence levels" do test "user-declared -> :high" do result = MechanismClassifier.classify(inputs(%{user_declared_prop_mode: "RS"})) assert result.confidence == :high end test "strong physics signal -> :medium" do result = MechanismClassifier.classify( inputs(%{ radar: %{max_dbz: 48.0, heavy_rain_pixel_count: 20, coverage_pct: 85.0} }) ) assert result.confidence in [:high, :medium] end test "default troposcatter fallback -> :low" do result = MechanismClassifier.classify(inputs()) assert result.confidence == :low end end end