prop/test/microwaveprop/weather/sounding_params_property_test.exs

196 lines
6.5 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 "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