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