prop/test/microwaveprop/propagation/scorer_property_test.exs
Graham McIntire e7fb34eb6f
test: add /qsos→/contacts redirect + scorer conversion properties
- PageController: cover the legacy `/qsos` → `/contacts` redirect pair
  (both the index and the id-preserving per-resource redirect), which
  had no coverage since the route rename.
- Scorer property tests:
  - f_to_c / c_to_f are inverses up to float epsilon, both propagate
    nil, and the C→F slope is exactly 5/9.
  - wind_speed_kts is non-negative, propagates nil on either axis,
    and matches sqrt(u²+v²)·1.94384 (the 5-digit kts factor the
    scorer uses).
  - precip_to_rate_mmhr is non-negative, 0 for nil/zero/negative.
  - dbz_to_rain_rate_mmhr is 0 below 5 dBZ (and for nil), and
    monotonically non-decreasing above — guards against accidental
    inversion of the Marshall-Palmer lookup.

Suite: 2,382 tests + 155 properties (was 2,380 + 148); coverage
71.08% → 71.12%; credo strict clean.
2026-04-23 14:03:02 -05:00

502 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/4 ─────────────────────────────────────────
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(),
lon <- longitude_gen()
) do
{score, label} = Scorer.score_time_of_day(hour, minute, month, lon)
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