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.
193 lines
6.1 KiB
Elixir
193 lines
6.1 KiB
Elixir
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
|