293 lines
9.3 KiB
Elixir
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
|