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
397 lines
12 KiB
Elixir
397 lines
12 KiB
Elixir
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.0–9.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.0–9.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
|