Score time-of-day per grid point using longitude/15 solar offset instead of hardcoded CST/CDT. Add PWAT as 10th scoring factor. Refine pressure thresholds. Update ML model and training pipeline to use local solar time.
555 lines
18 KiB
Elixir
555 lines
18 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/4 ──────────────────────────────────────────
|
|
|
|
describe "score_time_of_day/4" do
|
|
# lon -75.0 gives solar offset of -5.0 hours
|
|
@east_lon -75.0
|
|
|
|
test "dawn peak in June returns 100" do
|
|
# June sunrise ~6.25, lon -75 -> offset -5
|
|
# local = (11 + 15/60 - 5 + 24) 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, @east_lon)
|
|
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, @east_lon)
|
|
assert score == 18
|
|
end
|
|
|
|
test "pre-dawn returns 82" do
|
|
# June sunrise ~6.25, lon -75 -> 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, @east_lon)
|
|
assert score == 82
|
|
assert label =~ "Pre-dawn"
|
|
end
|
|
|
|
test "evening returns 72" do
|
|
# lon -75 -> 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, @east_lon)
|
|
assert score == 72
|
|
assert label =~ "Evening"
|
|
end
|
|
|
|
test "western longitude shifts local time earlier" do
|
|
# lon -90 -> offset -6, January sunrise 7.4
|
|
# local = (13 + 24/60 - 6 + 24) mod 24 = 7.4
|
|
# d = 7.4 - 7.4 = 0.0 -> 100
|
|
{score, _label} = Scorer.score_time_of_day(13, 24, 1, -90.0)
|
|
assert score == 100
|
|
end
|
|
|
|
test "same UTC hour scores differently at different longitudes" do
|
|
# 18 UTC in June: lon -75 -> local 13.0 (afternoon), lon -120 -> local 10.0 (morning)
|
|
{east_score, _} = Scorer.score_time_of_day(18, 0, 6, -75.0)
|
|
{west_score, _} = Scorer.score_time_of_day(18, 0, 6, -120.0)
|
|
assert west_score > east_score
|
|
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 gradient returns 85 for harmful" do
|
|
# gradient < -200 -> 85 for harmful (HRRR-calibrated)
|
|
assert Scorer.score_refractivity(-250, 500, @band_24g) == 85
|
|
end
|
|
|
|
test "moderate gradient returns appropriate score" do
|
|
# gradient < -150 but >= -200 -> 92 for beneficial (HRRR-calibrated)
|
|
assert Scorer.score_refractivity(-160, 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_pwat/2 ─────────────────────────────────────────────────
|
|
|
|
describe "score_pwat/2 beneficial (10 GHz)" do
|
|
test "low PWAT returns 55" do
|
|
assert Scorer.score_pwat(5, @band_10g) == 55
|
|
end
|
|
|
|
test "moderate PWAT returns 75" do
|
|
assert Scorer.score_pwat(15, @band_10g) == 75
|
|
end
|
|
|
|
test "optimal PWAT returns 90" do
|
|
assert Scorer.score_pwat(25, @band_10g) == 90
|
|
end
|
|
|
|
test "high PWAT returns 70" do
|
|
assert Scorer.score_pwat(35, @band_10g) == 70
|
|
end
|
|
|
|
test "very high PWAT returns 50" do
|
|
assert Scorer.score_pwat(45, @band_10g) == 50
|
|
end
|
|
end
|
|
|
|
describe "score_pwat/2 harmful (24 GHz)" do
|
|
test "low PWAT returns 95" do
|
|
assert Scorer.score_pwat(5, @band_24g) == 95
|
|
end
|
|
|
|
test "moderate PWAT returns 80" do
|
|
assert Scorer.score_pwat(15, @band_24g) == 80
|
|
end
|
|
|
|
test "high PWAT returns 60" do
|
|
assert Scorer.score_pwat(25, @band_24g) == 60
|
|
end
|
|
|
|
test "very high PWAT returns 35" do
|
|
assert Scorer.score_pwat(35, @band_24g) == 35
|
|
end
|
|
|
|
test "extreme PWAT returns 15" do
|
|
assert Scorer.score_pwat(45, @band_24g) == 15
|
|
end
|
|
end
|
|
|
|
describe "score_pwat/2 nil" do
|
|
test "nil returns 60" do
|
|
assert Scorer.score_pwat(nil, @band_10g) == 60
|
|
assert Scorer.score_pwat(nil, @band_24g) == 60
|
|
end
|
|
end
|
|
|
|
# ── score_pressure/2 ─────────────────────────────────────────────
|
|
|
|
describe "score_pressure/2" do
|
|
test "high pressure without previous returns 30" do
|
|
assert Scorer.score_pressure(1028, nil) == 30
|
|
end
|
|
|
|
test "normal pressure without previous returns 45" do
|
|
# 1015 <= 1020 < 1020 is false, so >= 1020 -> 30
|
|
# Actually 1020 is not < 1020, so it falls to true -> 30
|
|
assert Scorer.score_pressure(1020, nil) == 30
|
|
end
|
|
|
|
test "low pressure without previous returns 80" do
|
|
assert Scorer.score_pressure(1000, nil) == 80
|
|
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,
|
|
longitude: -75.0,
|
|
pressure_mb: 1020,
|
|
prev_pressure_mb: 1018,
|
|
rain_rate_mmhr: 0,
|
|
min_refractivity_gradient: -350,
|
|
bl_depth_m: 400,
|
|
pwat_mm: 25.0
|
|
}
|
|
|
|
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 10 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, :pwat)
|
|
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.pwat * weights.pwat +
|
|
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
|