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 returns default regardless of BL depth" do # gradient > -40 (no threshold match) -> default 42 assert Scorer.score_refractivity(-30, 200, @band_10g) == 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/4 ─────────────────────────────────────────────── describe "score_season/4" do test "10 GHz summer peak (no region)" do # July base = 95, adj = 0, no region mult -> 95 assert Scorer.score_season(7, nil, nil, @band_10g) == 95 end test "10 GHz winter low" do assert Scorer.score_season(3, nil, nil, @band_10g) == 22 end test "24 GHz winter peak" do assert Scorer.score_season(11, nil, nil, @band_24g) == 96 end test "24 GHz summer with adjustment" do assert Scorer.score_season(7, nil, nil, @band_24g) == 8 end test "clamped to 0-100" do assert Scorer.score_season(1, nil, nil, @band_10g) >= 0 assert Scorer.score_season(1, nil, nil, @band_10g) <= 100 end test "applies regional adjustment for Gulf coast August" do # Gulf coast August mult = 1.15, so score = base * 1.15 gulf_lat = 29.0 gulf_lon = -95.0 no_region = Scorer.score_season(8, nil, nil, @band_10g) gulf_score = Scorer.score_season(8, gulf_lat, gulf_lon, @band_10g) assert gulf_score > no_region end test "applies regional adjustment for Corn Belt August" do # Corn Belt August mult = 0.80, so score = base * 0.80 iowa_lat = 42.0 iowa_lon = -93.0 no_region = Scorer.score_season(8, nil, nil, @band_10g) iowa_score = Scorer.score_season(8, iowa_lat, iowa_lon, @band_10g) assert iowa_score < no_region 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 55" do # 1000 is in [1000, 1010) -> 55 assert Scorer.score_pressure(1000, nil) == 55 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