Implements BandConfig (data-driven thresholds for 8 bands) and Scorer module with humidity, time-of-day, TD depression, refractivity, sky cover, season, wind, rain attenuation, and pressure trend scoring. Composite score applies BandConfig weights (sum to 1.0) across all factors, producing 0-100 score per band. 10 GHz uses beneficial humidity/ducting model; 24 GHz+ uses harmful absorption model.
486 lines
15 KiB
Elixir
486 lines
15 KiB
Elixir
defmodule Microwaveprop.Propagation.ScorerTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Microwaveprop.Propagation.BandConfig
|
|
alias Microwaveprop.Propagation.Scorer
|
|
|
|
@band_10g BandConfig.get(10_000)
|
|
@band_24g BandConfig.get(24_000)
|
|
@band_75g BandConfig.get(75_000)
|
|
|
|
# ── Helper functions ──────────────────────────────────────────────
|
|
|
|
describe "f_to_c/1" do
|
|
test "converts 32F to 0C" do
|
|
assert_in_delta Scorer.f_to_c(32), 0.0, 0.01
|
|
end
|
|
|
|
test "converts 212F to 100C" do
|
|
assert_in_delta Scorer.f_to_c(212), 100.0, 0.01
|
|
end
|
|
|
|
test "returns nil for nil" do
|
|
assert Scorer.f_to_c(nil) == nil
|
|
end
|
|
end
|
|
|
|
describe "c_to_f/1" do
|
|
test "converts 0C to 32F" do
|
|
assert_in_delta Scorer.c_to_f(0), 32.0, 0.01
|
|
end
|
|
|
|
test "converts 100C to 212F" do
|
|
assert_in_delta Scorer.c_to_f(100), 212.0, 0.01
|
|
end
|
|
|
|
test "returns nil for nil" do
|
|
assert Scorer.c_to_f(nil) == nil
|
|
end
|
|
end
|
|
|
|
describe "absolute_humidity/2" do
|
|
test "calculates g/m3 for typical summer conditions" do
|
|
# ~25C temp, ~20C dewpoint -> ~17 g/m3
|
|
ah = Scorer.absolute_humidity(25, 20)
|
|
assert ah > 15
|
|
assert ah < 20
|
|
end
|
|
|
|
test "calculates g/m3 for cold dry conditions" do
|
|
# ~0C temp, ~-5C dewpoint -> ~3 g/m3
|
|
ah = Scorer.absolute_humidity(0, -5)
|
|
assert ah > 2
|
|
assert ah < 5
|
|
end
|
|
end
|
|
|
|
describe "wind_speed_kts/2" do
|
|
test "calculates wind speed from u and v components" do
|
|
# 3 m/s east + 4 m/s north = 5 m/s = ~9.72 kts
|
|
kts = Scorer.wind_speed_kts(3.0, 4.0)
|
|
assert_in_delta kts, 9.72, 0.1
|
|
end
|
|
|
|
test "returns nil when either component is nil" do
|
|
assert Scorer.wind_speed_kts(nil, 4.0) == nil
|
|
assert Scorer.wind_speed_kts(3.0, nil) == nil
|
|
assert Scorer.wind_speed_kts(nil, nil) == nil
|
|
end
|
|
end
|
|
|
|
describe "precip_to_rate_mmhr/1" do
|
|
test "passes through positive values" do
|
|
assert Scorer.precip_to_rate_mmhr(2.5) == 2.5
|
|
end
|
|
|
|
test "returns 0.0 for zero" do
|
|
assert Scorer.precip_to_rate_mmhr(0) == 0.0
|
|
end
|
|
|
|
test "returns 0.0 for negative" do
|
|
assert Scorer.precip_to_rate_mmhr(-1.0) == 0.0
|
|
end
|
|
|
|
test "returns 0.0 for nil" do
|
|
assert Scorer.precip_to_rate_mmhr(nil) == 0.0
|
|
end
|
|
end
|
|
|
|
# ── score_humidity/2 ──────────────────────────────────────────────
|
|
|
|
describe "score_humidity/2 beneficial (10 GHz)" do
|
|
test "low humidity gets first threshold score" do
|
|
# abs_humidity < 4 -> 55
|
|
assert Scorer.score_humidity(3.0, @band_10g) == 55
|
|
end
|
|
|
|
test "moderate humidity gets higher score" do
|
|
# abs_humidity in 7..10 -> 82
|
|
assert Scorer.score_humidity(9.0, @band_10g) == 82
|
|
end
|
|
|
|
test "high humidity gets peak score" do
|
|
# abs_humidity in 14..18 -> 95
|
|
assert Scorer.score_humidity(16.0, @band_10g) == 95
|
|
end
|
|
|
|
test "very high humidity gets decreased score" do
|
|
# abs_humidity in 18..22 -> 88
|
|
assert Scorer.score_humidity(20.0, @band_10g) == 88
|
|
end
|
|
|
|
test "extreme humidity gets default" do
|
|
# abs_humidity > 22 -> 75 (default)
|
|
assert Scorer.score_humidity(25.0, @band_10g) == 75
|
|
end
|
|
end
|
|
|
|
describe "score_humidity/2 harmful (24 GHz)" do
|
|
test "very low effective humidity gets 100" do
|
|
# r = abs_humidity * 1.6 = 2 * 1.6 = 3.2, r<=6 -> 100
|
|
assert Scorer.score_humidity(2.0, @band_24g) == 100
|
|
end
|
|
|
|
test "moderate effective humidity gets reduced score" do
|
|
# r = 5 * 1.6 = 8.0, 6 < r <= 9 -> round(95 - (8-6)/3*20) = round(95 - 13.33) = 82
|
|
assert Scorer.score_humidity(5.0, @band_24g) == 82
|
|
end
|
|
|
|
test "high effective humidity gets low score" do
|
|
# r = 10 * 1.6 = 16.0, 13 < r <= 18 -> round(45 - (16-13)/5*35) = round(45 - 21) = 24
|
|
assert Scorer.score_humidity(10.0, @band_24g) == 24
|
|
end
|
|
|
|
test "extreme effective humidity gets near zero" do
|
|
# r = 20 * 1.6 = 32.0, r > 18 -> max(0, round(10 - (32-18)*2)) = max(0, round(10-28)) = 0
|
|
assert Scorer.score_humidity(20.0, @band_24g) == 0
|
|
end
|
|
end
|
|
|
|
# ── score_time_of_day/3 ──────────────────────────────────────────
|
|
|
|
describe "score_time_of_day/3" do
|
|
test "dawn peak in June returns 100" do
|
|
# June sunrise ~6.25, CDT offset -5
|
|
# local = (11 + 15/60 - 5 + 24) mod 24 = 30.25 mod 24 = 6.25
|
|
# d = 6.25 - 6.25 = 0.0, |d| <= 1.5 -> 100
|
|
{score, label} = Scorer.score_time_of_day(11, 15, 6)
|
|
assert score == 100
|
|
assert label =~ "Peak"
|
|
end
|
|
|
|
test "afternoon in June returns 18" do
|
|
# local = (22 + 0/60 - 5 + 24) mod 24 = 17.0
|
|
# d = 17.0 - 6.25 = 10.75, d > 6 -> 18
|
|
{score, _label} = Scorer.score_time_of_day(22, 0, 6)
|
|
assert score == 18
|
|
end
|
|
|
|
test "pre-dawn returns 82" do
|
|
# June sunrise ~6.25, CDT offset -5
|
|
# local = (9 + 0/60 - 5 + 24) mod 24 = 4.0
|
|
# d = 4.0 - 6.25 = -2.25, in (-3.0, -1.5) -> 82
|
|
{score, label} = Scorer.score_time_of_day(9, 0, 6)
|
|
assert score == 82
|
|
assert label =~ "Pre-dawn"
|
|
end
|
|
|
|
test "evening returns 72" do
|
|
# June CDT offset -5
|
|
# local = (1 + 0/60 - 5 + 24) mod 24 = 20.0
|
|
# local >= 20 -> 72
|
|
{score, label} = Scorer.score_time_of_day(1, 0, 6)
|
|
assert score == 72
|
|
assert label =~ "Evening"
|
|
end
|
|
|
|
test "winter uses CST offset" do
|
|
# January, CST offset -6
|
|
# local = (13 + 24/60 - 6 + 24) mod 24 = 7.4
|
|
# sunrise for Jan = 7.4, d = 0.0 -> 100
|
|
{score, _label} = Scorer.score_time_of_day(13, 24, 1)
|
|
assert score == 100
|
|
end
|
|
end
|
|
|
|
# ── score_td_depression/3 ────────────────────────────────────────
|
|
|
|
describe "score_td_depression/3 beneficial (10 GHz)" do
|
|
test "very tight depression (saturated air) returns 40" do
|
|
# dep = 72 - 70 = 2, <3 -> 40
|
|
assert Scorer.score_td_depression(72, 70, @band_10g) == 40
|
|
end
|
|
|
|
test "moderate depression returns 85" do
|
|
# dep = 80 - 70 = 10, 8..14 -> 85
|
|
assert Scorer.score_td_depression(80, 70, @band_10g) == 85
|
|
end
|
|
|
|
test "wide depression returns 55" do
|
|
# dep = 100 - 50 = 50, >=22 -> 55
|
|
assert Scorer.score_td_depression(100, 50, @band_10g) == 55
|
|
end
|
|
end
|
|
|
|
describe "score_td_depression/3 harmful (24 GHz)" do
|
|
test "very tight depression returns 18 (bad for high freq)" do
|
|
# dep = 72 - 70 = 2, <=4 -> 18
|
|
assert Scorer.score_td_depression(72, 70, @band_24g) == 18
|
|
end
|
|
|
|
test "moderate depression returns 60" do
|
|
# dep = 80 - 70 = 10, 8..14 -> 60
|
|
assert Scorer.score_td_depression(80, 70, @band_24g) == 60
|
|
end
|
|
|
|
test "wide depression returns 96 (good for high freq)" do
|
|
# dep = 100 - 50 = 50, >22 -> 96
|
|
assert Scorer.score_td_depression(100, 50, @band_24g) == 96
|
|
end
|
|
end
|
|
|
|
# ── score_refractivity/3 ─────────────────────────────────────────
|
|
|
|
describe "score_refractivity/3" do
|
|
test "strong ducting gradient returns high score for beneficial" do
|
|
# gradient < -500 -> 98 for beneficial
|
|
assert Scorer.score_refractivity(-600, 500, @band_10g) == 98
|
|
end
|
|
|
|
test "strong ducting gradient returns 85 for harmful" do
|
|
# gradient < -500 -> 85 for harmful
|
|
assert Scorer.score_refractivity(-600, 500, @band_24g) == 85
|
|
end
|
|
|
|
test "moderate gradient returns appropriate score" do
|
|
# gradient < -300 but >= -500 -> 92 for beneficial
|
|
assert Scorer.score_refractivity(-400, 500, @band_10g) == 92
|
|
end
|
|
|
|
test "nil gradient returns 50" do
|
|
assert Scorer.score_refractivity(nil, 500, @band_10g) == 50
|
|
end
|
|
|
|
test "weak gradient with shallow BL returns shallow BL score" do
|
|
# gradient > -60 (no threshold match), bl_depth < 300 -> 82
|
|
assert Scorer.score_refractivity(-30, 200, @band_10g) == 82
|
|
end
|
|
|
|
test "weak gradient with deep BL returns default" do
|
|
# gradient > -60, bl_depth >= 300 -> 42
|
|
assert Scorer.score_refractivity(-30, 500, @band_10g) == 42
|
|
end
|
|
end
|
|
|
|
# ── score_sky/1 ──────────────────────────────────────────────────
|
|
|
|
describe "score_sky/1" do
|
|
test "clear sky returns 100" do
|
|
assert Scorer.score_sky(5) == 100
|
|
end
|
|
|
|
test "few clouds returns 88" do
|
|
assert Scorer.score_sky(20) == 88
|
|
end
|
|
|
|
test "scattered returns 60" do
|
|
assert Scorer.score_sky(40) == 60
|
|
end
|
|
|
|
test "broken returns 25" do
|
|
assert Scorer.score_sky(75) == 25
|
|
end
|
|
|
|
test "overcast returns 5" do
|
|
assert Scorer.score_sky(95) == 5
|
|
end
|
|
|
|
test "nil returns 50" do
|
|
assert Scorer.score_sky(nil) == 50
|
|
end
|
|
end
|
|
|
|
# ── score_season/2 ───────────────────────────────────────────────
|
|
|
|
describe "score_season/2" do
|
|
test "10 GHz summer peak" do
|
|
# July base = 95, adj = 0 -> 95
|
|
assert Scorer.score_season(7, @band_10g) == 95
|
|
end
|
|
|
|
test "10 GHz winter low" do
|
|
# March base = 22, adj = 0 -> 22
|
|
assert Scorer.score_season(3, @band_10g) == 22
|
|
end
|
|
|
|
test "24 GHz winter peak" do
|
|
# November base = 96, adj = 0 -> 96
|
|
assert Scorer.score_season(11, @band_24g) == 96
|
|
end
|
|
|
|
test "24 GHz summer with adjustment" do
|
|
# July base = 18, adj = -10 -> 8
|
|
assert Scorer.score_season(7, @band_24g) == 8
|
|
end
|
|
|
|
test "clamped to 0-100" do
|
|
# Even if base + adj > 100, clamp
|
|
assert Scorer.score_season(1, @band_10g) >= 0
|
|
assert Scorer.score_season(1, @band_10g) <= 100
|
|
end
|
|
end
|
|
|
|
# ── score_wind/1 ─────────────────────────────────────────────────
|
|
|
|
describe "score_wind/1" do
|
|
test "calm returns 100" do
|
|
assert Scorer.score_wind(3) == 100
|
|
end
|
|
|
|
test "light returns 90" do
|
|
assert Scorer.score_wind(8) == 90
|
|
end
|
|
|
|
test "moderate returns 75" do
|
|
assert Scorer.score_wind(12) == 75
|
|
end
|
|
|
|
test "strong returns 35" do
|
|
assert Scorer.score_wind(22) == 35
|
|
end
|
|
|
|
test "very strong returns 15" do
|
|
assert Scorer.score_wind(30) == 15
|
|
end
|
|
|
|
test "nil returns 50" do
|
|
assert Scorer.score_wind(nil) == 50
|
|
end
|
|
end
|
|
|
|
# ── score_rain/2 ─────────────────────────────────────────────────
|
|
|
|
describe "score_rain/2" do
|
|
test "no rain returns 100" do
|
|
assert Scorer.score_rain(0, @band_24g) == 100
|
|
end
|
|
|
|
test "nil rain returns 100" do
|
|
assert Scorer.score_rain(nil, @band_24g) == 100
|
|
end
|
|
|
|
test "light rain with 24 GHz" do
|
|
# gamma = 0.070 * rate^1.07
|
|
# For rate = 2.0: gamma = 0.070 * 2.0^1.07 ≈ 0.147
|
|
# 0.1 < gamma <= 0.5 -> 75
|
|
assert Scorer.score_rain(2.0, @band_24g) == 75
|
|
end
|
|
|
|
test "heavy rain with 10 GHz" do
|
|
# gamma = 0.010 * rate^1.28
|
|
# rate = 5.0: gamma = 0.010 * 5.0^1.28 ≈ 0.076
|
|
# gamma < 0.1 -> 95
|
|
assert Scorer.score_rain(5.0, @band_10g) == 95
|
|
end
|
|
|
|
test "heavy rain with 75 GHz gets very low score" do
|
|
# gamma = 0.345 * 10^0.84 ≈ 2.39
|
|
# 2.0 < gamma <= 5.0 -> 10
|
|
assert Scorer.score_rain(10.0, @band_75g) == 10
|
|
end
|
|
end
|
|
|
|
# ── score_pressure/2 ─────────────────────────────────────────────
|
|
|
|
describe "score_pressure/2" do
|
|
test "high pressure without previous returns 55" do
|
|
assert Scorer.score_pressure(1028, nil) == 55
|
|
end
|
|
|
|
test "normal pressure without previous returns 65" do
|
|
assert Scorer.score_pressure(1020, nil) == 65
|
|
end
|
|
|
|
test "low pressure without previous returns 40" do
|
|
assert Scorer.score_pressure(1000, nil) == 40
|
|
end
|
|
|
|
test "rising pressure returns 80" do
|
|
# delta = 1020 - 1015 = 5, > 2.5 -> 80
|
|
assert Scorer.score_pressure(1020, 1015) == 80
|
|
end
|
|
|
|
test "steady pressure returns 70" do
|
|
# delta = 1016 - 1015 = 1, 0.8 < delta <= 2.5 -> 70
|
|
assert Scorer.score_pressure(1016, 1015) == 70
|
|
end
|
|
|
|
test "slight fall returns 60" do
|
|
# delta = 1015 - 1015.3 = -0.3, -0.5 < delta <= 0.8 -> 60
|
|
assert Scorer.score_pressure(1015, 1015.3) == 60
|
|
end
|
|
|
|
test "moderate fall returns 65" do
|
|
# delta = 1014 - 1015 = -1.0, -2.0 < delta <= -0.5 -> 65
|
|
assert Scorer.score_pressure(1014, 1015) == 65
|
|
end
|
|
|
|
test "sharp fall returns 45" do
|
|
# delta = 1010 - 1015 = -5.0, <= -2.0 -> 45
|
|
assert Scorer.score_pressure(1010, 1015) == 45
|
|
end
|
|
end
|
|
|
|
# ── composite_score/2 ────────────────────────────────────────────
|
|
|
|
describe "composite_score/2" do
|
|
@conditions %{
|
|
abs_humidity: 10.0,
|
|
temp_f: 80,
|
|
dewpoint_f: 70,
|
|
wind_speed_kts: 5,
|
|
sky_cover_pct: 20,
|
|
utc_hour: 11,
|
|
utc_minute: 15,
|
|
month: 6,
|
|
pressure_mb: 1020,
|
|
prev_pressure_mb: 1018,
|
|
rain_rate_mmhr: 0,
|
|
min_refractivity_gradient: -350,
|
|
bl_depth_m: 400
|
|
}
|
|
|
|
test "returns score between 0 and 100" do
|
|
result = Scorer.composite_score(@conditions, @band_10g)
|
|
assert result.score >= 0
|
|
assert result.score <= 100
|
|
end
|
|
|
|
test "returns all 9 factor scores" do
|
|
result = Scorer.composite_score(@conditions, @band_10g)
|
|
assert Map.has_key?(result.factors, :humidity)
|
|
assert Map.has_key?(result.factors, :time_of_day)
|
|
assert Map.has_key?(result.factors, :td_depression)
|
|
assert Map.has_key?(result.factors, :refractivity)
|
|
assert Map.has_key?(result.factors, :sky)
|
|
assert Map.has_key?(result.factors, :season)
|
|
assert Map.has_key?(result.factors, :wind)
|
|
assert Map.has_key?(result.factors, :rain)
|
|
assert Map.has_key?(result.factors, :pressure)
|
|
end
|
|
|
|
test "uses weights from BandConfig" do
|
|
result = Scorer.composite_score(@conditions, @band_10g)
|
|
weights = BandConfig.weights()
|
|
|
|
# Manually compute expected score
|
|
expected =
|
|
result.factors.humidity * weights.humidity +
|
|
result.factors.time_of_day * weights.time_of_day +
|
|
result.factors.td_depression * weights.td_depression +
|
|
result.factors.refractivity * weights.refractivity +
|
|
result.factors.sky * weights.sky +
|
|
result.factors.season * weights.season +
|
|
result.factors.wind * weights.wind +
|
|
result.factors.rain * weights.rain +
|
|
result.factors.pressure * weights.pressure
|
|
|
|
assert_in_delta result.score, round(expected), 1
|
|
end
|
|
|
|
test "10 GHz vs 24 GHz give different scores" do
|
|
result_10g = Scorer.composite_score(@conditions, @band_10g)
|
|
result_24g = Scorer.composite_score(@conditions, @band_24g)
|
|
# In humid summer conditions, 10 GHz should score higher
|
|
assert result_10g.score != result_24g.score
|
|
end
|
|
|
|
test "each factor score is between 0 and 100" do
|
|
result = Scorer.composite_score(@conditions, @band_10g)
|
|
|
|
for {_factor, score} <- result.factors do
|
|
assert score >= 0 and score <= 100
|
|
end
|
|
end
|
|
end
|
|
end
|