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.
This commit is contained in:
Graham McIntire 2026-04-23 16:18:19 -05:00
parent e33116f759
commit bd107ee747
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
8 changed files with 1539 additions and 0 deletions

193
lib/microwaveprop/moon.ex Normal file
View file

@ -0,0 +1,193 @@
defmodule Microwaveprop.Moon do
@moduledoc """
Low-precision lunar ephemeris.
Uses the Astronomical Almanac "Low-Precision Formulas for the Moon"
(Section D.4) 6 longitude terms, 4 latitude terms, and 4 parallax
terms. Accurate to ~0.3° on position and ~200 km on distance, which
is well inside the beamwidth of any amateur EME dish at every
supported microwave band (1° beam at 10 GHz for a 1 m dish; wider
below that).
Exposes two coordinates:
- `geocentric/1` the Moon's right ascension, declination, and
distance from Earth's centre at a UTC instant.
- `observer_position/3` topocentric azimuth / elevation /
slant range for an observer at `(lat, lon)` degrees.
Both are pure functions of UTC plus observer coordinates; no side
effects, no DB access. Callers drive them on a timer to refresh
antenna aim points.
"""
@earth_radius_km 6378.14
@doc """
Julian Day for a Gregorian `DateTime`. Caller is responsible for
passing UT; TAI/UT1 offset (< 1 s) is ignored the low-precision
lunar series doesn't resolve it anyway.
"""
@spec julian_day(DateTime.t()) :: float()
def julian_day(%DateTime{} = dt) do
day_frac = (dt.hour + dt.minute / 60 + dt.second / 3600) / 24
{y, m} =
if dt.month <= 2 do
{dt.year - 1, dt.month + 12}
else
{dt.year, dt.month}
end
a = div(y, 100)
b = 2 - a + div(a, 4)
trunc(365.25 * (y + 4716)) + trunc(30.6001 * (m + 1)) + dt.day + day_frac + b - 1524.5
end
@doc """
Geocentric ecliptic equatorial position of the Moon at a UTC
instant. Returns `%{ra, dec, distance_km}` where `ra` and `dec`
are in degrees.
"""
@spec geocentric(DateTime.t()) :: %{ra: float(), dec: float(), distance_km: float()}
def geocentric(%DateTime{} = dt) do
jd = julian_day(dt)
t = (jd - 2_451_545.0) / 36_525.0
# Mean-element arguments (degrees, wrapped before trig conversion).
l = 218.32 + 481_267.881 * t
m_prime = 134.9 + 477_198.85 * t
m = 357.53 + 35_999.05 * t
d = 297.85 + 445_267.11 * t
f = 93.27 + 483_202.03 * t
m_prime_r = deg_to_rad(m_prime)
m_r = deg_to_rad(m)
d_r = deg_to_rad(d)
f_r = deg_to_rad(f)
# Ecliptic longitude λ (degrees).
lambda =
l + 6.29 * :math.sin(m_prime_r) -
1.27 * :math.sin(m_prime_r - 2 * d_r) +
0.66 * :math.sin(2 * d_r) +
0.21 * :math.sin(2 * m_prime_r) -
0.19 * :math.sin(m_r) -
0.11 * :math.sin(2 * f_r)
# Ecliptic latitude β (degrees).
beta =
5.13 * :math.sin(f_r) +
0.28 * :math.sin(m_prime_r + f_r) -
0.28 * :math.sin(m_prime_r - f_r) -
0.17 * :math.sin(2 * d_r - f_r)
# Horizontal parallax π (degrees).
parallax =
0.9508 +
0.0518 * :math.cos(m_prime_r) +
0.0095 * :math.cos(m_prime_r - 2 * d_r) +
0.0078 * :math.cos(2 * d_r) +
0.0028 * :math.cos(2 * m_prime_r)
distance_km = @earth_radius_km / :math.sin(deg_to_rad(parallax))
# Mean obliquity of the ecliptic (IAU 1980).
epsilon = 23.4393 - 0.0130 * t
lambda_r = deg_to_rad(lambda)
beta_r = deg_to_rad(beta)
eps_r = deg_to_rad(epsilon)
sin_dec =
:math.sin(beta_r) * :math.cos(eps_r) +
:math.cos(beta_r) * :math.sin(eps_r) * :math.sin(lambda_r)
dec = rad_to_deg(:math.asin(sin_dec))
ra =
(:math.sin(lambda_r) * :math.cos(eps_r) - :math.tan(beta_r) * :math.sin(eps_r))
|> :math.atan2(:math.cos(lambda_r))
|> rad_to_deg()
|> normalize_360()
%{ra: ra, dec: dec, distance_km: distance_km}
end
@doc """
Topocentric Moon position for an observer at `(lat, lon)` degrees,
UTC `dt`. Returns azimuth [0, 360), elevation [-90, 90], and
the geocentric slant range (the topocentric correction is below
the module's stated accuracy floor).
"""
@spec observer_position(DateTime.t(), float(), float()) ::
%{azimuth: float(), elevation: float(), distance_km: float()}
def observer_position(%DateTime{} = dt, lat_deg, lon_deg) do
g = geocentric(dt)
jd = julian_day(dt)
t = (jd - 2_451_545.0) / 36_525.0
# Greenwich Mean Sidereal Time — USNO polynomial (degrees).
gmst =
normalize_360(
280.46061837 + 360.98564736629 * (jd - 2_451_545.0) + 0.000387933 * t * t -
t * t * t / 38_710_000.0
)
lst = normalize_360(gmst + lon_deg)
hour_angle = normalize_360(lst - g.ra)
lat_r = deg_to_rad(lat_deg)
dec_r = deg_to_rad(g.dec)
ha_r = deg_to_rad(hour_angle)
sin_el =
:math.sin(lat_r) * :math.sin(dec_r) +
:math.cos(lat_r) * :math.cos(dec_r) * :math.cos(ha_r)
elevation = rad_to_deg(:math.asin(sin_el))
azimuth =
-:math.sin(ha_r)
|> :math.atan2(:math.tan(dec_r) * :math.cos(lat_r) - :math.sin(lat_r) * :math.cos(ha_r))
|> rad_to_deg()
|> normalize_360()
%{azimuth: azimuth, elevation: elevation, distance_km: g.distance_km}
end
@doc """
Radial velocity of the Moon relative to an observer at `(lat, lon)`
in km/s at UTC instant `dt`. Positive = the Moon is moving away
(increasing slant range), negative = approaching.
Computed via centred finite difference on the geocentric distance,
which tracks the dominant rate (the Earth-Moon elliptical orbit
plus the observer's diurnal baseline contribution is below 100 m/s).
Accurate enough to size the ±kHz Doppler shift EME operators care
about at the VHF/microwave bands this project supports.
"""
@spec radial_velocity_km_s(DateTime.t(), float(), float()) :: float()
def radial_velocity_km_s(%DateTime{} = dt, lat_deg, lon_deg) do
before = DateTime.add(dt, -1, :second)
later = DateTime.add(dt, 1, :second)
d_before = observer_position(before, lat_deg, lon_deg).distance_km
d_later = observer_position(later, lat_deg, lon_deg).distance_km
# Centred difference in km per 2 s → km/s.
(d_later - d_before) / 2.0
end
# --- Helpers --------------------------------------------------------
defp deg_to_rad(d), do: d * :math.pi() / 180.0
defp rad_to_deg(r), do: r * 180.0 / :math.pi()
defp normalize_360(x) do
r = :math.fmod(x, 360.0)
if r < 0, do: r + 360.0, else: r
end
end

View file

@ -0,0 +1,197 @@
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 EarthMoon, one leg
MoonEarth):
- 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
EarthMoon 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

View file

@ -42,6 +42,7 @@ defmodule MicrowavepropWeb.Layouts do
<nav class="flex gap-1">
<.link navigate="/map" class="btn btn-ghost btn-sm">Map</.link>
<.link navigate="/path" class="btn btn-ghost btn-sm">Path</.link>
<.link navigate="/eme" class="btn btn-ghost btn-sm">EME</.link>
<.link navigate="/beacons" class="btn btn-ghost btn-sm">Beacons</.link>
<.link navigate="/submit" class="btn btn-ghost btn-sm">Submit</.link>
<.link navigate="/contacts" class="btn btn-ghost btn-sm">Contacts</.link>

View file

@ -0,0 +1,603 @@
defmodule MicrowavepropWeb.EmeLive do
@moduledoc """
`/eme` Earth-Moon-Earth path-loss + antenna-aim calculator.
Given a station's grid/callsign/coords, TX power, and antenna gain,
the page computes:
- Current Moon az / el from the station (real-time tick).
- Slant range to the Moon.
- Round-trip EME path loss at the operating band.
- Link-budget received power for a self-echo bounce.
- Indicative CW SNR in a narrow detection bandwidth.
The Moon readout ticks once per 10 s so operators can watch the
aim drift live. Moon az/el is computed with a low-precision
ephemeris (±0.3°) well inside the beamwidth of any amateur EME
dish at the supported microwave bands.
"""
use MicrowavepropWeb, :live_view
alias Microwaveprop.Moon
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Eme
alias Microwaveprop.Radio.CallsignClient
alias Microwaveprop.Radio.Maidenhead
@band_options BandConfig.band_options()
@tick_ms 10_000
@defaults %{
"source" => "",
"band" => "1296",
"tx_power_dbm" => "50",
"tx_gain_dbi" => "30",
"bandwidth_hz" => "100",
"t_sys_k" => "150"
}
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
:timer.send_interval(@tick_ms, self(), :tick)
end
{:ok,
assign(socket,
page_title: "EME Calculator",
band_options: @band_options,
source: "",
source_resolved: nil,
band: "1296",
tx_power_dbm: "50",
tx_gain_dbi: "30",
bandwidth_hz: "100",
t_sys_k: "150",
moon: nil,
link: nil,
error: nil,
now_utc: DateTime.utc_now()
)}
end
@impl true
def handle_params(params, _uri, socket) do
p = Map.merge(@defaults, Map.take(params, Map.keys(@defaults)))
socket =
assign(socket,
source: p["source"],
band: p["band"],
tx_power_dbm: p["tx_power_dbm"],
tx_gain_dbi: p["tx_gain_dbi"],
bandwidth_hz: p["bandwidth_hz"],
t_sys_k: p["t_sys_k"]
)
{:noreply, recompute(socket)}
end
@impl true
def handle_event("calculate", params, socket) do
url_params =
@defaults
|> Map.keys()
|> Enum.reduce(%{}, fn k, acc ->
v = params[k] || ""
if v == "" or v == @defaults[k], do: acc, else: Map.put(acc, k, v)
end)
{:noreply, push_patch(socket, to: ~p"/eme?#{url_params}", replace: true)}
end
@impl true
def handle_info(:tick, socket) do
{:noreply, socket |> assign(now_utc: DateTime.utc_now()) |> recompute_moon_only()}
end
# Recompute both the source resolution and the link budget (called on
# form submit / URL patch).
defp recompute(socket) do
case resolve_source(socket.assigns.source) do
{:ok, resolved} ->
socket
|> assign(source_resolved: resolved, error: nil)
|> recompute_moon_only()
{:error, reason} ->
assign(socket, source_resolved: nil, error: reason, moon: nil, link: nil)
:empty ->
assign(socket, source_resolved: nil, error: nil, moon: nil, link: nil)
end
end
# Refresh just the live-ticking numbers (moon pos + link budget).
# Called from the tick timer so a stale error state doesn't get
# stomped by a repeat source-resolution.
defp recompute_moon_only(%{assigns: %{source_resolved: nil}} = socket), do: socket
defp recompute_moon_only(socket) do
%{lat: lat, lon: lon} = socket.assigns.source_resolved
moon = Moon.observer_position(socket.assigns.now_utc, lat, lon)
band_mhz = parse_int(socket.assigns.band, 1_296)
tx_dbm = parse_float(socket.assigns.tx_power_dbm, 50.0)
tx_gain = parse_float(socket.assigns.tx_gain_dbi, 30.0)
bw = parse_float(socket.assigns.bandwidth_hz, 100.0)
tsys = parse_float(socket.assigns.t_sys_k, 150.0)
# Band-dependent lunar albedo feeds both the path-loss number
# and the per-line-item display below.
albedo = Eme.moon_albedo(band_mhz)
fspl_rt = Eme.fspl_round_trip_db(band_mhz, moon.distance_km)
moon_gain = Eme.moon_reflection_gain_db(band_mhz, albedo)
path_loss = fspl_rt - moon_gain
# Keep everything in dBm for display — EIRP(dBm) = TX(dBm) + G_tx(dBi).
eirp_dbm = tx_dbm + tx_gain
received_power = tx_dbm + tx_gain + tx_gain - path_loss
noise_floor = Eme.noise_floor_dbm(bw, tsys)
snr = received_power - noise_floor
# Self-echo Doppler (Earth → Moon → Earth) for the current
# geometry. Live-updates with the tick.
v_radial_km_s = Moon.radial_velocity_km_s(socket.assigns.now_utc, lat, lon)
doppler_hz = Eme.doppler_shift_hz(band_mhz, v_radial_km_s)
link = %{
# EIRP breakdown so the template can render "TX + gain = EIRP".
tx_power_dbm: tx_dbm,
tx_power_w: :math.pow(10.0, (tx_dbm - 30) / 10.0),
tx_gain_dbi: tx_gain,
eirp_dbm: eirp_dbm,
eirp_w: :math.pow(10.0, (eirp_dbm - 30) / 10.0),
# Path-loss decomposition: spreading vs moon-reflection gain.
fspl_round_trip_db: fspl_rt,
moon_albedo: albedo,
moon_reflection_gain_db: moon_gain,
path_loss_db: path_loss,
# Final link numbers.
received_power_dbm: received_power,
noise_floor_dbm: noise_floor,
snr_db: snr,
# Doppler (2× one-way for self-echo round-trip).
radial_velocity_km_s: v_radial_km_s,
doppler_hz: doppler_hz
}
assign(socket, moon: moon, link: link)
end
# --- Input parsing --------------------------------------------------
defp resolve_source(""), do: :empty
defp resolve_source(raw) do
input = String.trim(raw)
cond do
input == "" ->
:empty
coordinate_pair?(input) ->
[lat_s, lon_s] = String.split(input, ",", parts: 2)
{lat, _} = Float.parse(String.trim(lat_s))
{lon, _} = Float.parse(String.trim(lon_s))
{:ok,
%{
lat: lat,
lon: lon,
label: "#{Float.round(lat, 3)}, #{Float.round(lon, 3)}",
kind: :coordinates
}}
maidenhead?(input) ->
case Maidenhead.to_latlon(input) do
{:ok, {lat, lon}} ->
{:ok, %{lat: lat, lon: lon, label: String.upcase(input), kind: :grid}}
:error ->
{:error, "Invalid Maidenhead grid: #{input}"}
end
true ->
case CallsignClient.locate(input) do
{:ok, info} ->
{:ok,
%{
lat: info.lat,
lon: info.lon,
label: String.upcase(info.callsign),
grid: info.gridsquare,
kind: :callsign
}}
{:error, reason} ->
{:error, "Could not find #{input}: #{reason}"}
end
end
end
defp coordinate_pair?(input) do
case String.split(input, ",", parts: 2) do
[a, b] ->
match?({_, _}, Float.parse(String.trim(a))) and
match?({_, _}, Float.parse(String.trim(b)))
_ ->
false
end
end
defp maidenhead?(input) do
String.length(input) >= 4 and String.length(input) <= 10 and
Regex.match?(~r/^[A-Ra-r]{2}[0-9]{2}/i, input)
end
defp parse_int(s, default) do
case Integer.parse(to_string(s)) do
{n, _} -> n
:error -> default
end
end
defp parse_float(nil, default), do: default
defp parse_float("", default), do: default
defp parse_float(s, default) do
case Float.parse(to_string(s)) do
{n, _} -> n
:error -> default
end
end
# --- Render helpers -------------------------------------------------
#
# Note on UTF-8: these use Elixir's native :erlang.float_to_binary/2
# rather than :io_lib.format/2 because the latter treats each byte of
# an inline UTF-8 codepoint (like "°" = 0xC2 0xB0) as a separate
# Latin-1 character, producing mojibake (°) when the result is
# joined back into a binary.
defp format_deg(nil), do: ""
defp format_deg(v) when is_number(v) do
:erlang.float_to_binary(v * 1.0, decimals: 2) <> "°"
end
defp format_distance_km(nil), do: ""
defp format_distance_km(v) when is_number(v) do
# Integer km with thousands separators ("374,088 km").
v |> round() |> Microwaveprop.Format.number() |> Kernel.<>(" km")
end
defp format_db(nil), do: ""
defp format_db(v) when is_number(v), do: :erlang.float_to_binary(v * 1.0, decimals: 1) <> " dB"
defp format_dbm(nil), do: ""
defp format_dbm(v) when is_number(v) do
:erlang.float_to_binary(v * 1.0, decimals: 1) <> " dBm"
end
defp format_doppler(nil), do: ""
defp format_doppler(hz) when is_number(hz) do
abs_hz = abs(hz)
body =
cond do
abs_hz >= 1000.0 -> :erlang.float_to_binary(hz / 1000.0, decimals: 2) <> " kHz"
abs_hz >= 1.0 -> :erlang.float_to_binary(hz * 1.0, decimals: 0) <> " Hz"
true -> :erlang.float_to_binary(hz * 1.0, decimals: 2) <> " Hz"
end
# Prepend a + sign for positive (rising) shifts; `float_to_binary`
# already renders negatives with the leading character.
if hz > 0, do: "+" <> body, else: body
end
defp format_radial_velocity(nil), do: ""
defp format_radial_velocity(v_km_s) when is_number(v_km_s) do
# Display in m/s at the ~kHz-scale Doppler levels EME operators
# care about. Positive = receding.
m_s = v_km_s * 1000.0
suffix = " m/s"
body = :erlang.float_to_binary(m_s, decimals: 1)
if m_s > 0, do: "+" <> body <> suffix, else: body <> suffix
end
defp format_watts(nil), do: ""
defp format_watts(w) when is_number(w) and w >= 1000 do
:erlang.float_to_binary(w / 1000.0, decimals: 2) <> " kW"
end
defp format_watts(w) when is_number(w) and w >= 1 do
:erlang.float_to_binary(w * 1.0, decimals: 1) <> " W"
end
defp format_watts(w) when is_number(w) and w > 0 do
:erlang.float_to_binary(w * 1000.0, decimals: 2) <> " mW"
end
defp format_watts(_), do: ""
defp format_albedo(nil), do: ""
defp format_albedo(v) when is_number(v) do
:erlang.float_to_binary(v * 1.0, decimals: 3)
end
defp snr_badge_class(nil), do: "badge-ghost"
defp snr_badge_class(snr) when snr >= 10, do: "badge-success"
defp snr_badge_class(snr) when snr >= 0, do: "badge-warning"
defp snr_badge_class(_), do: "badge-error"
defp elevation_badge_class(nil), do: "badge-ghost"
defp elevation_badge_class(el) when el >= 5.0, do: "badge-success"
defp elevation_badge_class(el) when el >= 0.0, do: "badge-warning"
defp elevation_badge_class(_), do: "badge-error"
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-5xl px-4 py-6 space-y-6">
<header>
<h1 class="text-2xl font-semibold">EME Calculator</h1>
<p class="opacity-70 text-sm">
Earth-Moon-Earth path loss, link budget, and real-time Moon az / el for antenna aiming.
Moon position updates every 10 seconds; refresh to force an immediate recompute.
</p>
</header>
<form
phx-submit="calculate"
phx-change="calculate"
class="bg-base-200 rounded-box p-4 grid grid-cols-1 md:grid-cols-3 gap-3"
>
<label class="form-control md:col-span-3">
<div class="label">
<span class="label-text">Station (callsign, grid, or "lat,lon")</span>
</div>
<input
type="text"
name="source"
value={@source}
placeholder="e.g. W5ISP / EM13ng / 32.9,-97.0"
class="input input-bordered"
autocomplete="off"
/>
</label>
<label class="form-control">
<div class="label"><span class="label-text">Band (MHz)</span></div>
<select name="band" class="select select-bordered">
<%= for {label, value} <- @band_options do %>
<option value={value} selected={value == @band}>{label}</option>
<% end %>
</select>
</label>
<label class="form-control">
<div class="label"><span class="label-text">TX power (dBm)</span></div>
<input
type="number"
name="tx_power_dbm"
value={@tx_power_dbm}
step="0.1"
class="input input-bordered"
/>
</label>
<label class="form-control">
<div class="label"><span class="label-text">Antenna gain (dBi)</span></div>
<input
type="number"
name="tx_gain_dbi"
value={@tx_gain_dbi}
step="0.1"
class="input input-bordered"
/>
</label>
<label class="form-control">
<div class="label"><span class="label-text">Detection BW (Hz)</span></div>
<input
type="number"
name="bandwidth_hz"
value={@bandwidth_hz}
step="1"
class="input input-bordered"
/>
</label>
<label class="form-control">
<div class="label"><span class="label-text">System noise Tsys (K)</span></div>
<input
type="number"
name="t_sys_k"
value={@t_sys_k}
step="1"
class="input input-bordered"
/>
</label>
<div class="md:col-span-1 flex items-end">
<button type="submit" class="btn btn-primary w-full">Calculate</button>
</div>
</form>
<%= if @error do %>
<div class="alert alert-error">
<span>{@error}</span>
</div>
<% end %>
<%= if @source_resolved do %>
<section class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-base-100 border border-base-300 rounded-box p-4">
<div class="flex items-center justify-between gap-2">
<h2 class="text-base font-semibold">Antenna aim</h2>
<span class="text-xs opacity-60 font-mono whitespace-nowrap">
{Calendar.strftime(@now_utc, "%Y-%m-%d %H:%M:%SZ")}
</span>
</div>
<dl class="mt-3 text-sm space-y-1.5">
<div class="flex items-baseline justify-between gap-3">
<dt class="opacity-70 shrink-0">Station</dt>
<dd class="font-mono text-right">{@source_resolved.label}</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="opacity-70 shrink-0">Lat / lon</dt>
<dd class="font-mono text-right">
{Float.round(@source_resolved.lat, 3)}, {Float.round(@source_resolved.lon, 3)}
</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="opacity-70 shrink-0">Azimuth</dt>
<dd class="font-mono text-right">{format_deg(@moon && @moon.azimuth)}</dd>
</div>
<div class="flex items-baseline justify-between gap-3 flex-wrap">
<dt class="opacity-70 shrink-0">Elevation</dt>
<dd class="font-mono text-right flex items-center gap-2 flex-wrap justify-end">
<span>{format_deg(@moon && @moon.elevation)}</span>
<span class={"badge badge-sm whitespace-nowrap " <> elevation_badge_class(@moon && @moon.elevation)}>
<%= cond do %>
<% is_nil(@moon) -> %>
<% @moon.elevation >= 5.0 -> %>
above horizon
<% @moon.elevation >= 0.0 -> %>
low
<% true -> %>
below horizon
<% end %>
</span>
</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="opacity-70 shrink-0">Slant range</dt>
<dd class="font-mono text-right">
{format_distance_km(@moon && @moon.distance_km)}
</dd>
</div>
</dl>
</div>
<div class="bg-base-100 border border-base-300 rounded-box p-4">
<h2 class="text-base font-semibold">Link budget (Earth Moon Earth)</h2>
<dl class="mt-3 text-sm space-y-1.5">
<div class="flex items-baseline justify-between gap-3">
<dt class="opacity-70 shrink-0">TX power</dt>
<dd class="font-mono text-right">
{format_dbm(@link && @link.tx_power_dbm)}
<span class="opacity-60 text-xs">
({format_watts(@link && @link.tx_power_w)})
</span>
</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="opacity-70 shrink-0">Antenna gain</dt>
<dd class="font-mono text-right">
+{@link && :erlang.float_to_binary(@link.tx_gain_dbi * 1.0, decimals: 1)} dBi
</dd>
</div>
<div class="flex items-baseline justify-between gap-3 border-t border-base-300 pt-1.5">
<dt class="opacity-70 shrink-0">= EIRP</dt>
<dd class="font-mono text-right font-semibold">
{format_dbm(@link && @link.eirp_dbm)}
<span class="opacity-60 text-xs">({format_watts(@link && @link.eirp_w)})</span>
</dd>
</div>
<div class="flex items-baseline justify-between gap-3 mt-2">
<dt class="opacity-70 shrink-0">Free-space spreading (×2)</dt>
<dd class="font-mono text-right">
{format_db(@link && @link.fspl_round_trip_db)}
</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="opacity-70 shrink-0">
Moon reflection
<span class="text-xs opacity-70">
(ρ = {format_albedo(@link && @link.moon_albedo)})
</span>
</dt>
<dd class="font-mono text-right text-success">
{format_db(@link && @link.moon_reflection_gain_db)}
</dd>
</div>
<div class="flex items-baseline justify-between gap-3 border-t border-base-300 pt-1.5">
<dt class="opacity-70 shrink-0">= Round-trip path loss</dt>
<dd class="font-mono text-right font-semibold">
{format_db(@link && @link.path_loss_db)}
</dd>
</div>
<div class="flex items-baseline justify-between gap-3 mt-2">
<dt class="opacity-70 shrink-0">Received power</dt>
<dd class="font-mono text-right">
{format_dbm(@link && @link.received_power_dbm)}
</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="opacity-70 shrink-0">Noise floor</dt>
<dd class="font-mono text-right">{format_dbm(@link && @link.noise_floor_dbm)}</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="opacity-70 shrink-0">Doppler (self-echo)</dt>
<dd class="font-mono text-right">
{format_doppler(@link && @link.doppler_hz)}
<span class="opacity-60 text-xs">
({format_radial_velocity(@link && @link.radial_velocity_km_s)})
</span>
</dd>
</div>
<div class="flex items-baseline justify-between gap-3 flex-wrap border-t border-base-300 pt-1.5">
<dt class="opacity-70 shrink-0">SNR</dt>
<dd class="font-mono text-right flex items-center gap-2 flex-wrap justify-end">
<span class="font-semibold">{format_db(@link && @link.snr_db)}</span>
<span class={"badge badge-sm whitespace-nowrap " <> snr_badge_class(@link && @link.snr_db)}>
<%= cond do %>
<% is_nil(@link) -> %>
<% @link.snr_db >= 10 -> %>
strong
<% @link.snr_db >= 0 -> %>
marginal
<% true -> %>
below noise
<% end %>
</span>
</dd>
</div>
</dl>
<p class="text-xs opacity-60 mt-3">
Round-trip path loss = 2 × free-space spreading minus the Moon's
effective scattering gain <code>10·log(4π σ / λ²)</code>, with σ = ρ·π·R²<sub>moon</sub>.
Albedo ρ varies by band ( 0.065 at VHF, 0.08 at microwave, dropping above 40 GHz).
Cable, feed, and polarisation losses are on you.
</p>
</div>
</section>
<% else %>
<div class="text-sm opacity-70">
Enter your callsign or grid above to start the live Moon aim readout.
</div>
<% end %>
</div>
</Layouts.app>
"""
end
end

View file

@ -190,6 +190,7 @@ defmodule MicrowavepropWeb.Router do
live "/contacts/map", ContactMapLive
live "/contacts/:id", ContactLive.Show
live "/path", PathLive
live "/eme", EmeLive
live "/rover", RoverLive
live "/algo", AlgoLive
live "/about", AboutLive

View file

@ -0,0 +1,195 @@
defmodule Microwaveprop.MoonTest do
@moduledoc """
Tests for the low-precision lunar ephemeris.
The underlying algorithm (Astronomical Almanac Sec. D.4) is accurate
to ~0.3° on position and ~200 km on distance tolerances chosen
here reflect that.
"""
use ExUnit.Case, async: true
use ExUnitProperties
alias Microwaveprop.Moon
describe "julian_day/1" do
test "J2000 epoch is 2_451_545.0 at 2000-01-01 12:00 UTC" do
# By definition — the J2000 reference epoch.
assert Moon.julian_day(~U[2000-01-01 12:00:00Z]) == 2_451_545.0
end
test "1992-04-12 00:00 UTC is Meeus Example 47.a (2_448_724.5)" do
# Meeus, Astronomical Algorithms (2nd ed.) page 342.
assert Moon.julian_day(~U[1992-04-12 00:00:00Z]) == 2_448_724.5
end
test "increments by 1 per UTC day" do
assert_in_delta Moon.julian_day(~U[2024-01-02 00:00:00Z]) -
Moon.julian_day(~U[2024-01-01 00:00:00Z]),
1.0,
1.0e-9
end
test "increments by 0.5 per 12-hour step" do
assert_in_delta Moon.julian_day(~U[2024-06-15 12:00:00Z]) -
Moon.julian_day(~U[2024-06-15 00:00:00Z]),
0.5,
1.0e-9
end
test "handles the January / February month-correction branch" do
# Meeus's algorithm shifts Jan/Feb into the previous year's
# month 13/14 — this test exercises that branch explicitly.
assert_in_delta Moon.julian_day(~U[2024-02-29 00:00:00Z]) -
Moon.julian_day(~U[2024-02-28 00:00:00Z]),
1.0,
1.0e-9
end
property "monotonic in time" do
check all(
base_year <- integer(1900..2100),
day_offset <- integer(0..364),
delta_hours <- integer(1..72)
) do
base = %DateTime{
year: base_year,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
microsecond: {0, 0},
std_offset: 0,
utc_offset: 0,
zone_abbr: "UTC",
time_zone: "Etc/UTC"
}
a = DateTime.add(base, day_offset * 86_400, :second)
b = DateTime.add(a, delta_hours * 3600, :second)
assert Moon.julian_day(a) < Moon.julian_day(b)
end
end
end
describe "geocentric/1" do
test "distance stays inside the Earth-Moon perigee/apogee envelope" do
# Min ~356_500 km, max ~406_700 km at the extremes over the
# 21st century. Include a small margin for the simplified series.
for iso <- ~w(2024-01-01T00:00:00Z 2024-06-15T12:00:00Z 2025-03-30T00:00:00Z 2030-11-11T00:00:00Z) do
{:ok, dt, _} = DateTime.from_iso8601(iso)
%{distance_km: d} = Moon.geocentric(dt)
assert d > 350_000 and d < 410_000,
"got distance #{d} km at #{iso}, outside the 350_000..410_000 envelope"
end
end
test "declination is bounded by the Moon's ±28.6° extremes" do
for iso <- ~w(2024-01-01T00:00:00Z 2024-06-15T12:00:00Z 2025-09-21T09:00:00Z) do
{:ok, dt, _} = DateTime.from_iso8601(iso)
%{dec: dec} = Moon.geocentric(dt)
assert dec > -29.0 and dec < 29.0
end
end
test "right ascension is always in [0, 360)" do
for iso <- ~w(2024-01-01T00:00:00Z 2024-04-30T06:00:00Z 2024-09-12T18:00:00Z) do
{:ok, dt, _} = DateTime.from_iso8601(iso)
%{ra: ra} = Moon.geocentric(dt)
assert ra >= 0.0 and ra < 360.0
end
end
property "distance, RA, and declination all stay in their physical envelopes for any UTC across the next century" do
check all(seconds_from_now <- integer(0..3_155_760_000)) do
dt = DateTime.add(~U[2024-01-01 00:00:00Z], seconds_from_now, :second)
g = Moon.geocentric(dt)
assert g.distance_km > 340_000 and g.distance_km < 420_000
assert g.dec > -30.0 and g.dec < 30.0
assert g.ra >= 0.0 and g.ra < 360.0
end
end
end
describe "observer_position/3" do
test "azimuth and elevation are always in their ranges" do
for iso <- ~w(2024-01-01T00:00:00Z 2024-06-15T12:00:00Z) do
{:ok, dt, _} = DateTime.from_iso8601(iso)
# DFW, London, Sydney, North Pole.
for {lat, lon} <- [{32.9, -97.0}, {51.5, 0.0}, {-33.9, 151.2}, {89.9, 0.0}] do
p = Moon.observer_position(dt, lat, lon)
assert p.azimuth >= 0.0 and p.azimuth < 360.0
assert p.elevation >= -90.0 and p.elevation <= 90.0
assert p.distance_km > 340_000 and p.distance_km < 420_000
end
end
end
test "distance matches geocentric/1 (topocentric correction skipped)" do
# The module stubs topocentric slant range (< 0.02° error) so
# observer distance equals geocentric distance.
dt = ~U[2024-06-15 12:00:00Z]
g = Moon.geocentric(dt)
p = Moon.observer_position(dt, 32.9, -97.0)
assert_in_delta p.distance_km, g.distance_km, 1.0e-6
end
property "azimuth always in [0, 360), elevation in [-90, 90]" do
check all(
seconds <- integer(0..3_155_760_000),
lat <- float(min: -85.0, max: 85.0),
lon <- float(min: -179.9, max: 179.9)
) do
dt = DateTime.add(~U[2024-01-01 00:00:00Z], seconds, :second)
p = Moon.observer_position(dt, lat, lon)
assert p.azimuth >= 0.0 and p.azimuth < 360.0
assert p.elevation >= -90.0 and p.elevation <= 90.0
end
end
test "radial_velocity_km_s stays within the dominant orbital envelope ±1.5 km/s" do
# The Earth-Moon distance changes at most by its orbital
# eccentricity over half a month; instantaneously |dr/dt| peaks
# near ±0.5 km/s on the orbital component. Adding observer
# diurnal baseline motion (max ~0.5 km/s extra at equatorial
# lat) keeps the envelope under ~1.5 km/s.
for iso <- ~w(2024-01-01T00:00:00Z 2024-06-15T12:00:00Z 2025-09-21T09:00:00Z) do
{:ok, dt, _} = DateTime.from_iso8601(iso)
for {lat, lon} <- [{32.9, -97.0}, {51.5, 0.0}, {-33.9, 151.2}] do
v = Moon.radial_velocity_km_s(dt, lat, lon)
assert is_number(v)
assert abs(v) < 1.5, "radial velocity #{v} km/s out of range at #{iso} / #{lat},#{lon}"
end
end
end
test "moon climbs through local zenith roughly every 24h 50m at CONUS lat" do
# Sanity: the Moon's average transit (meridian) cadence is a
# lunar day = 24 h 50 m. Take the max elevation across two
# successive 25-hour windows at DFW and confirm both peaks
# occur — i.e. the function isn't frozen.
lat = 32.9
lon = -97.0
base = ~U[2024-06-01 00:00:00Z]
peaks =
for hour_offset <- 0..48 do
dt = DateTime.add(base, hour_offset * 3600, :second)
{dt, Moon.observer_position(dt, lat, lon).elevation}
end
els = Enum.map(peaks, &elem(&1, 1))
max_el = Enum.max(els)
min_el = Enum.min(els)
# The Moon has to reach both a positive and a negative elevation
# over 48 hours at 33°N regardless of orbital phase.
assert max_el > 10.0
assert min_el < -10.0
end
end
end

View file

@ -0,0 +1,291 @@
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

View file

@ -0,0 +1,58 @@
defmodule MicrowavepropWeb.EmeLiveTest do
use MicrowavepropWeb.ConnCase, async: true
import Phoenix.LiveViewTest
describe "GET /eme" do
test "renders the empty form when no source is supplied", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/eme")
assert html =~ "EME Calculator"
assert html =~ "Station (callsign, grid, or"
assert html =~ "Calculate"
# No aim readout yet.
refute html =~ "Antenna aim"
end
test "renders the live Moon aim + link budget when a grid source is provided", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/eme?source=EM13&band=1296&tx_power_dbm=50&tx_gain_dbi=30")
assert html =~ "Antenna aim"
assert html =~ "Link budget"
assert html =~ "EM13"
assert html =~ "Azimuth"
assert html =~ "Elevation"
assert html =~ "Slant range"
# Path loss should be in the high 200s (dB) for 1296 MHz.
assert html =~ "dB"
end
test "submitting the form patches the URL with the supplied params", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/eme")
view
|> form("form",
source: "EM13",
band: "432",
tx_power_dbm: "60",
tx_gain_dbi: "25",
bandwidth_hz: "100",
t_sys_k: "150"
)
|> render_submit()
# Flush any pending assign updates to catch redirects/patches.
html = render(view)
assert html =~ "EM13"
assert html =~ "Antenna aim"
end
test "coordinate pair 'lat,lon' resolves without touching the callsign API", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/eme?source=32.9,-97.0&band=1296")
assert html =~ "Antenna aim"
assert html =~ "32.9"
assert html =~ "-97.0"
end
end
end