prop/test/microwaveprop/propagation/band_config_property_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

149 lines
5.2 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.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.09.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