New /eme page — antenna aiming + link budget for moonbounce.
Given a station (callsign / grid / lat,lon), TX power, antenna gain,
detection bandwidth, and system Tsys, the page shows in real time:
- Moon azimuth / elevation from the observer (ticks every 10 s)
- Slant range to the Moon
- EIRP breakdown: TX(dBm) + gain(dBi) = EIRP(dBm)
- Path-loss decomposition (Earth → Moon → Earth):
free-space spreading (×2)
− moon reflection gain 10·log(4π σ / λ²) where σ = ρ·π·R²
= round-trip path loss
- Band-dependent lunar albedo ρ (VHF ≈ 0.065, microwave ≈ 0.08,
mm-wave ≈ 0.02) surfaced as a named line item with the current
ρ shown to three decimals
- Received power, thermal noise floor, and SNR with a badge that
classifies the margin (strong / marginal / below noise)
- Self-echo Doppler shift with its radial-velocity driver
New modules:
- `Microwaveprop.Moon` — low-precision lunar ephemeris
(Astronomical Almanac Sec. D.4 truncated series). Provides
`julian_day/1`, `geocentric/1`, `observer_position/3`, and
`radial_velocity_km_s/3`. Accurate to ~0.3° position / ~200 km
distance — well inside any amateur EME-dish beamwidth.
- `Microwaveprop.Propagation.Eme` — path-loss helpers:
`moon_albedo/1` (band lookup), `moon_rcs_m2/1`,
`fspl_round_trip_db/2`, `moon_reflection_gain_db/2`,
`path_loss_db/3`, `received_power_dbm/1`, `noise_floor_dbm/2`,
`snr_db/1`, `doppler_shift_hz/2`.
Wired into the main nav ("EME" button) and the router between
/path and /rover.
Tests: 3 properties + 41 unit tests cover JD conversion, moon
position envelope, band-dependent albedo lookup, decomposition
identity, linearity/monotonicity of path loss and Doppler, and a
LiveView integration test against the rendered DOM.
Full suite: 2,460 tests + 170 properties; credo strict clean.
291 lines
9.8 KiB
Elixir
291 lines
9.8 KiB
Elixir
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
|