prop/test/microwaveprop/weather/sounding_params_property_test.exs
Graham McIntire bbf51544e1
test: coverage round 2 (82.05% → 83.96%) + 23 new property tests
77 unit tests + 23 property tests across four parallel agents.

- ContactLive.Show (72.01% → ~75%): every factor-note branch
  (humidity/time/td/refractivity/pressure/season/pwat), all
  propagation_mechanism classifications, apply_admin_edit/owner_edit/
  submit_user_edit error paths, internal_network? IP shapes, expanded
  sounding skew-T, composite-score [0,100] property.

- PathLive + BeaconLive.Show + Weather: coordinate-pair resolver,
  invalid grid, empty-DB edge cases for iemre_for_*, narr_for_*,
  nearest_native_duct_*, find_nearest_rtma, recent_surface_obs;
  properties for round_to_hrrr_grid/round_to_iemre_grid/band
  round-trip/beacon-id URL round-trip.

- Viewshed + Duct + SoundingParams: find_reach_km edge cases,
  destination_point, effective_reach_km fractions, detect_ducts
  trailing-duct + guard branches; 13 properties including flat-
  terrain-fully-visible, thicker-duct-lower-freq, M-profile
  monotonicity, feature-vector deterministic length.

- Workers + Mix tasks: GefsFetchWorker 500/429/403 + malformed
  ISO8601, HrrrNativeGridWorker snap-to-3-decimals + empty DB,
  IonosphereFetchWorker 404 + non-tabular body, RadarBackfill
  --dry-run + --year + --limit, NexradBackfill --limit 0, Backtest
  --feature + --all; year-filter property covering 2015..2026.

170 → 205 properties, 2666 → 2743 tests, 0 failures.
2026-04-24 10:15:37 -05:00

209 lines
7 KiB
Elixir

defmodule Microwaveprop.Weather.SoundingParamsPropertyTest do
@moduledoc """
Property tests for `Microwaveprop.Weather.SoundingParams` — exercises
the `derive/1` invariants on arbitrary (but physically-plausible)
sounding profiles.
"""
use ExUnit.Case, async: true
use ExUnitProperties
alias Microwaveprop.Weather.SoundingParams
# ── Generators ──────────────────────────────────────────────────
# A realistic mandatory-level pressure sequence: surface down to the
# upper troposphere. Pressures strictly decrease. We build it by
# walking down from a surface pressure, sampling a positive
# step between each level — cheaper than `uniq_list_of` at small
# generation sizes and guaranteed to avoid duplicates.
defp pressure_levels_gen(n) when n >= 0 do
gen all(
surface <- float(min: 900.0, max: 1020.0),
steps <- list_of(float(min: 5.0, max: 60.0), length: max(n - 1, 0))
) do
{levels, _} =
Enum.reduce(steps, {[surface], surface}, fn step, {acc, prev} ->
next = prev - step
{[next | acc], next}
end)
levels
|> Enum.reverse()
|> Enum.take(n)
end
end
# A single level at a given pressure/height with temperature and
# dewpoint generated inside the level. `dwpc` is forced ≤ `tmpc` so
# the depression is physical.
defp level_gen(pres, hght) do
gen all(
tmpc <- float(min: -80.0, max: 40.0),
dep <- float(min: 0.0, max: 40.0)
) do
%{
"pres" => pres,
"tmpc" => tmpc,
"dwpc" => tmpc - dep,
"hght" => hght,
"drct" => 0.0,
"sknt" => 0.0
}
end
end
# Profile generator: heights increase strictly as pressure decreases
# (the hydrostatic atmospheric convention). Built by pairing the
# sorted pressure sequence (desc) with a heights sequence (asc,
# starting at surface elevation), then generating temps/dewpoints per
# level.
defp profile_gen(min_levels, max_levels) do
gen all(
n <- integer(min_levels..max_levels),
pressures <- pressure_levels_gen(n),
surface_hght <- float(min: 0.0, max: 3000.0),
height_steps <- list_of(float(min: 50.0, max: 1500.0), length: max(n - 1, 0)),
levels <-
surface_hght
|> heights_from_surface(height_steps, n)
|> Enum.zip(pressures)
|> Enum.map(fn {h, p} -> level_gen(p, h) end)
|> fixed_list()
) do
levels
end
end
defp heights_from_surface(surface, steps, n) do
{hs, _} =
Enum.reduce(steps, {[surface], surface}, fn step, {acc, prev} ->
next = prev + step
{[next | acc], next}
end)
hs |> Enum.reverse() |> Enum.take(n)
end
# ── derive/1 invariants ────────────────────────────────────────
property "derive/1 returns a result map whenever ≥3 valid levels are given" do
check all(profile <- profile_gen(3, 12)) do
assert %{} = result = SoundingParams.derive(profile)
assert result.level_count >= 3
assert result.level_count == length(profile)
assert is_list(result.profile)
assert is_list(result.inversions)
assert is_boolean(result.ducting_detected)
end
end
property "derive/1 returns nil when fewer than 3 valid levels are provided" do
check all(
n <- integer(0..2),
profile <- short_profile_gen(n)
) do
assert SoundingParams.derive(profile) == nil
end
end
defp short_profile_gen(n) do
gen all(
pressures <- pressure_levels_gen(n),
surface_hght <- float(min: 0.0, max: 3000.0),
height_steps <- list_of(float(min: 50.0, max: 1500.0), length: max(n - 1, 0)),
levels <-
surface_hght
|> heights_from_surface(height_steps, n)
|> Enum.zip(pressures)
|> Enum.map(fn {h, p} -> level_gen(p, h) end)
|> fixed_list()
) do
levels
end
end
property "precipitable_water_mm is non-negative when present" do
check all(profile <- profile_gen(4, 10)) do
%{precipitable_water_mm: pw} = SoundingParams.derive(profile)
assert is_number(pw)
assert pw >= 0.0
end
end
property "boundary_layer_depth_m is non-negative when it resolves" do
check all(profile <- profile_gen(4, 10)) do
case SoundingParams.derive(profile).boundary_layer_depth_m do
nil -> :ok
depth -> assert depth >= 0.0
end
end
end
property "ducting_detected agrees with duct_characteristics non-nilness" do
check all(profile <- profile_gen(4, 12)) do
%{ducting_detected: detected, duct_characteristics: chars} = SoundingParams.derive(profile)
# nil chars ⇔ no ducts detected
assert detected == (chars != nil)
end
end
property "derive/1 filters out levels missing pres/tmpc/hght before the count check" do
check all(
bad_level_count <- integer(0..3),
good_profile <- profile_gen(3, 6)
) do
bad_levels =
List.duplicate(%{"pres" => nil, "tmpc" => nil, "hght" => nil, "dwpc" => 0.0}, bad_level_count)
merged = bad_levels ++ good_profile
# Good levels alone satisfy the ≥3 threshold, so adding garbage
# should not push the result to nil.
assert SoundingParams.derive(merged)
end
end
# ── sat_vap_pres / mixing_ratio helpers ─────────────────────────
property "sat_vap_pres is positive and monotonically increasing in temperature" do
check all(
t_lo <- float(min: -40.0, max: 30.0),
delta <- float(min: 0.1, max: 20.0)
) do
t_hi = t_lo + delta
e_lo = SoundingParams.sat_vap_pres(t_lo)
e_hi = SoundingParams.sat_vap_pres(t_hi)
assert e_lo > 0.0
assert e_hi > 0.0
assert e_hi > e_lo
end
end
property "surface_refractivity is non-negative for every derivable profile" do
check all(profile <- profile_gen(3, 10)) do
%{surface_refractivity: n} = SoundingParams.derive(profile)
# N may be nil if surface level lacked dewpoint (our generator
# always supplies one), otherwise it's the P.453 N value which is
# strictly positive for positive P, T, e.
case n do
nil -> :ok
value -> assert value >= 0.0
end
end
end
property "mixing_ratio is positive for temperatures well below pressure" do
# The formula hits a pole when saturation vapour pressure approaches
# the total pressure, which only happens at boiling-hot temperatures
# relative to pressure. Realistic weather stays comfortably away.
check all(
t_c <- float(min: -60.0, max: 30.0),
p_mb <- float(min: 300.0, max: 1020.0)
) do
mr = SoundingParams.mixing_ratio(t_c, p_mb)
assert is_number(mr)
assert mr > 0.0
end
end
end