416 lines
15 KiB
Elixir
416 lines
15 KiB
Elixir
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
|
|
|
|
# ── 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
|