prop/test/microwaveprop/propagation/scorer_test.exs
Graham McIntire 56e6a3513c
test: cover METAR field extractors and Scorer.path_integrated_conditions
- NceiMetarClient: wind-group parsing (VVVKKKT), missing-wind-group
  fallback to nil, multi-layer sky condition concat, missing-sky token
  returns nil, missing altimeter returns nil, T-group-less fallback to
  the coarse "TT/DD" field.
- Scorer.path_integrated_conditions/2: averages surface temp/dew,
  takes MIN of pressure + refractivity gradient, takes AVG of HPBL +
  PWAT, threads contact time+longitude, and propagates -97.0 longitude
  when pos1 lacks a lon key. Returns nil when every profile is missing
  surface temp OR dewpoint; returns nil on empty-list input;
  tolerates nil pressure / gradient / HPBL / PWAT when temp + dew
  are present.

Suite: 2,409 tests + 159 properties (was 2,397 + 159); credo strict
clean.
2026-04-23 14:13:42 -05:00

891 lines
30 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
# 750 m sits in the [500, 1500) baseline-multiplier band so existing
# threshold assertions don't pick up the HPBL adjustment.
@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 treated as baseline (no HPBL multiplier)" do
# A nil HPBL must not crash and must produce the unmultiplied score.
assert Scorer.score_refractivity(-160, nil, @band_10g) == 92
end
test "shallow boundary layer (<200 m) boosts the gradient score" do
# Same -100 gradient: shallow BL should clearly out-score a baseline BL.
shallow = Scorer.score_refractivity(-100, 150, @band_10g)
baseline = Scorer.score_refractivity(-100, @baseline_bl, @band_10g)
assert shallow > baseline
assert shallow <= 100
end
test "deep boundary layer (>=2000 m) penalises the gradient score" do
# Same -100 gradient: deep BL drags the score down.
deep = Scorer.score_refractivity(-100, 2200, @band_10g)
baseline = Scorer.score_refractivity(-100, @baseline_bl, @band_10g)
assert deep < baseline
assert deep >= 0
end
test "shallow BL also lifts the weak-gradient default" do
# The HPBL multiplier applies to the default fallback too — the binned
# data shows shallow-BL paths run ~230 km even when HRRR gradient is weak.
shallow_default = Scorer.score_refractivity(-30, 150, @band_10g)
baseline_default = Scorer.score_refractivity(-30, @baseline_bl, @band_10g)
assert shallow_default > baseline_default
end
end
describe "score_refractivity/4 with native-profile duct support" do
# New 4-arity variant: adds best_duct_band_ghz from hrrr_native_profiles so
# cells with a thin native duct that supports the target band get a boost
# the HRRR pressure-level gradient alone would miss. Cells where the native
# duct only supports sub-band frequencies do NOT get a boost.
test "native duct supporting the target band boosts the score" do
boosted = Scorer.score_refractivity(-80, 800, 15.0, @band_10g)
plain = Scorer.score_refractivity(-80, 800, nil, @band_10g)
assert boosted > plain
assert boosted <= 100
end
test "native duct below the target band does NOT boost" do
# A 3 GHz duct is useless for 10 GHz.
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 boosted only when native duct supports ≥24 GHz" 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
# 5-arity variant: the native-duct boost only applies when Bulk
# Richardson number is in the stable regime (< 25). Native duct
# cells average 8.718.4 for ducting vs 38.3 for non-ducting, so a
# low best_duct_band_ghz reading with high Richardson is likely a
# duct that would be shredded by mechanical mixing — we should not
# boost it (Part 2c of algo.md).
test "stable Richardson (<25) + matching duct band → boost applies" do
boosted = Scorer.score_refractivity(-80, 800, 15.0, 12.0, @band_10g)
plain = Scorer.score_refractivity(-80, 800, nil, nil, @band_10g)
assert boosted > plain
end
test "turbulent Richardson (≥25) + matching duct band → boost suppressed" 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 preserves 4-arity behaviour (boost still applies)" 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 "boundary case: Richardson at exactly 25 suppresses the boost" 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
# ── 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