prop/test/microwaveprop/propagation/eme_test.exs
Graham McIntire bd107ee747
feat(eme): add /eme Earth-Moon-Earth calculator
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.
2026-04-23 16:18:20 -05:00

291 lines
9.8 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
# EarthMoon 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