defmodule Microwaveprop.Propagation.EmeTest do use ExUnit.Case, async: true use ExUnitProperties alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Propagation.Eme describe "path_loss_db/2" do test "matches published amateur EME path-loss values at mean lunar distance" do # Round-trip one-way-to-moon + moon-to-earth path loss at # Earth–Moon mean distance. Cite spot values from the ARRL # Handbook EME chapter and W5LUA's moonbounce handbook. d = 384_400.0 assert_in_delta Eme.path_loss_db(144.0, d), 252.0, 1.0 assert_in_delta Eme.path_loss_db(432.0, d), 261.5, 1.0 assert_in_delta Eme.path_loss_db(1_296.0, d), 271.0, 1.0 assert_in_delta Eme.path_loss_db(10_368.0, d), 289.1, 1.0 end test "monotonic in frequency at fixed distance" do for d <- [356_500.0, 384_400.0, 406_700.0] do freqs = [50.0, 144.0, 432.0, 1_296.0, 2_304.0, 5_760.0, 10_368.0, 24_048.0] losses = Enum.map(freqs, &Eme.path_loss_db(&1, d)) assert losses == Enum.sort(losses) end end test "doubling distance adds 12 dB (4·log2(2) × 10·log10 ≈ 12.04)" do for {f, d} <- [{432.0, 200_000.0}, {1_296.0, 300_000.0}, {10_368.0, 400_000.0}] do base = Eme.path_loss_db(f, d) doubled = Eme.path_loss_db(f, d * 2) assert_in_delta doubled - base, 12.04, 0.01 end end test "doubling frequency adds 6 dB (20·log10 of 2)" do for {f, d} <- [{144.0, 384_400.0}, {432.0, 384_400.0}, {1_296.0, 360_000.0}] do base = Eme.path_loss_db(f, d) doubled = Eme.path_loss_db(f * 2, d) assert_in_delta doubled - base, 6.02, 0.01 end end property "always increases with distance at fixed frequency" do check all( f <- float(min: 10.0, max: 100_000.0), d1 <- float(min: 300_000.0, max: 400_000.0), delta <- float(min: 1.0, max: 200_000.0) ) do d2 = d1 + delta assert Eme.path_loss_db(f, d2) > Eme.path_loss_db(f, d1) end end property "always increases with frequency at fixed distance" do check all( d <- float(min: 356_500.0, max: 406_700.0), f1 <- float(min: 10.0, max: 10_000.0), delta <- float(min: 1.0, max: 10_000.0) ) do f2 = f1 + delta assert Eme.path_loss_db(f2, d) > Eme.path_loss_db(f1, d) end end end describe "moon_rcs_m2/1" do test "agrees with the σ = 0.065 · π · R² closed form within 1 % (default albedo)" do # R_moon ≈ 1738.1 km, ρ = 0.065 → σ ≈ 6.16 × 10¹¹ m². rcs = Eme.moon_rcs_m2() assert_in_delta rcs, 6.16e11, 0.01 * 6.16e11 end test "scales linearly with the albedo override" do assert_in_delta Eme.moon_rcs_m2(0.13) / Eme.moon_rcs_m2(0.065), 2.0, 1.0e-9 assert_in_delta Eme.moon_rcs_m2(0.0325) / Eme.moon_rcs_m2(0.065), 0.5, 1.0e-9 end end describe "moon_albedo/1 band lookup" do test "returns the classic 0.065 baseline at every configured VHF band" do for f <- [50.0, 144.0, 222.0] do assert Eme.moon_albedo(f) == 0.065 end end test "peaks around 10 GHz (≈ 0.090) and falls off above 40 GHz" do assert Eme.moon_albedo(10_368.0) == 0.090 assert Eme.moon_albedo(5_760.0) == 0.080 # >= 76 GHz is the terminal "thermal-emission dominates" regime. assert Eme.moon_albedo(76_000.0) == 0.020 assert Eme.moon_albedo(241_000.0) == 0.020 end test "every amateur microwave band in BandConfig gets a positive albedo" do for {_label, mhz_str} <- BandConfig.band_options() do {mhz, _} = Integer.parse(mhz_str) rho = Eme.moon_albedo(mhz) assert is_number(rho) and rho > 0.0 and rho < 0.2 end end end describe "fspl_round_trip_db + moon_reflection_gain_db decomposition" do test "sum equals path_loss_db at the same (f, d, ρ) within float epsilon" do # Pick a variety of band / albedo pairs. The decomposition # identity holds algebraically so the tolerance is tiny. for {f, d} <- [{144.0, 384_400.0}, {1_296.0, 360_000.0}, {10_368.0, 400_000.0}, {24_048.0, 356_500.0}] do rho = Eme.moon_albedo(f) expected = Eme.path_loss_db(f, d, rho) components = Eme.fspl_round_trip_db(f, d) - Eme.moon_reflection_gain_db(f, rho) assert_in_delta components, expected, 1.0e-9 end end test "moon_reflection_gain_db is positive at every amateur band (gain reduces loss)" do for {_label, mhz_str} <- BandConfig.band_options() do {mhz, _} = Integer.parse(mhz_str) rho = Eme.moon_albedo(mhz) gain = Eme.moon_reflection_gain_db(mhz, rho) assert gain > 0.0, "expected positive moon reflection gain at #{mhz} MHz, got #{gain}" end end test "doubling albedo adds exactly 3.01 dB to the moon-reflection gain" do for f <- [144.0, 1_296.0, 10_368.0] do g1 = Eme.moon_reflection_gain_db(f, 0.05) g2 = Eme.moon_reflection_gain_db(f, 0.10) assert_in_delta g2 - g1, 3.01, 0.01 end end end describe "path_loss_db/3 with band-aware albedo" do test "uses the supplied albedo — different ρ gives different loss" do flat = Eme.path_loss_db(10_368.0, 384_400.0) band_aware = Eme.path_loss_db(10_368.0, 384_400.0, Eme.moon_albedo(10_368.0)) # At 10 GHz band_aware uses ρ = 0.090 vs flat ρ = 0.065 → # band_aware should be ~1.4 dB LOWER (less loss because the # moon reflects more at cm-wavelengths). assert band_aware < flat assert_in_delta flat - band_aware, 1.4, 0.2 end end describe "received_power_dbm/1" do test "classic 1296 MHz / 1 kW / 45 dBi case lands at ~ -145 dBm" do # 30 dBm + 45 dBi + 45 dBi - 271 dB ≈ -151 dBm (weak but # workable for a CW operator with a cooled LNA). 1 kW = 60 dBm. p = Eme.received_power_dbm( tx_power_dbm: 60.0, tx_gain_dbi: 45.0, rx_gain_dbi: 45.0, freq_mhz: 1_296.0, distance_km: 384_400.0 ) assert_in_delta p, -121.0, 1.0 end test "every dB added to gain adds 1 dB to received power (linearity)" do base = Eme.received_power_dbm( tx_power_dbm: 50.0, tx_gain_dbi: 30.0, rx_gain_dbi: 30.0, freq_mhz: 1_296.0, distance_km: 384_400.0 ) bumped = Eme.received_power_dbm( tx_power_dbm: 50.0, tx_gain_dbi: 31.0, rx_gain_dbi: 30.0, freq_mhz: 1_296.0, distance_km: 384_400.0 ) assert_in_delta bumped - base, 1.0, 0.001 end end describe "doppler_shift_hz/2" do test "zero radial velocity gives zero shift" do assert Eme.doppler_shift_hz(1_296.0, 0.0) == 0.0 end test "receding Moon (+v) produces a negative (down-shifted) return" do # +1 m/s = +0.001 km/s receding at 1296 MHz → # Δf = -2 * 1296e6 * 1 / 3e8 ≈ -8.65 Hz. dop = Eme.doppler_shift_hz(1_296.0, 0.001) assert dop < 0.0 assert_in_delta dop, -8.65, 0.05 end test "approaching Moon (−v) produces a positive (up-shifted) return" do dop = Eme.doppler_shift_hz(10_368.0, -0.5) assert dop > 0.0 # Δf = +2 * 10.368e9 * 500 / 3e8 ≈ +34_560 Hz. assert_in_delta dop, 34_560.0, 100.0 end test "scales linearly with frequency at fixed radial velocity" do dop_1296 = Eme.doppler_shift_hz(1_296.0, 0.1) dop_10368 = Eme.doppler_shift_hz(10_368.0, 0.1) assert_in_delta dop_10368 / dop_1296, 10_368.0 / 1_296.0, 1.0e-9 end test "scales linearly with radial velocity at fixed frequency" do a = Eme.doppler_shift_hz(1_296.0, 0.2) b = Eme.doppler_shift_hz(1_296.0, 0.4) assert_in_delta b / a, 2.0, 1.0e-9 end property "sign is always opposite to radial_velocity (receding → down-shift)" do check all( f <- float(min: 50.0, max: 100_000.0), v <- float(min: -2.0, max: 2.0), v != 0.0 ) do dop = Eme.doppler_shift_hz(f, v) if v > 0 do assert dop < 0 else assert dop > 0 end end end end describe "noise_floor_dbm/2" do test "-174 dBm/Hz at room temperature (290 K) matches kTB textbook" do assert_in_delta Eme.noise_floor_dbm(1.0, 290.0), -174.0, 0.01 end test "+10 dB noise increase per decade of bandwidth" do base = Eme.noise_floor_dbm(100.0, 290.0) decade = Eme.noise_floor_dbm(1_000.0, 290.0) assert_in_delta decade - base, 10.0, 0.01 end test "halving Tsys drops noise floor by ~3 dB" do hot = Eme.noise_floor_dbm(2500.0, 290.0) cold = Eme.noise_floor_dbm(2500.0, 145.0) assert_in_delta hot - cold, 3.01, 0.01 end end describe "snr_db/1" do test "own-echo CW SNR on a decent 1296 MHz station comes out positive" do # 500 W (57 dBm), 3 m dish (~30 dBi), 100 Hz CW bandwidth, 100 K # system noise. Should be ~12 dB above thermal — plenty for CW. snr = Eme.snr_db( tx_power_dbm: 57.0, tx_gain_dbi: 32.0, rx_gain_dbi: 32.0, freq_mhz: 1_296.0, distance_km: 384_400.0, bandwidth_hz: 100.0, t_sys_k: 100.0 ) assert snr > 5.0 and snr < 30.0 end test "narrowing detection bandwidth improves SNR one-for-one in dB" do opts = [ tx_power_dbm: 50.0, tx_gain_dbi: 30.0, rx_gain_dbi: 30.0, freq_mhz: 1_296.0, distance_km: 384_400.0, t_sys_k: 290.0 ] snr_wide = Eme.snr_db(Keyword.put(opts, :bandwidth_hz, 2500.0)) snr_narrow = Eme.snr_db(Keyword.put(opts, :bandwidth_hz, 25.0)) # 100× narrower → 20 dB less noise → 20 dB more SNR. assert_in_delta snr_narrow - snr_wide, 20.0, 0.01 end end end