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.
209 lines
7 KiB
Elixir
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
|