prop/test/microwaveprop/propagation/scorer_test.exs
Graham McIntire f122eedfa8
perf(propagation): parallel chain fan-out + hoist shared factors
Two wins on the hourly propagation pipeline:

1. Parallelize the chain. seed_chain was enqueuing only f00 and
   each step self-enqueued f00+1, serializing ~2.5 min × 19
   forecast hours into a ~48 min chain. Fan out all 19 jobs at
   once — they're genuinely independent (different HRRR URLs,
   different output files) — and let queue concurrency (2 slots
   × 3 pods = 6 parallel workers) drop wall time to ~10 min.

   Side effect: one permanently-failing step no longer takes out
   the rest of the chain, so the rescue-chain logic I added last
   commit becomes unnecessary. Removed.

   The :final cleanup (retain_window + purge) used to run on the
   fh=18 transition; with parallel execution we don't know which
   step finishes last. Cleanup now relies on the existing
   PropagationPruneWorker 15-min cron for time-based pruning.
   Stale chain leftovers are already a minor concern with the
   15-min prune; if file bloat becomes real, PruneWorker can
   gain retain_window later.

2. Hoist band-invariant factors. score_time_of_day, score_sky,
   score_wind, score_pressure depend on conditions only, not the
   band — but composite_score was recomputing them 17 times per
   point. Added Scorer.precompute_band_invariants/1, called once
   per point before the bands loop; composite_score uses the
   cached values when present and falls back to computing for
   any caller that doesn't pre-warm (test harness, path
   integrator). Saves ~30% of the scoring inner loop.
2026-04-19 09:39:06 -05:00

758 lines
26 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
end