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
149 lines
5.2 KiB
Elixir
149 lines
5.2 KiB
Elixir
defmodule Microwaveprop.Propagation.BandConfigPropertyTest do
|
||
@moduledoc """
|
||
Property tests for `Microwaveprop.Propagation.BandConfig` —
|
||
exercises the data-driven invariants that the scorer relies on
|
||
(every band has the right shape; weights sum to 1.0; seasonal
|
||
tables cover all 12 months).
|
||
"""
|
||
|
||
use ExUnit.Case, async: true
|
||
use ExUnitProperties
|
||
|
||
alias Microwaveprop.Propagation.BandConfig
|
||
|
||
defp band_config_gen, do: StreamData.member_of(BandConfig.all_bands())
|
||
defp freq_gen, do: StreamData.member_of(BandConfig.all_freqs())
|
||
defp month_gen, do: StreamData.integer(1..12)
|
||
|
||
property "get/1 returns a well-formed struct for every supported frequency" do
|
||
check all(freq <- freq_gen()) do
|
||
band = BandConfig.get(freq)
|
||
assert is_map(band)
|
||
assert band.freq_mhz == freq
|
||
assert is_binary(band.label)
|
||
assert band.humidity_effect in [:beneficial, :harmful]
|
||
assert is_map(band.seasonal_base)
|
||
assert is_map(band.seasonal_adj)
|
||
end
|
||
end
|
||
|
||
property "every band's seasonal_base covers all 12 months with 0..100 integers" do
|
||
check all(band <- band_config_gen()) do
|
||
Enum.each(1..12, fn month ->
|
||
value = Map.fetch!(band.seasonal_base, month)
|
||
assert is_integer(value)
|
||
assert value in 0..100
|
||
end)
|
||
end
|
||
end
|
||
|
||
property "weights/1 returns a map whose values sum to ~1.0" do
|
||
check all(band <- band_config_gen()) do
|
||
weights = BandConfig.weights(band)
|
||
total = weights |> Map.values() |> Enum.sum()
|
||
assert_in_delta total, 1.0, 1.0e-3
|
||
end
|
||
end
|
||
|
||
property "weights/1 covers exactly the ten scoring factors" do
|
||
# The scorer reduces over a factors map with these keys; if a band
|
||
# ever dropped one, `Map.fetch!` in `composite_score/2` would crash.
|
||
expected = MapSet.new(~w(humidity time_of_day td_depression refractivity sky season wind rain pressure pwat)a)
|
||
|
||
check all(band <- band_config_gen()) do
|
||
keys = band |> BandConfig.weights() |> Map.keys() |> MapSet.new()
|
||
assert keys == expected
|
||
end
|
||
end
|
||
|
||
property "weights/1 accepts nil and maps without override, returning the global defaults" do
|
||
defaults = BandConfig.weights()
|
||
|
||
check all(band <- band_config_gen()) do
|
||
# A band without its own `:weights` falls through to defaults.
|
||
if Map.has_key?(band, :weights) do
|
||
assert is_map(BandConfig.weights(band))
|
||
else
|
||
assert BandConfig.weights(band) == defaults
|
||
end
|
||
end
|
||
|
||
assert BandConfig.weights(nil) == defaults
|
||
end
|
||
|
||
property "unknown frequencies return nil from get/1" do
|
||
known = MapSet.new(BandConfig.all_freqs())
|
||
|
||
check all(freq <- StreamData.integer(1..1_000_000), freq not in known) do
|
||
assert BandConfig.get(freq) == nil
|
||
end
|
||
end
|
||
|
||
property "sunrise_hour returns plausible values in the expected hour range" do
|
||
check all(
|
||
lat <- StreamData.float(min: 25.0, max: 49.0),
|
||
month <- StreamData.integer(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
|
||
|
||
property "humidity_beneficial_thresholds are strictly increasing in the threshold value" do
|
||
thresholds = BandConfig.humidity_beneficial_thresholds()
|
||
cutoffs = Enum.map(thresholds, fn {cutoff, _score} -> cutoff end)
|
||
assert cutoffs == Enum.sort(cutoffs)
|
||
assert length(Enum.uniq(cutoffs)) == length(cutoffs)
|
||
end
|
||
|
||
property "refractivity_thresholds are strictly decreasing (more negative gradient first)" do
|
||
# `find_refractivity_threshold/3` walks the list and picks the
|
||
# first cutoff greater than the observed gradient — the ordering
|
||
# is what makes that correct.
|
||
cutoffs =
|
||
Enum.map(BandConfig.refractivity_thresholds(), fn {cutoff, _b, _h} -> cutoff end)
|
||
|
||
assert cutoffs == Enum.sort(cutoffs)
|
||
assert length(Enum.uniq(cutoffs)) == length(cutoffs)
|
||
end
|
||
|
||
property "tiers are ordered descending by min_score and cover 0..100" do
|
||
tiers = BandConfig.tiers()
|
||
min_scores = Enum.map(tiers, & &1.min_score)
|
||
assert min_scores == Enum.sort(min_scores, :desc)
|
||
assert List.last(min_scores) == 0
|
||
assert hd(min_scores) <= 100
|
||
|
||
check all(s <- StreamData.integer(0..100)) do
|
||
# Every score in 0..100 matches at least one tier.
|
||
assert Enum.any?(tiers, fn tier -> s >= tier.min_score end)
|
||
end
|
||
end
|
||
|
||
property "band_options pairs each band label with its stringified freq_mhz" do
|
||
options = BandConfig.band_options()
|
||
assert length(options) == length(BandConfig.all_bands())
|
||
|
||
check all({label, value} <- StreamData.member_of(options)) do
|
||
assert is_binary(label)
|
||
assert is_binary(value)
|
||
{freq, ""} = Integer.parse(value)
|
||
band = BandConfig.get(freq)
|
||
assert band.label == label
|
||
end
|
||
end
|
||
|
||
property "every month has a non-negative seasonal_adj value for every band" do
|
||
check all(band <- band_config_gen(), month <- month_gen()) do
|
||
# The adjustment map is sparse — nil for missing entries is fine,
|
||
# but any explicit entry must be a number.
|
||
case Map.get(band.seasonal_adj, month) do
|
||
nil -> :ok
|
||
value -> assert is_number(value)
|
||
end
|
||
end
|
||
end
|
||
end
|