196 lines
6.5 KiB
Elixir
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
|