prop/lib/microwaveprop/moon.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

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