defmodule Microwaveprop.Propagation.DuctPropertyTest do @moduledoc """ StreamData property tests for the pure-math core of `Microwaveprop.Propagation.Duct`: refractivity/M-profile transformations, `detect_ducts/1`, and the trapped-frequency approximation from Bean & Dutton (1966). Each property encodes one physical invariant (positive trapped frequency for well-formed ducts, monotonicity in thickness, identity between N and M-profile lengths, empty-duct list for monotonic M-profiles). """ use ExUnit.Case, async: true use ExUnitProperties alias Microwaveprop.Propagation.Duct describe "min_trapped_frequency_ghz/1" do property "is strictly positive for any positive thickness and deficit" do check all( d <- float(min: 0.5, max: 2_000.0), delta <- float(min: 0.01, max: 200.0) ) do f = Duct.min_trapped_frequency_ghz(%{thickness_m: d, m_deficit: delta}) assert f > 0 assert is_float(f) end end property "thicker ducts trap lower minimum frequencies (at fixed deficit)" do # f_min = c / (2.5 * d * sqrt(ΔM * 1e-6)) — strictly decreasing in d. check all( delta <- float(min: 0.5, max: 50.0), d_small <- float(min: 1.0, max: 100.0), bump <- float(min: 1.0, max: 500.0) ) do f_small = Duct.min_trapped_frequency_ghz(%{thickness_m: d_small, m_deficit: delta}) f_big = Duct.min_trapped_frequency_ghz(%{thickness_m: d_small + bump, m_deficit: delta}) assert f_big < f_small end end property "degenerate inputs (zero or negative thickness/deficit) return the 999 GHz sentinel" do check all( d <- float(min: -10.0, max: 0.0), delta <- float(min: -10.0, max: 10.0) ) do assert Duct.min_trapped_frequency_ghz(%{thickness_m: d, m_deficit: delta}) == 999.0 end check all( d <- float(min: 1.0, max: 100.0), delta <- float(min: -10.0, max: 0.0) ) do assert Duct.min_trapped_frequency_ghz(%{thickness_m: d, m_deficit: delta}) == 999.0 end end end describe "refractivity_profile/1 & m_profile/1" do property "output length matches input level count" do check all(levels <- list_of(level_gen(), min_length: 1, max_length: 20)) do sorted = Enum.sort_by(levels, & &1.h) profile = %{ heights_m: Enum.map(sorted, & &1.h), temp_k: Enum.map(sorted, & &1.t), spfh: Enum.map(sorted, & &1.q), pressure_pa: Enum.map(sorted, & &1.p) } n_profile = Duct.refractivity_profile(profile) assert length(n_profile) == length(sorted) m_out = Duct.m_profile(n_profile) assert length(m_out) == length(sorted) # All N values positive for any physical atmosphere. for {_h, n} <- n_profile, do: assert(n > 0) end end end describe "detect_ducts/1" do property "returns [] for any strictly increasing M-profile (standard atmosphere)" do check all( heights <- list_of(float(min: 0.0, max: 10_000.0), min_length: 2, max_length: 15), base_m <- float(min: 200.0, max: 400.0), slope <- float(min: 0.1, max: 0.5) ) do sorted_h = heights |> Enum.uniq() |> Enum.sort() # Skip degenerate cases where uniq removed too many entries. if length(sorted_h) >= 2 do m_profile = Enum.map(sorted_h, fn h -> {h, base_m + slope * h} end) assert Duct.detect_ducts(m_profile) == [] end end end property "profiles with fewer than 2 points always return []" do check all(n_opt <- integer(0..1)) do prof = case n_opt do 0 -> [] 1 -> [{0.0, 350.0}] end assert Duct.detect_ducts(prof) == [] end end end # ── helpers ──────────────────────────────────────────────────── defp level_gen do gen all( h <- float(min: 0.0, max: 10_000.0), t <- float(min: 220.0, max: 320.0), q <- float(min: 0.0001, max: 0.025), p <- float(min: 20_000.0, max: 102_000.0) ) do %{h: h, t: t, q: q, p: p} end end end