77 unit tests + 23 property tests across four parallel agents. - ContactLive.Show (72.01% → ~75%): every factor-note branch (humidity/time/td/refractivity/pressure/season/pwat), all propagation_mechanism classifications, apply_admin_edit/owner_edit/ submit_user_edit error paths, internal_network? IP shapes, expanded sounding skew-T, composite-score [0,100] property. - PathLive + BeaconLive.Show + Weather: coordinate-pair resolver, invalid grid, empty-DB edge cases for iemre_for_*, narr_for_*, nearest_native_duct_*, find_nearest_rtma, recent_surface_obs; properties for round_to_hrrr_grid/round_to_iemre_grid/band round-trip/beacon-id URL round-trip. - Viewshed + Duct + SoundingParams: find_reach_km edge cases, destination_point, effective_reach_km fractions, detect_ducts trailing-duct + guard branches; 13 properties including flat- terrain-fully-visible, thicker-duct-lower-freq, M-profile monotonicity, feature-vector deterministic length. - Workers + Mix tasks: GefsFetchWorker 500/429/403 + malformed ISO8601, HrrrNativeGridWorker snap-to-3-decimals + empty DB, IonosphereFetchWorker 404 + non-tabular body, RadarBackfill --dry-run + --year + --limit, NexradBackfill --limit 0, Backtest --feature + --all; year-filter property covering 2015..2026. 170 → 205 properties, 2666 → 2743 tests, 0 failures.
127 lines
4.3 KiB
Elixir
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 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
|