prop/test/microwaveprop/propagation/mechanism_classifier_test.exs

293 lines
9.3 KiB
Elixir

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≈55 > 50) -> :sporadic_e with :medium confidence" do
result =
MechanismClassifier.classify(inputs(%{band_mhz: 50, distance_km: 1_500.0, foes_mhz: 8.0}))
assert result.mechanism == :sporadic_e
assert result.confidence == :medium
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