prop/test/microwaveprop/propagation/scorer_test.exs
Graham McIntire 7702b9e161
feat(scoring): aurora boost on VHF/low-UHF during geomagnetic storms
`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.
2026-05-04 16:02:49 -05:00

947 lines
32 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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