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.
209 lines
6 KiB
Elixir
209 lines
6 KiB
Elixir
defmodule Microwaveprop.Propagation.DuctTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Microwaveprop.Propagation.Duct
|
|
|
|
defp profile(levels) do
|
|
%{
|
|
level_count: length(levels),
|
|
heights_m: Enum.map(levels, &elem(&1, 0)),
|
|
temp_k: Enum.map(levels, &elem(&1, 1)),
|
|
spfh: Enum.map(levels, &elem(&1, 2)),
|
|
pressure_pa: Enum.map(levels, &elem(&1, 3))
|
|
}
|
|
end
|
|
|
|
describe "refractivity_profile/1" do
|
|
test "computes N at each level with plausible values" do
|
|
p =
|
|
profile([
|
|
{10.0, 295.0, 0.012, 101_000.0},
|
|
{500.0, 290.0, 0.006, 95_000.0},
|
|
{1000.0, 283.0, 0.003, 90_000.0}
|
|
])
|
|
|
|
n_profile = Duct.refractivity_profile(p)
|
|
assert length(n_profile) == 3
|
|
|
|
# N should decrease with height in a normal atmosphere
|
|
[{_, n0}, {_, n1}, {_, n2}] = n_profile
|
|
assert n0 > n1
|
|
assert n1 > n2
|
|
# Typical surface N = 300-400
|
|
assert n0 > 250 and n0 < 500
|
|
end
|
|
end
|
|
|
|
describe "m_profile/1" do
|
|
test "M = N + 157*h where h is in km" do
|
|
n_profile = [{0.0, 350.0}, {1000.0, 280.0}]
|
|
m_profile = Duct.m_profile(n_profile)
|
|
|
|
[{h0, m0}, {1000.0, m1}] = m_profile
|
|
assert h0 == 0.0
|
|
assert_in_delta m0, 350.0, 0.01
|
|
# M at 1km = 280 + 157*1.0 = 437
|
|
assert_in_delta m1, 437.0, 0.01
|
|
end
|
|
end
|
|
|
|
describe "detect_ducts/1" do
|
|
test "returns empty list when M increases monotonically (no duct)" do
|
|
# Standard atmosphere: M always increases with height
|
|
m_profile = [
|
|
{0.0, 350.0},
|
|
{100.0, 365.0},
|
|
{300.0, 397.0},
|
|
{500.0, 428.0},
|
|
{1000.0, 507.0}
|
|
]
|
|
|
|
assert Duct.detect_ducts(m_profile) == []
|
|
end
|
|
|
|
test "detects a surface-based duct where M decreases" do
|
|
# Surface-based duct: M decreases from 0 to 100m, then resumes increasing
|
|
m_profile = [
|
|
{0.0, 380.0},
|
|
{50.0, 370.0},
|
|
{100.0, 360.0},
|
|
{200.0, 375.0},
|
|
{500.0, 428.0}
|
|
]
|
|
|
|
ducts = Duct.detect_ducts(m_profile)
|
|
assert length(ducts) == 1
|
|
|
|
[duct] = ducts
|
|
assert duct.base_m == 0.0
|
|
assert duct.top_m == 100.0
|
|
assert duct.thickness_m == 100.0
|
|
assert duct.m_deficit > 0
|
|
end
|
|
|
|
test "detects an elevated duct" do
|
|
# Elevated duct between 500-700m
|
|
m_profile = [
|
|
{0.0, 350.0},
|
|
{100.0, 366.0},
|
|
{300.0, 397.0},
|
|
{500.0, 430.0},
|
|
{600.0, 425.0},
|
|
{700.0, 420.0},
|
|
{1000.0, 500.0}
|
|
]
|
|
|
|
ducts = Duct.detect_ducts(m_profile)
|
|
assert length(ducts) == 1
|
|
|
|
[duct] = ducts
|
|
assert duct.base_m == 500.0
|
|
assert duct.top_m == 700.0
|
|
assert duct.thickness_m == 200.0
|
|
end
|
|
|
|
test "detects multiple ducts" do
|
|
m_profile = [
|
|
{0.0, 380.0},
|
|
{50.0, 370.0},
|
|
{100.0, 390.0},
|
|
{500.0, 430.0},
|
|
{600.0, 425.0},
|
|
{700.0, 440.0}
|
|
]
|
|
|
|
ducts = Duct.detect_ducts(m_profile)
|
|
assert length(ducts) == 2
|
|
end
|
|
end
|
|
|
|
describe "min_trapped_frequency_ghz/1" do
|
|
test "a narrow duct with weak deficit traps only high frequencies" do
|
|
# 10m duct, 1 M-unit deficit → λ_max ≈ 0.025m → f ≈ 12 GHz
|
|
duct = %{thickness_m: 10.0, m_deficit: 1.0}
|
|
f = Duct.min_trapped_frequency_ghz(duct)
|
|
assert_in_delta f, 12.0, 1.0
|
|
end
|
|
|
|
test "a moderate duct traps sub-GHz frequencies" do
|
|
# 50m duct, 10 M-units → λ_max ≈ 0.395m → f ≈ 0.76 GHz
|
|
duct = %{thickness_m: 50.0, m_deficit: 10.0}
|
|
f = Duct.min_trapped_frequency_ghz(duct)
|
|
assert_in_delta f, 0.76, 0.1
|
|
end
|
|
|
|
test "a thick strong duct traps very low frequencies" do
|
|
# 200m, 20 M-units → λ_max ≈ 2.24m → f ≈ 0.13 GHz
|
|
duct = %{thickness_m: 200.0, m_deficit: 20.0}
|
|
f = Duct.min_trapped_frequency_ghz(duct)
|
|
assert f < 0.2
|
|
end
|
|
|
|
test "thicker ducts trap lower frequencies than thinner ones" do
|
|
thin = Duct.min_trapped_frequency_ghz(%{thickness_m: 30.0, m_deficit: 5.0})
|
|
thick = Duct.min_trapped_frequency_ghz(%{thickness_m: 150.0, m_deficit: 5.0})
|
|
assert thick < thin
|
|
end
|
|
end
|
|
|
|
describe "detect_ducts/1 edge cases" do
|
|
test "returns [] for an empty M-profile" do
|
|
assert Duct.detect_ducts([]) == []
|
|
end
|
|
|
|
test "returns [] for a single-point M-profile (no pairs to evaluate)" do
|
|
assert Duct.detect_ducts([{0.0, 350.0}]) == []
|
|
end
|
|
|
|
test "finalizes a duct that runs to the end of the profile" do
|
|
# M decreases through every adjacent pair, so the duct never
|
|
# closes — `close_trailing_duct/1` should still produce one duct
|
|
# covering the full profile.
|
|
m_profile = [
|
|
{0.0, 400.0},
|
|
{100.0, 380.0},
|
|
{200.0, 360.0},
|
|
{500.0, 340.0}
|
|
]
|
|
|
|
assert [duct] = Duct.detect_ducts(m_profile)
|
|
assert duct.base_m == 0.0
|
|
assert duct.top_m == 500.0
|
|
assert duct.thickness_m == 500.0
|
|
assert duct.m_deficit == 60.0
|
|
end
|
|
end
|
|
|
|
describe "min_trapped_frequency_ghz/1 sentinel branch" do
|
|
test "returns 999.0 for zero thickness" do
|
|
assert Duct.min_trapped_frequency_ghz(%{thickness_m: 0.0, m_deficit: 10.0}) == 999.0
|
|
end
|
|
|
|
test "returns 999.0 for zero deficit" do
|
|
assert Duct.min_trapped_frequency_ghz(%{thickness_m: 100.0, m_deficit: 0.0}) == 999.0
|
|
end
|
|
|
|
test "returns 999.0 for arbitrary non-matching inputs" do
|
|
assert Duct.min_trapped_frequency_ghz(%{foo: :bar}) == 999.0
|
|
end
|
|
end
|
|
|
|
describe "analyze/1" do
|
|
test "full pipeline from native profile to duct list with frequencies" do
|
|
# Profile with a surface-based duct (strong inversion)
|
|
p =
|
|
profile([
|
|
{10.0, 300.0, 0.018, 101_325.0},
|
|
{50.0, 301.0, 0.017, 100_800.0},
|
|
{100.0, 302.0, 0.012, 100_300.0},
|
|
{150.0, 298.0, 0.008, 99_800.0},
|
|
{300.0, 293.0, 0.005, 97_000.0},
|
|
{500.0, 288.0, 0.003, 95_000.0}
|
|
])
|
|
|
|
result = Duct.analyze(p)
|
|
assert is_list(result.ducts)
|
|
assert is_float(result.best_duct_band_ghz) or is_nil(result.best_duct_band_ghz)
|
|
end
|
|
end
|
|
end
|