`Scorer.aurora_boost(score, kp, band_config)` is a terminal boost applied after `composite_score` in the same shape as the existing `commercial_link_boost`. It tracks the NOAA G-scale (Kp 5+ opens auroral E-region paths up to ~2500 km) and is freq-gated to ≤ 432 MHz — auroral propagation is observed almost exclusively on 50/144/222, occasionally on 432, and never at microwave. Boost magnitudes: * Kp < 4 (quiet) — pass-through * Kp 4 — +5 * Kp 5 (G1) — +15 * Kp 6 (G2) — +25 * Kp ≥ 7 (G3+) — +35, clamped to 100 Wired into `Propagation.score_grid_point/4` via the existing `hrrr_profile` map: callers populate `:kp_index` once per cycle so the per-grid-point band loop never queries the DB. The UI fallback path (`factors_from_profile/5`) sources Kp from `SpaceWeather.latest_kp/0` directly. Other call sites (GEFS forecast, path-compute, contact-show) can opt in by adding `:kp_index` to their profile map. Diagnostic factor `:kp_index` is stashed on VHF/low-UHF results so the operator-facing factor breakdown can show why a band scored above its quiet-conditions baseline. Microwave bands don't carry the diagnostic — they're never affected by the boost. Refactored `score_with_algorithm/7` to delegate band-level finalization to `finalize_band_result/5`, keeping the parent function under credo's cyclomatic-complexity limit. NOTE: this only affects the Elixir scoring path — the live grid served by `prop_grid_rs` (Rust) does its own scoring and does not yet honor Kp. Adding aurora to the Rust side is follow-up work.
947 lines
32 KiB
Elixir
947 lines
32 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
|
||
|
||
describe "dbz_to_rain_rate_mmhr/1" do
|
||
test "returns 0.0 for nil" do
|
||
assert Scorer.dbz_to_rain_rate_mmhr(nil) == 0.0
|
||
end
|
||
|
||
test "returns 0.0 for sub-rain reflectivity (below 5 dBZ)" do
|
||
# Clear air / ground clutter — not rain.
|
||
assert Scorer.dbz_to_rain_rate_mmhr(3.0) == 0.0
|
||
assert Scorer.dbz_to_rain_rate_mmhr(0.0) == 0.0
|
||
end
|
||
|
||
test "20 dBZ is light rain ~0.7 mm/hr (Marshall-Palmer)" do
|
||
# Z = 10^(dbz/10) = 100; R = (100/200)^(1/1.6) ≈ 0.66 mm/hr
|
||
rate = Scorer.dbz_to_rain_rate_mmhr(20.0)
|
||
assert_in_delta rate, 0.66, 0.05
|
||
end
|
||
|
||
test "30 dBZ is moderate rain ~2.7 mm/hr" do
|
||
# Z = 1000; R = (1000/200)^(1/1.6) ≈ 2.7 mm/hr
|
||
rate = Scorer.dbz_to_rain_rate_mmhr(30.0)
|
||
assert_in_delta rate, 2.7, 0.2
|
||
end
|
||
|
||
test "45 dBZ is heavy rain ~24 mm/hr" do
|
||
# Z = 31623; R = (31623/200)^(1/1.6) ≈ 23.7 mm/hr
|
||
rate = Scorer.dbz_to_rain_rate_mmhr(45.0)
|
||
assert_in_delta rate, 23.7, 1.0
|
||
end
|
||
|
||
test "55 dBZ clips to a hail-safe ceiling (≤150 mm/hr)" do
|
||
# Pure Marshall-Palmer at 55 dBZ returns ~116 mm/hr; at 60 it blows past
|
||
# 200 which is unrealistic — hail contamination. Clip to the ITU rain-rate
|
||
# tabulated range.
|
||
assert Scorer.dbz_to_rain_rate_mmhr(55.0) <= 150.0
|
||
assert Scorer.dbz_to_rain_rate_mmhr(65.0) <= 150.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
|
||
# As of the 2026-04-25 algo revisions the HPBL multiplier and the
|
||
# native-profile 1.15× duct boost are both retired (`docs/algo-reports/
|
||
# 2026-04-25-algo-revisions.md` Recommendations 1 and 2). HPBL had
|
||
# rho ≤ 0.092 at every band and +0.004 at 10 GHz on n=47,418 — well
|
||
# below the noise floor. The function arities are kept so callers
|
||
# continue compiling, but they all collapse onto the gradient-only
|
||
# base score.
|
||
@baseline_bl 750
|
||
|
||
test "strong ducting gradient returns high score for beneficial" do
|
||
# gradient < -500 -> 98 for beneficial
|
||
assert Scorer.score_refractivity(-600, @baseline_bl, @band_10g) == 98
|
||
end
|
||
|
||
test "strong gradient returns 85 for harmful" do
|
||
# gradient < -200 -> 85 for harmful (HRRR-calibrated)
|
||
assert Scorer.score_refractivity(-250, @baseline_bl, @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, @baseline_bl, @band_10g) == 92
|
||
end
|
||
|
||
test "nil gradient returns 50" do
|
||
assert Scorer.score_refractivity(nil, @baseline_bl, @band_10g) == 50
|
||
end
|
||
|
||
test "nil bl_depth_m matches any other bl_depth_m (HPBL retired)" do
|
||
assert Scorer.score_refractivity(-160, nil, @band_10g) ==
|
||
Scorer.score_refractivity(-160, @baseline_bl, @band_10g)
|
||
end
|
||
|
||
test "shallow boundary layer is no longer a boost" do
|
||
shallow = Scorer.score_refractivity(-100, 150, @band_10g)
|
||
baseline = Scorer.score_refractivity(-100, @baseline_bl, @band_10g)
|
||
assert shallow == baseline
|
||
end
|
||
|
||
test "deep boundary layer is no longer a penalty" do
|
||
deep = Scorer.score_refractivity(-100, 2200, @band_10g)
|
||
baseline = Scorer.score_refractivity(-100, @baseline_bl, @band_10g)
|
||
assert deep == baseline
|
||
end
|
||
|
||
test "HPBL has no effect on the default-fallback branch either" do
|
||
shallow_default = Scorer.score_refractivity(-30, 150, @band_10g)
|
||
baseline_default = Scorer.score_refractivity(-30, @baseline_bl, @band_10g)
|
||
deep_default = Scorer.score_refractivity(-30, 2200, @band_10g)
|
||
assert shallow_default == baseline_default
|
||
assert deep_default == baseline_default
|
||
end
|
||
end
|
||
|
||
describe "score_refractivity/4 with native-profile duct support" do
|
||
# Native-duct 1.15× boost retired — see Recommendation 1 in
|
||
# `docs/algo-reports/2026-04-25-algo-revisions.md`. At 10 GHz on
|
||
# n=52,341, contacts where the native duct supports the band ran
|
||
# *shorter* (198 km) than no-duct contacts (211 km). Arity kept,
|
||
# behaviour collapses onto the 3-arity gradient-only score.
|
||
|
||
test "native duct supporting the target band no longer boosts" do
|
||
with_duct = Scorer.score_refractivity(-80, 800, 15.0, @band_10g)
|
||
plain = Scorer.score_refractivity(-80, 800, nil, @band_10g)
|
||
assert with_duct == plain
|
||
end
|
||
|
||
test "native duct below the target band still produces the same plain score" do
|
||
unchanged = Scorer.score_refractivity(-80, 800, 3.0, @band_10g)
|
||
plain = Scorer.score_refractivity(-80, 800, nil, @band_10g)
|
||
assert unchanged == plain
|
||
end
|
||
|
||
test "nil best_duct_band_ghz is equivalent to the 3-arity form" do
|
||
assert Scorer.score_refractivity(-100, 600, nil, @band_10g) ==
|
||
Scorer.score_refractivity(-100, 600, @band_10g)
|
||
end
|
||
|
||
test "nil gradient still returns 50 regardless of duct data" do
|
||
assert Scorer.score_refractivity(nil, 600, 15.0, @band_10g) == 50
|
||
end
|
||
|
||
test "24 GHz is no longer boosted by any native duct band" do
|
||
weak = Scorer.score_refractivity(-80, 800, 15.0, @band_24g)
|
||
strong = Scorer.score_refractivity(-80, 800, 30.0, @band_24g)
|
||
plain = Scorer.score_refractivity(-80, 800, nil, @band_24g)
|
||
assert weak == plain
|
||
assert strong == plain
|
||
end
|
||
end
|
||
|
||
describe "score_refractivity/5 with bulk-Richardson gating" do
|
||
# The Richardson gate existed only to suppress the native-duct
|
||
# boost during mechanically-mixed conditions. Now that the boost
|
||
# itself is retired, Richardson is irrelevant — every input maps
|
||
# to the same plain gradient-only score. Arity preserved so prod
|
||
# call sites in propagation/recalibrator and the Rust comparator
|
||
# don't need an emergency rewrite.
|
||
|
||
test "stable Richardson + matching duct band ≡ plain" do
|
||
stable = Scorer.score_refractivity(-80, 800, 15.0, 12.0, @band_10g)
|
||
plain = Scorer.score_refractivity(-80, 800, nil, nil, @band_10g)
|
||
assert stable == plain
|
||
end
|
||
|
||
test "turbulent Richardson + matching duct band ≡ plain" do
|
||
gated = Scorer.score_refractivity(-80, 800, 15.0, 40.0, @band_10g)
|
||
plain = Scorer.score_refractivity(-80, 800, nil, nil, @band_10g)
|
||
assert gated == plain
|
||
end
|
||
|
||
test "nil Richardson ≡ plain" do
|
||
with_nil_r = Scorer.score_refractivity(-80, 800, 15.0, nil, @band_10g)
|
||
four_arity = Scorer.score_refractivity(-80, 800, 15.0, @band_10g)
|
||
assert with_nil_r == four_arity
|
||
end
|
||
|
||
test "Richardson at the old 25 boundary ≡ plain" do
|
||
gated = Scorer.score_refractivity(-80, 800, 15.0, 25.0, @band_10g)
|
||
plain = Scorer.score_refractivity(-80, 800, nil, nil, @band_10g)
|
||
assert gated == plain
|
||
end
|
||
end
|
||
|
||
describe "commercial_link_boost/2" do
|
||
# Inverse sensor: when nearby commercial LOS links are fading below baseline,
|
||
# the *same* multipath that hurts them is the enhanced refractivity that
|
||
# helps beyond-LOS amateur paths. >8 dB of degradation means multi-Fresnel-
|
||
# zone disturbance — strong boost. 3-8 dB is a mild boost. <3 dB is noise.
|
||
|
||
test "nil degradation is a no-op" do
|
||
assert Scorer.commercial_link_boost(75, nil) == 75
|
||
end
|
||
|
||
test "<3 dB fade is noise and does not boost" do
|
||
assert Scorer.commercial_link_boost(75, %{degradation_db: 2.0, n_links: 3}) == 75
|
||
end
|
||
|
||
test "5 dB fade applies a mild boost" do
|
||
boosted = Scorer.commercial_link_boost(75, %{degradation_db: 5.0, n_links: 3})
|
||
assert boosted > 75
|
||
assert boosted < 90
|
||
end
|
||
|
||
test "12 dB fade applies a large boost capped at 100" do
|
||
boosted = Scorer.commercial_link_boost(75, %{degradation_db: 12.0, n_links: 3})
|
||
assert boosted > 85
|
||
assert boosted <= 100
|
||
end
|
||
|
||
test "single-link result still boosts (n_links >= 1)" do
|
||
boosted = Scorer.commercial_link_boost(70, %{degradation_db: 6.0, n_links: 1})
|
||
assert boosted > 70
|
||
end
|
||
|
||
test "empty-links (n_links == 0) is a no-op" do
|
||
assert Scorer.commercial_link_boost(75, %{degradation_db: 10.0, n_links: 0}) == 75
|
||
end
|
||
end
|
||
|
||
describe "aurora_boost/3" do
|
||
# Auroral E-region scatter is a 50/144/222 phenomenon (rare on
|
||
# 432, essentially never above). Boost magnitude tracks the NOAA
|
||
# G-scale: Kp 5+ events open paths up to ~2500 km. Below Kp 4 the
|
||
# geomagnetic field is quiet and the boost is a no-op.
|
||
@vhf2m %{freq_mhz: 144}
|
||
@uhf %{freq_mhz: 432}
|
||
@microwave10g %{freq_mhz: 10_000}
|
||
|
||
test "nil Kp is a no-op" do
|
||
assert Scorer.aurora_boost(60, nil, @vhf2m) == 60
|
||
end
|
||
|
||
test "microwave bands (>432 MHz) pass through regardless of Kp" do
|
||
assert Scorer.aurora_boost(60, 7, @microwave10g) == 60
|
||
assert Scorer.aurora_boost(60, 9, @microwave10g) == 60
|
||
end
|
||
|
||
test "geomagnetically quiet (Kp < 4) is a no-op on VHF" do
|
||
assert Scorer.aurora_boost(60, 0, @vhf2m) == 60
|
||
assert Scorer.aurora_boost(60, 3, @vhf2m) == 60
|
||
end
|
||
|
||
test "Kp 4 (unsettled) is a small boost on VHF" do
|
||
assert Scorer.aurora_boost(60, 4, @vhf2m) == 65
|
||
end
|
||
|
||
test "Kp 5 (G1 storm) is a moderate boost on VHF" do
|
||
assert Scorer.aurora_boost(60, 5, @vhf2m) == 75
|
||
end
|
||
|
||
test "Kp 6 (G2 storm) is a strong boost on VHF" do
|
||
assert Scorer.aurora_boost(60, 6, @vhf2m) == 85
|
||
end
|
||
|
||
test "Kp 7+ (G3+ storm) saturates the boost" do
|
||
assert Scorer.aurora_boost(60, 7, @vhf2m) == 95
|
||
assert Scorer.aurora_boost(60, 9, @vhf2m) == 95
|
||
end
|
||
|
||
test "boost is clamped to 100" do
|
||
assert Scorer.aurora_boost(80, 7, @vhf2m) == 100
|
||
end
|
||
|
||
test "70cm (432 MHz) is included — auroral propagation does occur there" do
|
||
assert Scorer.aurora_boost(60, 6, @uhf) == 85
|
||
end
|
||
|
||
test "fractional Kp (estimated_kp) rounds down to the band" do
|
||
# 4.67 → still in the Kp-4 (unsettled) band, +5 boost
|
||
assert Scorer.aurora_boost(60, 4.67, @vhf2m) == 65
|
||
# 5.33 → into Kp-5 (G1) band, +15 boost
|
||
assert Scorer.aurora_boost(60, 5.33, @vhf2m) == 75
|
||
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 "accepts precomputed band-invariant scores" do
|
||
# The grid scorer precomputes time_of_day / sky / wind / pressure
|
||
# once per point (they don't depend on the band) and passes them
|
||
# into composite_score for every band iteration. composite_score
|
||
# should use those when present and skip recomputing.
|
||
precomputed =
|
||
Scorer.precompute_band_invariants(@conditions)
|
||
|
||
enriched = Map.merge(@conditions, precomputed)
|
||
|
||
fresh = Scorer.composite_score(@conditions, @band_10g)
|
||
reused = Scorer.composite_score(enriched, @band_10g)
|
||
|
||
assert reused.factors.time_of_day == fresh.factors.time_of_day
|
||
assert reused.factors.sky == fresh.factors.sky
|
||
assert reused.factors.wind == fresh.factors.wind
|
||
assert reused.factors.pressure == fresh.factors.pressure
|
||
assert reused.score == fresh.score
|
||
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
|
||
|
||
describe "path_integrated_conditions/2" do
|
||
@contact %{
|
||
qso_timestamp: ~U[2026-06-15 18:30:00Z],
|
||
pos1: %{"lat" => 32.9, "lon" => -97.0}
|
||
}
|
||
|
||
test "averages surface temp/dew, mins pressure+gradient, and averages HPBL/PWAT" do
|
||
profiles = [
|
||
%{
|
||
surface_temp_c: 20.0,
|
||
surface_dewpoint_c: 10.0,
|
||
surface_pressure_mb: 1020.0,
|
||
min_refractivity_gradient: -200.0,
|
||
hpbl_m: 500.0,
|
||
pwat_mm: 20.0
|
||
},
|
||
%{
|
||
surface_temp_c: 30.0,
|
||
surface_dewpoint_c: 20.0,
|
||
surface_pressure_mb: 1010.0,
|
||
min_refractivity_gradient: -400.0,
|
||
hpbl_m: 1500.0,
|
||
pwat_mm: 30.0
|
||
}
|
||
]
|
||
|
||
cond_map = Scorer.path_integrated_conditions(profiles, @contact)
|
||
assert cond_map
|
||
|
||
# Averages carry through to Fahrenheit equivalents.
|
||
assert_in_delta cond_map.temp_f, 77.0, 0.1
|
||
assert_in_delta cond_map.dewpoint_f, 59.0, 0.1
|
||
|
||
# min() picks the worst pressure and the strongest (most-negative)
|
||
# refractivity gradient.
|
||
assert cond_map.pressure_mb == 1010.0
|
||
assert cond_map.min_refractivity_gradient == -400.0
|
||
|
||
# avg() smooths HPBL + PWAT.
|
||
assert cond_map.bl_depth_m == 1000.0
|
||
assert cond_map.pwat_mm == 25.0
|
||
|
||
# Time/position fields come from the contact.
|
||
assert cond_map.utc_hour == 18
|
||
assert cond_map.utc_minute == 30
|
||
assert cond_map.month == 6
|
||
assert cond_map.longitude == -97.0
|
||
|
||
# Fields that the path integration can't infer from HRRR are nil.
|
||
assert cond_map.wind_speed_kts == nil
|
||
assert cond_map.sky_cover_pct == nil
|
||
assert cond_map.prev_pressure_mb == nil
|
||
assert cond_map.rain_rate_mmhr == 0.0
|
||
end
|
||
|
||
test "returns nil when every profile is missing surface temperature" do
|
||
profiles = [
|
||
%{
|
||
surface_temp_c: nil,
|
||
surface_dewpoint_c: 10.0,
|
||
surface_pressure_mb: 1020.0,
|
||
min_refractivity_gradient: -200.0,
|
||
hpbl_m: 500.0,
|
||
pwat_mm: 20.0
|
||
}
|
||
]
|
||
|
||
assert Scorer.path_integrated_conditions(profiles, @contact) == nil
|
||
end
|
||
|
||
test "returns nil when every profile is missing surface dewpoint" do
|
||
profiles = [
|
||
%{
|
||
surface_temp_c: 20.0,
|
||
surface_dewpoint_c: nil,
|
||
surface_pressure_mb: 1020.0,
|
||
min_refractivity_gradient: -200.0,
|
||
hpbl_m: 500.0,
|
||
pwat_mm: 20.0
|
||
}
|
||
]
|
||
|
||
assert Scorer.path_integrated_conditions(profiles, @contact) == nil
|
||
end
|
||
|
||
test "nil profile list yields nil" do
|
||
assert Scorer.path_integrated_conditions([], @contact) == nil
|
||
end
|
||
|
||
test "tolerates profiles with missing non-critical scalars (pressure, gradient, HPBL, PWAT)" do
|
||
# Only temp + dewpoint are required; everything else can be nil
|
||
# across all profiles and the conditions map still comes back
|
||
# with nil / avg-over-empty for those fields.
|
||
profiles = [
|
||
%{
|
||
surface_temp_c: 22.0,
|
||
surface_dewpoint_c: 15.0,
|
||
surface_pressure_mb: nil,
|
||
min_refractivity_gradient: nil,
|
||
hpbl_m: nil,
|
||
pwat_mm: nil
|
||
}
|
||
]
|
||
|
||
cond_map = Scorer.path_integrated_conditions(profiles, @contact)
|
||
assert cond_map
|
||
assert cond_map.pressure_mb == nil
|
||
assert cond_map.min_refractivity_gradient == nil
|
||
assert cond_map.bl_depth_m == nil
|
||
assert cond_map.pwat_mm == nil
|
||
end
|
||
|
||
test "uses -97.0 as longitude when contact.pos1 is missing the lon key" do
|
||
# Stale contacts written before pos1 was normalised can lack a
|
||
# lon field. The function falls back to CONUS-centre (-97.0).
|
||
contact = %{qso_timestamp: ~U[2026-06-15 18:00:00Z], pos1: %{}}
|
||
|
||
profiles = [
|
||
%{
|
||
surface_temp_c: 20.0,
|
||
surface_dewpoint_c: 10.0,
|
||
surface_pressure_mb: 1020.0,
|
||
min_refractivity_gradient: -200.0,
|
||
hpbl_m: 500.0,
|
||
pwat_mm: 20.0
|
||
}
|
||
]
|
||
|
||
cond_map = Scorer.path_integrated_conditions(profiles, contact)
|
||
assert cond_map.longitude == -97.0
|
||
end
|
||
end
|
||
end
|