prop/test/microwaveprop/moon_test.exs
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

195 lines
7.1 KiB
Elixir

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