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.
195 lines
7.1 KiB
Elixir
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
|