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