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.
197 lines
7.6 KiB
Elixir
197 lines
7.6 KiB
Elixir
defmodule Microwaveprop.Propagation.Eme do
|
||
@moduledoc """
|
||
Earth-Moon-Earth (moonbounce) link-budget helpers.
|
||
|
||
EME path loss follows the bistatic radar equation with the Moon
|
||
acting as a limb-darkened sphere of albedo ρ ≈ 0.065. That factors
|
||
into a single RCS σ_moon ≈ 0.065 · π · R_moon² ≈ 6.16 × 10¹¹ m².
|
||
|
||
Reducing the full radar equation to `f_MHz` and `d_km` gives the
|
||
closed form used by this module:
|
||
|
||
L_eme(dB) = -14.44 + 40·log₁₀(d_km) + 20·log₁₀(f_MHz)
|
||
|
||
Spot-check values (round-trip, one leg Earth→Moon, one leg
|
||
Moon→Earth):
|
||
|
||
- 144 MHz at 384_400 km → ~252 dB
|
||
- 432 MHz at 384_400 km → ~261 dB
|
||
- 1_296 MHz at 384_400 km → ~271 dB
|
||
- 10_368 MHz at 384_400 km → ~289 dB
|
||
|
||
Pure functions only — no DB, no observer state.
|
||
"""
|
||
|
||
@moon_radius_km 1738.1
|
||
@default_albedo 0.065
|
||
|
||
@doc """
|
||
Band-dependent lunar radar albedo ρ (dimensionless).
|
||
|
||
Measured / cited values from the EME literature (Evans 1969,
|
||
Hagfors, and later K1JT / W5LUA handbook values):
|
||
|
||
| Band | ρ |
|
||
|------------------|------|
|
||
| < 300 MHz | 0.065|
|
||
| 300–999 MHz | 0.081|
|
||
| 1–1.999 GHz | 0.065|
|
||
| 2–4.999 GHz | 0.070|
|
||
| 5–9.999 GHz | 0.080|
|
||
| 10–23.999 GHz | 0.090|
|
||
| 24–46.999 GHz | 0.060|
|
||
| 47–75.999 GHz | 0.030|
|
||
| ≥ 76 GHz | 0.020|
|
||
|
||
The tiers reflect the surface-roughness regime the radar beam
|
||
probes: meter-wavelength bands see quasi-specular returns off
|
||
smooth mare, mid-centimetre bands see a rough surface, and
|
||
millimetre bands start losing reflectivity to thermal emission.
|
||
"""
|
||
@spec moon_albedo(number()) :: float()
|
||
def moon_albedo(freq_mhz) when is_number(freq_mhz) and freq_mhz < 300, do: 0.065
|
||
def moon_albedo(freq_mhz) when freq_mhz < 1000, do: 0.081
|
||
def moon_albedo(freq_mhz) when freq_mhz < 2000, do: 0.065
|
||
def moon_albedo(freq_mhz) when freq_mhz < 5000, do: 0.070
|
||
def moon_albedo(freq_mhz) when freq_mhz < 10_000, do: 0.080
|
||
def moon_albedo(freq_mhz) when freq_mhz < 24_000, do: 0.090
|
||
def moon_albedo(freq_mhz) when freq_mhz < 47_000, do: 0.060
|
||
def moon_albedo(freq_mhz) when freq_mhz < 76_000, do: 0.030
|
||
def moon_albedo(freq_mhz) when freq_mhz >= 76_000, do: 0.020
|
||
|
||
@doc """
|
||
Returns the Moon's radar cross-section σ in m² for a given albedo.
|
||
Defaults to 0.065 (the mean value commonly quoted at 1.3 GHz) so
|
||
callers that don't care about band-dependence get the classic
|
||
baseline.
|
||
"""
|
||
@spec moon_rcs_m2(number()) :: float()
|
||
def moon_rcs_m2(albedo \\ @default_albedo) when is_number(albedo) and albedo > 0 do
|
||
radius_m = @moon_radius_km * 1000.0
|
||
albedo * :math.pi() * radius_m * radius_m
|
||
end
|
||
|
||
@doc """
|
||
Free-space round-trip path loss (Earth → Moon → Earth) in dB,
|
||
without any Moon-reflection term. This is just 2 × one-way FSPL:
|
||
|
||
L_fs,2way(dB) = 64.90 + 40·log₁₀(f_MHz) + 40·log₁₀(d_km)
|
||
|
||
Pair with `moon_reflection_gain_db/2` to decompose the full EME
|
||
loss into its spreading vs scattering terms.
|
||
"""
|
||
@spec fspl_round_trip_db(number(), number()) :: float()
|
||
def fspl_round_trip_db(freq_mhz, distance_km)
|
||
when is_number(freq_mhz) and freq_mhz > 0 and is_number(distance_km) and distance_km > 0 do
|
||
64.90 + 40.0 * :math.log10(freq_mhz) + 40.0 * :math.log10(distance_km)
|
||
end
|
||
|
||
@doc """
|
||
The Moon's equivalent "antenna gain" in dB for a monostatic radar:
|
||
`G_moon = 10·log₁₀(4π σ / λ²)` where σ = ρ · π · R². This is the
|
||
term that *reduces* the net round-trip path loss relative to plain
|
||
double-FSPL — i.e. the moon's reflection effectiveness.
|
||
|
||
`moon_reflection_gain_db/2` lets callers override the albedo (for
|
||
example via `moon_albedo/1`); `moon_reflection_gain_db/1` defaults
|
||
to ρ = 0.065.
|
||
"""
|
||
@spec moon_reflection_gain_db(number(), number()) :: float()
|
||
def moon_reflection_gain_db(freq_mhz, albedo \\ @default_albedo)
|
||
when is_number(freq_mhz) and freq_mhz > 0 and is_number(albedo) and albedo > 0 do
|
||
lambda_m = 300.0 / freq_mhz
|
||
sigma = moon_rcs_m2(albedo)
|
||
10.0 * :math.log10(4.0 * :math.pi() * sigma / (lambda_m * lambda_m))
|
||
end
|
||
|
||
@doc """
|
||
Round-trip EME path loss in dB for a frequency in MHz and an
|
||
Earth→Moon slant range in km. Covers the transmit leg, Moon
|
||
scattering, and receive leg in a single number (the conventional
|
||
"moonbounce path loss" that link-budget spreadsheets use).
|
||
|
||
Accepts an optional `albedo` override — pass
|
||
`moon_albedo(freq_mhz)` for a band-aware calculation. Defaults to
|
||
ρ = 0.065, the classic 1296 MHz value, which keeps legacy callers
|
||
stable.
|
||
|
||
Equivalent to `fspl_round_trip_db - moon_reflection_gain_db`.
|
||
"""
|
||
@spec path_loss_db(number(), number(), number()) :: float()
|
||
def path_loss_db(freq_mhz, distance_km, albedo \\ @default_albedo)
|
||
when is_number(freq_mhz) and freq_mhz > 0 and is_number(distance_km) and distance_km > 0 and is_number(albedo) and
|
||
albedo > 0 do
|
||
fspl_round_trip_db(freq_mhz, distance_km) - moon_reflection_gain_db(freq_mhz, albedo)
|
||
end
|
||
|
||
# Speed of light (m/s).
|
||
@c_m_s 299_792_458.0
|
||
|
||
@doc """
|
||
Self-echo Doppler shift in Hz for an EME bounce, given the operating
|
||
frequency and the Moon's radial velocity relative to the observer.
|
||
|
||
For an own-echo round-trip the Doppler is twice the one-way shift:
|
||
`Δf = −2 · f · v_radial / c`. A positive radial velocity (Moon
|
||
receding) produces a negative Hz shift — the returned bounce lands
|
||
*below* the transmit frequency.
|
||
"""
|
||
@spec doppler_shift_hz(number(), number()) :: float()
|
||
def doppler_shift_hz(freq_mhz, radial_velocity_km_s)
|
||
when is_number(freq_mhz) and freq_mhz > 0 and is_number(radial_velocity_km_s) do
|
||
freq_hz = freq_mhz * 1_000_000.0
|
||
v_m_s = radial_velocity_km_s * 1000.0
|
||
-2.0 * freq_hz * v_m_s / @c_m_s
|
||
end
|
||
|
||
@doc """
|
||
Received signal power in dBm for a symmetric own-echo EME link
|
||
(operator listens to their own bounce) given:
|
||
|
||
- `:tx_power_dbm` — transmitter output power (dBm)
|
||
- `:tx_gain_dbi` — transmit antenna gain (dBi)
|
||
- `:rx_gain_dbi` — receive antenna gain (dBi) — same as tx for
|
||
self-echo, kept distinct so the function also works for cross-
|
||
station contacts
|
||
- `:freq_mhz` — operating frequency (MHz)
|
||
- `:distance_km` — one-way slant range to the Moon (km)
|
||
"""
|
||
@spec received_power_dbm(keyword()) :: float()
|
||
def received_power_dbm(opts) do
|
||
tx = Keyword.fetch!(opts, :tx_power_dbm)
|
||
gt = Keyword.fetch!(opts, :tx_gain_dbi)
|
||
gr = Keyword.fetch!(opts, :rx_gain_dbi)
|
||
f = Keyword.fetch!(opts, :freq_mhz)
|
||
d = Keyword.fetch!(opts, :distance_km)
|
||
|
||
tx + gt + gr - path_loss_db(f, d)
|
||
end
|
||
|
||
@doc """
|
||
Thermal noise floor (dBm) in a given bandwidth and system noise
|
||
temperature. Callers use this against `received_power_dbm/1` to
|
||
get SNR.
|
||
|
||
Default Tsys = 290 K (room-temperature receive system, a reasonable
|
||
placeholder for VHF; a real cooled LNA at 10 GHz is closer to 100 K).
|
||
"""
|
||
@spec noise_floor_dbm(number(), number()) :: float()
|
||
def noise_floor_dbm(bandwidth_hz, t_sys_k \\ 290.0)
|
||
when is_number(bandwidth_hz) and bandwidth_hz > 0 and is_number(t_sys_k) and t_sys_k > 0 do
|
||
# -174 dBm/Hz + 10 log(BW) + 10 log(T / 290)
|
||
-174.0 + 10.0 * :math.log10(bandwidth_hz) + 10.0 * :math.log10(t_sys_k / 290.0)
|
||
end
|
||
|
||
@doc """
|
||
Signal-to-noise ratio (dB) for a symmetric own-echo EME link.
|
||
Convenience on top of `received_power_dbm/1` and
|
||
`noise_floor_dbm/2` — returns the dB margin over thermal noise in
|
||
the specified detection bandwidth.
|
||
"""
|
||
@spec snr_db(keyword()) :: float()
|
||
def snr_db(opts) do
|
||
bw_hz = Keyword.fetch!(opts, :bandwidth_hz)
|
||
t_sys_k = Keyword.get(opts, :t_sys_k, 290.0)
|
||
received_power_dbm(opts) - noise_floor_dbm(bw_hz, t_sys_k)
|
||
end
|
||
end
|