prop/test/microwaveprop/propagation/duct_property_test.exs
Graham McIntire 079346a1b9
Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 4m38s
fix: resolve all 281 credo issues across source and test files
- F-level (228): replace length/1 with Enum.count_until/2 or pattern
  matching; convert Enum.flat_map+if to Enum.filter+Enum.map; fix
  identity case in narr_client
- W-level (46): normalize dual atom/string key access in weather_layers,
  beacon_measurements, surface, skewt_live, contact_live/show; add
  :data_provider and weather-map assigns to ignored_assigns in credo
  config (consumed by child components credo can't trace); remove
  weak is_list assertion; remove explicit assert_receive timeout
- R-level (6): replace 'This module provides...' moduledocs with
  meaningful descriptions
- Also fix 4 compile-connected xref issues by deferring
  BandConfig.band_options() from module attribute to runtime

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 09:04:21 -05:00

127 lines
4.3 KiB
Elixir

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 Enum.count_until(sorted_h, 2) == 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