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

503 lines
18 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.ScorerPropertyTest do
@moduledoc """
Property tests for the pure scoring functions in
`Microwaveprop.Propagation.Scorer`. Each property exercises ONE
invariant — composite score bounds, per-factor bounds, monotonicity
where expected, and the nil-handling contract.
"""
use ExUnit.Case, async: true
use ExUnitProperties
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Scorer
describe "temperature conversions" do
property "f_to_c and c_to_f are inverses up to float epsilon" do
check all(c <- float(min: -100.0, max: 150.0)) do
round_tripped = c |> Scorer.c_to_f() |> Scorer.f_to_c()
assert_in_delta round_tripped, c, 1.0e-9
end
end
property "f_to_c and c_to_f propagate nil (scorer nil-pass contract)" do
assert Scorer.f_to_c(nil) == nil
assert Scorer.c_to_f(nil) == nil
end
property "f_to_c is linear (a F increase of ΔF maps to (5/9)·ΔF C)" do
check all(
base <- float(min: -50.0, max: 120.0),
delta <- float(min: -30.0, max: 30.0)
) do
d_c = Scorer.f_to_c(base + delta) - Scorer.f_to_c(base)
assert_in_delta d_c, delta * 5.0 / 9.0, 1.0e-9
end
end
end
describe "wind_speed_kts/2" do
property "is non-negative and propagates nil" do
assert Scorer.wind_speed_kts(nil, 1.0) == nil
assert Scorer.wind_speed_kts(1.0, nil) == nil
check all(
u <- float(min: -30.0, max: 30.0),
v <- float(min: -30.0, max: 30.0)
) do
kts = Scorer.wind_speed_kts(u, v)
assert is_number(kts)
assert kts >= 0.0
end
end
property "magnitude equals sqrt(u²+v²) × m/s→kts conversion" do
# Scorer uses the 5-digit 1.94384 factor (not the full-precision
# 1.9438444924...). That's ~0.001% off, which tops out at a few
# millimetres per hour on a 60 kt wind — too small to matter for
# propagation scoring.
check all(
u <- float(min: -20.0, max: 20.0),
v <- float(min: -20.0, max: 20.0)
) do
speed_ms = :math.sqrt(u * u + v * v)
expected = speed_ms * 1.94384
assert_in_delta Scorer.wind_speed_kts(u, v), expected, 1.0e-6
end
end
end
describe "precip_to_rate_mmhr/1 and dbz_to_rain_rate_mmhr/1" do
property "precip_to_rate_mmhr is non-negative and 0-valued for nil/0" do
assert Scorer.precip_to_rate_mmhr(nil) == 0.0
assert Scorer.precip_to_rate_mmhr(0.0) == 0.0
assert Scorer.precip_to_rate_mmhr(-1.0) == 0.0
check all(mm <- float(min: 0.01, max: 200.0)) do
rate = Scorer.precip_to_rate_mmhr(mm)
assert rate >= 0.0
assert rate == mm
end
end
property "dbz_to_rain_rate_mmhr is 0 below 5 dBZ and monotonic non-decreasing above" do
check all(dbz <- float(min: -30.0, max: 4.99)) do
assert Scorer.dbz_to_rain_rate_mmhr(dbz) == 0.0
end
assert Scorer.dbz_to_rain_rate_mmhr(nil) == 0.0
check all(
a <- float(min: 5.0, max: 60.0),
delta <- float(min: 0.0, max: 30.0)
) do
b = a + delta
assert Scorer.dbz_to_rain_rate_mmhr(a) <= Scorer.dbz_to_rain_rate_mmhr(b) + 1.0e-9
end
end
end
# ── Generators ──────────────────────────────────────────────────
# All configured bands — harmful (24+ GHz) and beneficial (10 GHz and
# below) are both represented. A property that needs the opposite
# humidity effect can filter after generating.
defp band_config_gen do
StreamData.member_of(BandConfig.all_bands())
end
defp beneficial_band_gen do
StreamData.bind(band_config_gen(), fn band ->
if band.humidity_effect == :beneficial do
StreamData.constant(band)
else
StreamData.filter(band_config_gen(), &(&1.humidity_effect == :beneficial))
end
end)
end
defp harmful_band_gen do
StreamData.bind(band_config_gen(), fn band ->
if band.humidity_effect == :harmful do
StreamData.constant(band)
else
StreamData.filter(band_config_gen(), &(&1.humidity_effect == :harmful))
end
end)
end
defp latitude_gen, do: StreamData.float(min: 25.0, max: 50.0)
defp longitude_gen, do: StreamData.float(min: -125.0, max: -66.0)
defp month_gen, do: StreamData.integer(1..12)
defp hour_gen, do: StreamData.integer(0..23)
defp minute_gen, do: StreamData.integer(0..59)
# A self-consistent conditions map accepted by `composite_score/2`.
# Dewpoint is bounded above by temperature so the T-Td depression
# never goes negative.
defp conditions_gen do
gen all(
temp_f <- StreamData.float(min: -20.0, max: 110.0),
# 0..60°F spread
depression <- StreamData.float(min: 0.0, max: 60.0),
wind_ms <- StreamData.float(min: 0.0, max: 40.0),
sky_pct <- StreamData.float(min: 0.0, max: 100.0),
pressure_mb <- StreamData.float(min: 950.0, max: 1050.0),
rain_mmhr <- StreamData.float(min: 0.0, max: 80.0),
pwat_mm <- StreamData.float(min: 0.0, max: 60.0),
grad <- StreamData.float(min: -400.0, max: 50.0),
hpbl <- StreamData.float(min: 50.0, max: 3000.0),
month <- month_gen(),
hour <- hour_gen(),
minute <- minute_gen(),
lat <- latitude_gen(),
lon <- longitude_gen()
) do
dewpoint_f = temp_f - depression
temp_c = (temp_f - 32) * 5 / 9
dew_c = (dewpoint_f - 32) * 5 / 9
%{
abs_humidity: Scorer.absolute_humidity(temp_c, dew_c),
temp_f: temp_f,
dewpoint_f: dewpoint_f,
wind_speed_kts: wind_ms * 1.94384,
sky_cover_pct: sky_pct,
utc_hour: hour,
utc_minute: minute,
month: month,
latitude: lat,
longitude: lon,
pressure_mb: pressure_mb,
prev_pressure_mb: nil,
rain_rate_mmhr: rain_mmhr,
min_refractivity_gradient: grad,
bl_depth_m: hpbl,
pwat_mm: pwat_mm
}
end
end
# ── score_humidity/2 ────────────────────────────────────────────
property "score_humidity is always in 0..100 (beneficial bands)" do
check all(
ah <- StreamData.float(min: 0.0, max: 40.0),
band <- beneficial_band_gen()
) do
score = Scorer.score_humidity(ah, band)
assert is_integer(score)
assert score in 0..100
end
end
property "score_humidity is always in 0..100 (harmful bands)" do
check all(
ah <- StreamData.float(min: 0.0, max: 40.0),
band <- harmful_band_gen()
) do
score = Scorer.score_humidity(ah, band)
assert is_integer(score)
assert score in 0..100
end
end
property "score_humidity is monotonically non-increasing in humidity (harmful bands)" do
# More absolute humidity → more absorption → never a higher score.
check all(
band <- harmful_band_gen(),
ah_lo <- StreamData.float(min: 0.0, max: 20.0),
delta <- StreamData.float(min: 0.0, max: 20.0)
) do
ah_hi = ah_lo + delta
assert Scorer.score_humidity(ah_hi, band) <= Scorer.score_humidity(ah_lo, band)
end
end
# ── score_td_depression/3 ───────────────────────────────────────
property "score_td_depression always in 0..100 for any band" do
check all(
temp_f <- StreamData.float(min: -40.0, max: 120.0),
dewpoint_f <- StreamData.float(min: -50.0, max: 120.0),
band <- band_config_gen()
) do
score = Scorer.score_td_depression(temp_f, dewpoint_f, band)
assert is_integer(score)
assert score in 0..100
end
end
property "score_td_depression is monotonically non-decreasing in depression (harmful bands)" do
# Dry air (wide T-Td depression) reduces water-vapour absorption.
check all(
band <- harmful_band_gen(),
temp_f <- StreamData.float(min: 20.0, max: 100.0),
dep_lo <- StreamData.float(min: 0.0, max: 30.0),
delta <- StreamData.float(min: 0.0, max: 20.0)
) do
dep_hi = dep_lo + delta
score_lo = Scorer.score_td_depression(temp_f, temp_f - dep_lo, band)
score_hi = Scorer.score_td_depression(temp_f, temp_f - dep_hi, band)
assert score_hi >= score_lo
end
end
# ── score_refractivity/4,5 ──────────────────────────────────────
property "score_refractivity returns 50 for nil gradient regardless of other inputs" do
check all(
bl <- StreamData.one_of([StreamData.constant(nil), StreamData.float(min: 0.0, max: 3000.0)]),
band <- band_config_gen()
) do
assert Scorer.score_refractivity(nil, bl, band) == 50
end
end
property "score_refractivity is in 0..100 for any gradient / band / HPBL / duct / richardson" do
check all(
grad <- StreamData.float(min: -800.0, max: 200.0),
bl <- StreamData.one_of([StreamData.constant(nil), StreamData.float(min: 0.0, max: 3000.0)]),
duct_ghz <- StreamData.one_of([StreamData.constant(nil), StreamData.float(min: 0.0, max: 300.0)]),
richardson <-
StreamData.one_of([StreamData.constant(nil), StreamData.float(min: 0.0, max: 100.0)]),
band <- band_config_gen()
) do
score = Scorer.score_refractivity(grad, bl, duct_ghz, richardson, band)
assert is_integer(score)
assert score in 0..100
end
end
# ── score_sky/1 ─────────────────────────────────────────────────
property "score_sky always in 0..100 and monotonically non-increasing in cloudiness" do
check all(
pct_lo <- StreamData.float(min: 0.0, max: 100.0),
delta <- StreamData.float(min: 0.0, max: 100.0)
) do
pct_hi = min(100.0, pct_lo + delta)
s_lo = Scorer.score_sky(pct_lo)
s_hi = Scorer.score_sky(pct_hi)
assert s_lo in 0..100
assert s_hi in 0..100
assert s_hi <= s_lo
end
end
# ── score_wind/1 ────────────────────────────────────────────────
property "score_wind always in 0..100 and monotonically non-increasing in wind speed" do
check all(
lo <- StreamData.float(min: 0.0, max: 60.0),
delta <- StreamData.float(min: 0.0, max: 60.0)
) do
hi = lo + delta
s_lo = Scorer.score_wind(lo)
s_hi = Scorer.score_wind(hi)
assert s_lo in 0..100
assert s_hi in 0..100
assert s_hi <= s_lo
end
end
# ── score_rain/2 ────────────────────────────────────────────────
property "score_rain returns 100 for no rain (nil or 0) across every band" do
check all(band <- band_config_gen()) do
assert Scorer.score_rain(nil, band) == 100
assert Scorer.score_rain(0, band) == 100
assert Scorer.score_rain(0.0, band) == 100
end
end
property "score_rain always in 0..100 and monotonically non-increasing in rain rate" do
check all(
band <- band_config_gen(),
rate_lo <- StreamData.float(min: 0.0, max: 40.0),
delta <- StreamData.float(min: 0.0, max: 40.0)
) do
rate_hi = rate_lo + delta
s_lo = Scorer.score_rain(rate_lo, band)
s_hi = Scorer.score_rain(rate_hi, band)
assert s_lo in 0..100
assert s_hi in 0..100
assert s_hi <= s_lo
end
end
# ── score_pwat/2 ────────────────────────────────────────────────
property "score_pwat always in 0..100 for any band" do
check all(
pwat <- StreamData.float(min: 0.0, max: 80.0),
band <- band_config_gen()
) do
score = Scorer.score_pwat(pwat, band)
assert is_integer(score)
assert score in 0..100
end
end
property "score_pwat returns 60 for nil input on every band" do
check all(band <- band_config_gen()) do
assert Scorer.score_pwat(nil, band) == 60
end
end
# ── score_pressure/2 ────────────────────────────────────────────
property "score_pressure always in 0..100" do
check all(
current <- StreamData.one_of([StreamData.constant(nil), StreamData.float(min: 900.0, max: 1080.0)]),
previous <- StreamData.one_of([StreamData.constant(nil), StreamData.float(min: 900.0, max: 1080.0)])
) do
score = Scorer.score_pressure(current, previous)
assert is_integer(score)
assert score in 0..100
end
end
# ── score_season/4 ──────────────────────────────────────────────
property "score_season always in 0..100 for any band/month/point" do
check all(
month <- month_gen(),
lat <- latitude_gen(),
lon <- longitude_gen(),
band <- band_config_gen()
) do
score = Scorer.score_season(month, lat, lon, band)
assert is_integer(score)
assert score in 0..100
end
end
property "score_season accepts nil lat/lon (no regional adjustment)" do
check all(
month <- month_gen(),
band <- band_config_gen()
) do
score = Scorer.score_season(month, nil, nil, band)
assert is_integer(score)
assert score in 0..100
end
end
# ── score_time_of_day/5 ─────────────────────────────────────────
property "score_time_of_day always in 0..100 and returns a string label" do
check all(
hour <- hour_gen(),
minute <- minute_gen(),
month <- month_gen(),
lat <- latitude_gen(),
lon <- longitude_gen()
) do
{score, label} = Scorer.score_time_of_day(hour, minute, month, lon, lat)
assert score in 0..100
assert is_binary(label)
assert label != ""
end
end
# ── composite_score/2 ───────────────────────────────────────────
property "composite_score is integer in 0..100 with a factors map" do
check all(
conds <- conditions_gen(),
band <- band_config_gen()
) do
%{score: score, factors: factors} = Scorer.composite_score(conds, band)
assert is_integer(score)
assert score in 0..100
assert is_map(factors)
Enum.each(factors, fn {_factor, value} ->
assert is_integer(value)
assert value in 0..100
end)
end
end
property "composite_score agrees regardless of whether band invariants are pre-computed" do
check all(
conds <- conditions_gen(),
band <- band_config_gen()
) do
direct = Scorer.composite_score(conds, band)
hoisted_conds = Map.merge(conds, Scorer.precompute_band_invariants(conds))
via_hoist = Scorer.composite_score(hoisted_conds, band)
assert direct.score == via_hoist.score
assert direct.factors == via_hoist.factors
end
end
# ── commercial_link_boost/2 ─────────────────────────────────────
property "commercial_link_boost never decreases the score and stays in 0..100" do
check all(
base <- StreamData.integer(0..100),
db <- StreamData.float(min: 0.0, max: 30.0),
n_links <- StreamData.integer(0..10)
) do
deg = %{degradation_db: db, n_links: n_links}
boosted = Scorer.commercial_link_boost(base, deg)
assert boosted in 0..100
assert boosted >= base
end
end
property "commercial_link_boost with nil or zero-link map is a no-op (clamped to 0..100)" do
check all(base <- StreamData.integer(-50..150)) do
expected = base |> max(0) |> min(100)
assert Scorer.commercial_link_boost(base, nil) == expected
assert Scorer.commercial_link_boost(base, %{n_links: 0}) == expected
end
end
# ── Helpers ─────────────────────────────────────────────────────
property "dbz_to_rain_rate_mmhr clips below 5 dBZ and caps at 150 mm/hr" do
check all(dbz <- StreamData.float(min: -20.0, max: 80.0)) do
rate = Scorer.dbz_to_rain_rate_mmhr(dbz)
assert is_float(rate)
assert rate >= 0.0
assert rate <= 150.0
if dbz < 5.0, do: assert(rate == 0.0)
end
end
property "precip_to_rate_mmhr is 0 for nil / non-positive and passes through positives" do
check all(mm <- StreamData.float(min: -10.0, max: 50.0)) do
rate = Scorer.precip_to_rate_mmhr(mm)
assert is_float(rate)
assert rate >= 0.0
if mm > 0 do
assert rate == mm / 1
else
assert rate == 0.0
end
end
end
property "f_to_c and c_to_f are mutual inverses" do
check all(f <- StreamData.float(min: -100.0, max: 200.0)) do
roundtrip = f |> Scorer.f_to_c() |> Scorer.c_to_f()
assert_in_delta roundtrip, f, 1.0e-9
end
end
property "wind_speed_kts scales with Euclidean norm of (u, v)" do
check all(
u <- StreamData.float(min: -30.0, max: 30.0),
v <- StreamData.float(min: -30.0, max: 30.0)
) do
expected = :math.sqrt(u * u + v * v) * 1.94384
assert_in_delta Scorer.wind_speed_kts(u, v), expected, 1.0e-9
end
end
end