prop/lib/microwaveprop/propagation/eme.ex
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

197 lines
7.6 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.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|
| 300999 MHz | 0.081|
| 11.999 GHz | 0.065|
| 24.999 GHz | 0.070|
| 59.999 GHz | 0.080|
| 1023.999 GHz | 0.090|
| 2446.999 GHz | 0.060|
| 4775.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