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