prop/test/microwaveprop/propagation/band_config_test.exs
Graham McIntire 2fd88a94ea
Some checks failed
Build and Push / Build and Push Docker Image (push) Waiting to run
Build prop-grid-rs / Test, build, push (push) Failing after 3m41s
fix(propagation): 7 pipeline bugs + validation harnesses + model improvements
Phase A — 7 pipeline bugs:
- retain_scores_window: fix timeline self-deletion (NOTIFY payload run_time|valid_time)
- Rust/Elixir weight divergence: Rust loads band_weights.json at startup
- Aurora boost ported to Rust (Kp query + per-cell boost for bands <= 432 MHz)
- Commercial-link boost applied in Rust per-cell scoring
- f00 native gradient preferred over pressure-level gradient
- HRDPS files: greedy regex fixed, now visible to timeline/prune/retain
- GEFS/HRRR collision: GEFS namespace as .gefs.prop, merge at read

Phase B — Validation harness:
- scripts/validate_algo.py: out-of-sample Spearman rho(score,distance) + baselines
- docs/algo-reports/validation-2026-08-01.{json,md}

Phase C — Forecast-skill evaluation:
- scripts/validate_forecast.py: skill degradation by lead time (0h-24h)
- docs/algo-reports/forecast-2026-08-01.{json,md}

Phase D — Calibration + model improvements:
- recalibrate.py: validation gate before weight deploy
- Recalibrator: Nx.max(0.0) replaces Nx.abs(), L2 regularization, val-set integrity
- ML train/serve defaults unified; prop_compare null-handling skew fixed
- Latitude-aware sunrise: solar-declination sunrise_hour(lat,month) (Elixir + Rust)
- Path scoring: wind/sky/rain from HRRR (was ~30% of composite weight silent)
- Region multiplier documented as unvalidated; PWAT/refractivity doc-vs-code noted
- algo.md: synced scoring sections, marked retired features, D5/D6 changelog
- Credo: path_compute cyclomatic complexity bumped (6->10 fields) — informational only
2026-08-01 19:28:41 -05:00

397 lines
12 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.BandConfigTest do
use ExUnit.Case, async: true
alias Microwaveprop.Propagation.BandConfig
@all_freqs [
50,
144,
222,
432,
902,
1_296,
2_304,
3_400,
5_760,
10_000,
24_000,
47_000,
68_000,
75_000,
122_000,
134_000,
142_000,
145_000,
241_000,
288_000,
322_000,
403_000,
411_000
]
describe "get/1" do
test "returns config for 10 GHz" do
config = BandConfig.get(10_000)
assert config.freq_mhz == 10_000
assert config.label == "10 GHz"
assert config.humidity_effect == :beneficial
assert config.humidity_penalty == 0.0
assert config.rain_k == 0.010
assert config.rain_alpha == 1.28
assert config.typical_range_km == 200
assert config.extended_range_km == 500
assert config.exceptional_range_km == 1000
end
test "returns config for 24 GHz" do
config = BandConfig.get(24_000)
assert config.freq_mhz == 24_000
assert config.label == "24 GHz"
assert config.humidity_effect == :harmful
assert config.humidity_penalty == 1.6
assert config.rain_k == 0.070
assert config.rain_alpha == 1.07
assert config.typical_range_km == 100
assert config.extended_range_km == 250
assert config.exceptional_range_km == 500
end
test "returns config for 47 GHz" do
config = BandConfig.get(47_000)
assert config.freq_mhz == 47_000
assert config.humidity_effect == :harmful
assert config.humidity_penalty == 1.0
assert config.rain_k == 0.187
assert config.rain_alpha == 0.93
end
test "returns config for 68 GHz with atmospheric absorption" do
config = BandConfig.get(68_000)
assert config.o2_db_km == 0.90
assert config.h2o_coeff == 0.007
assert config.humidity_penalty == 1.4
assert config.rain_k == 0.310
assert config.rain_alpha == 0.86
assert config.typical_range_km == 40
assert config.extended_range_km == 80
assert config.exceptional_range_km == 150
end
test "returns config for 75 GHz" do
config = BandConfig.get(75_000)
assert config.o2_db_km == 0.012
assert config.h2o_coeff == 0.006
assert config.humidity_penalty == 1.2
assert config.rain_k == 0.345
assert config.rain_alpha == 0.84
end
test "returns config for 122 GHz" do
config = BandConfig.get(122_000)
assert config.o2_db_km == 0.80
assert config.h2o_coeff == 0.010
assert config.humidity_penalty == 1.0
assert config.rain_k == 0.498
assert config.rain_alpha == 0.77
end
test "returns config for 134 GHz" do
config = BandConfig.get(134_000)
assert config.o2_db_km == 0.08
assert config.h2o_coeff == 0.015
assert config.humidity_penalty == 1.3
assert config.rain_k == 0.520
assert config.rain_alpha == 0.75
end
test "returns config for 241 GHz" do
config = BandConfig.get(241_000)
assert config.o2_db_km == 0.08
assert config.h2o_coeff == 0.30
assert config.humidity_penalty == 3.0
assert config.rain_k == 0.550
assert config.rain_alpha == 0.70
assert config.typical_range_km == 10
assert config.extended_range_km == 50
assert config.exceptional_range_km == 115
end
test "returns nil for unknown band" do
assert BandConfig.get(999) == nil
end
test "returns config for 144 MHz (2m VHF)" do
config = BandConfig.get(144)
assert config.freq_mhz == 144
assert config.label == "144 MHz"
# Tropo ducting physics is the same as low microwave — humidity
# builds refractivity, rain and atmospheric absorption are
# negligible at VHF.
assert config.humidity_effect == :beneficial
assert config.humidity_penalty == 0.0
assert config.rain_k == 0.0
assert config.rain_alpha == 1.0
assert config.o2_db_km == 0.0
assert config.h2o_coeff == 0.0
# 2m tropo ducts can reach 2500+ km under exceptional conditions
# (e.g. documented Hawaii ↔ California openings).
assert config.exceptional_range_km >= 2000
end
test "returns config for 432 MHz (70cm UHF)" do
config = BandConfig.get(432)
assert config.freq_mhz == 432
assert config.label == "432 MHz"
assert config.humidity_effect == :beneficial
assert config.humidity_penalty == 0.0
assert config.rain_k == 0.0
assert config.rain_alpha == 1.0
assert config.h2o_coeff == 0.0
# 70cm tropo is strong but slightly less extreme than 2m.
assert config.exceptional_range_km >= 1500
end
test "returns config for 142 GHz (between H2O 118 and 183 lines)" do
config = BandConfig.get(142_000)
assert config.freq_mhz == 142_000
assert config.humidity_effect == :harmful
assert config.rain_alpha < 1.0
end
test "returns config for 145 GHz" do
config = BandConfig.get(145_000)
assert config.freq_mhz == 145_000
assert config.humidity_effect == :harmful
end
test "returns config for 288 GHz (sub-mm window)" do
config = BandConfig.get(288_000)
assert config.freq_mhz == 288_000
assert config.humidity_effect == :harmful
assert config.typical_range_km <= 10
end
test "returns config for 322 GHz" do
assert %{freq_mhz: 322_000, humidity_effect: :harmful} = BandConfig.get(322_000)
end
test "returns config for 403 GHz" do
assert %{freq_mhz: 403_000, humidity_effect: :harmful} = BandConfig.get(403_000)
end
test "returns config for 411 GHz" do
assert %{freq_mhz: 411_000, humidity_effect: :harmful} = BandConfig.get(411_000)
end
end
describe "all_bands/0" do
test "returns 23 bands (13 original + 142/145/288/322/403/411 GHz + 50/144/222/432 MHz)" do
bands = BandConfig.all_bands()
assert Enum.count_until(bands, 24) == 23
end
test "bands are sorted by frequency" do
freqs = Enum.map(BandConfig.all_bands(), & &1.freq_mhz)
assert freqs == @all_freqs
end
end
describe "all_freqs/0" do
test "returns sorted frequency list" do
assert BandConfig.all_freqs() == @all_freqs
end
end
describe "weights/0" do
test "returns all 10 factor keys" do
weights = BandConfig.weights()
expected_keys =
MapSet.new([
:humidity,
:time_of_day,
:td_depression,
:refractivity,
:sky,
:season,
:wind,
:rain,
:pwat,
:pressure
])
assert MapSet.new(Map.keys(weights)) == expected_keys
end
test "weights sum to 1.0" do
total = BandConfig.weights() |> Map.values() |> Enum.sum()
assert_in_delta total, 1.0, 0.001
end
test "individual weights have correct values" do
weights = BandConfig.weights()
assert weights.humidity == 0.1262
assert weights.time_of_day == 0.0380
assert weights.td_depression == 0.1010
assert weights.refractivity == 0.0986
assert weights.sky == 0.0841
assert weights.season == 0.1134
assert weights.wind == 0.0841
assert weights.rain == 0.1431
assert weights.pwat == 0.1147
assert weights.pressure == 0.0967
end
end
describe "weights/1 — band-aware" do
test "returns the default weights when the band has no override" do
# 50 MHz has 0 contacts in the corpus → no per-band fit → default weights.
band = BandConfig.get(50)
assert BandConfig.weights(band) == BandConfig.weights()
end
test "returns the override weights when the band has a :weights key" do
band = %{freq_mhz: 99_999, weights: %{rain: 0.5, humidity: 0.5}}
assert BandConfig.weights(band) == %{rain: 0.5, humidity: 0.5}
end
test "returns defaults when passed nil" do
assert BandConfig.weights(nil) == BandConfig.weights()
end
end
describe "sunrise_hour/2" do
test "returns plausible sunrise hours across latitudes" do
# Verify latitude-aware computation produces physically reasonable
# sunrise times per latitude and month (CONUS range ~4.09.0h solar).
for lat <- [30.0, 38.0, 49.0], month <- 1..12 do
h = BandConfig.sunrise_hour(lat, month)
assert is_float(h)
assert h >= 4.0 and h <= 9.0,
"sunrise_hour(#{lat}, #{month}) = #{h}, expected 4.09.0"
end
end
test "summer has earlier sunrise than winter at all latitudes" do
for lat <- [30.0, 38.0, 49.0] do
assert BandConfig.sunrise_hour(lat, 6) < BandConfig.sunrise_hour(lat, 1),
"lat=#{lat}: June sunrise should be earlier than January"
end
end
test "northern latitudes have later winter sunrise" do
# Seattle should have later winter sunrise than Miami
assert BandConfig.sunrise_hour(49.0, 1) > BandConfig.sunrise_hour(30.0, 1)
end
end
describe "tiers/0" do
test "returns 5 tiers" do
tiers = BandConfig.tiers()
assert Enum.count_until(tiers, 6) == 5
end
test "tiers are ordered by threshold descending" do
thresholds = Enum.map(BandConfig.tiers(), & &1.min_score)
assert thresholds == [80, 65, 50, 33, 0]
end
test "tier labels and colors are correct" do
tiers = BandConfig.tiers()
[excellent, good, marginal, poor, negligible] = tiers
assert excellent == %{min_score: 80, label: "EXCELLENT", color: "#059669"}
assert good == %{min_score: 65, label: "GOOD", color: "#0d9488"}
assert marginal == %{min_score: 50, label: "MARGINAL", color: "#ca8a04"}
assert poor == %{min_score: 33, label: "POOR", color: "#ea580c"}
assert negligible == %{min_score: 0, label: "NEGLIGIBLE", color: "#dc2626"}
end
end
describe "seasonal bases" do
test "every band has 12 months in seasonal_base" do
for freq <- @all_freqs do
config = BandConfig.get(freq)
assert map_size(config.seasonal_base) == 12, "#{freq} missing months in seasonal_base"
assert Enum.all?(1..12, &Map.has_key?(config.seasonal_base, &1))
end
end
test "10 GHz seasonal base peaks in summer" do
config = BandConfig.get(10_000)
assert config.seasonal_base[7] == 95
assert config.seasonal_base[3] == 22
end
test "24 GHz seasonal base peaks in winter" do
config = BandConfig.get(24_000)
assert config.seasonal_base[11] == 96
assert config.seasonal_base[7] == 18
end
test "24 GHz has seasonal adjustments" do
config = BandConfig.get(24_000)
assert config.seasonal_adj[7] == -10
assert config.seasonal_adj[5] == -4
end
test "10 GHz has empty seasonal adjustments" do
config = BandConfig.get(10_000)
assert config.seasonal_adj == %{}
end
end
describe "humidity_beneficial_thresholds/0" do
test "returns threshold tuples sorted by hour" do
thresholds = BandConfig.humidity_beneficial_thresholds()
assert thresholds == [{4, 55}, {7, 70}, {10, 82}, {14, 90}, {18, 95}, {22, 88}]
end
end
describe "humidity_beneficial_default/0" do
test "returns 75" do
assert BandConfig.humidity_beneficial_default() == 75
end
end
describe "refractivity_thresholds/0" do
test "returns gradient/score tuples" do
thresholds = BandConfig.refractivity_thresholds()
assert thresholds == [
{-200, 98, 85},
{-150, 92, 80},
{-100, 82, 72},
{-75, 68, 62},
{-55, 55, 55},
{-40, 48, 48}
]
end
end
describe "refractivity_default/0" do
test "returns default scores" do
assert BandConfig.refractivity_default() == {42, 42}
end
end
describe "only 10 GHz is beneficial" do
test "10 GHz has beneficial humidity effect" do
assert BandConfig.get(10_000).humidity_effect == :beneficial
end
test "sub-10 GHz bands are beneficial, 24+ GHz are harmful" do
beneficial = [50, 144, 222, 432, 902, 1_296, 2_304, 3_400, 5_760, 10_000]
harmful = @all_freqs -- beneficial
for freq <- beneficial do
assert BandConfig.get(freq).humidity_effect == :beneficial, "#{freq} should be :beneficial"
end
for freq <- harmful do
assert BandConfig.get(freq).humidity_effect == :harmful, "#{freq} should be :harmful"
end
end
end
end