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