astro/eclipse/lunar_test.go

346 lines
13 KiB
Go
Raw Normal View History

package eclipse
import (
"testing"
"time"
)
func TestLunarEclipseLocalDayBoundsRespectDST(t *testing.T) {
loc, err := time.LoadLocation("America/New_York")
if err != nil {
t.Skipf("tzdata unavailable: %v", err)
}
testCases := []struct {
name string
date time.Time
wantDuration time.Duration
}{
{
name: "spring forward 2025-03-09",
date: time.Date(2025, 3, 9, 8, 0, 0, 0, loc),
wantDuration: 23 * time.Hour,
},
{
name: "fall back 2025-11-02",
date: time.Date(2025, 11, 2, 8, 0, 0, 0, loc),
wantDuration: 25 * time.Hour,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
dayStart, dayMid, dayEnd := lunarEclipseLocalDayBounds(tc.date)
if dayStart.Hour() != 0 || dayStart.Minute() != 0 || dayStart.Second() != 0 {
t.Fatalf("dayStart should be local midnight, got %v", dayStart)
}
if dayMid.Hour() != 12 || dayMid.Minute() != 0 || dayMid.Second() != 0 {
t.Fatalf("dayMid should be local noon, got %v", dayMid)
}
if dayEnd.Hour() != 0 || dayEnd.Minute() != 0 || dayEnd.Second() != 0 {
t.Fatalf("dayEnd should be next local midnight, got %v", dayEnd)
}
if got := dayEnd.Sub(dayStart); got != tc.wantDuration {
t.Fatalf("day length mismatch: got %v want %v", got, tc.wantDuration)
}
})
}
}
func TestLunarEclipseOnDateByLocalDay(t *testing.T) {
loc := time.FixedZone("UTC-05", -5*3600)
testCases := []struct {
name string
date time.Time
want bool
}{
{
name: "day before no eclipse",
date: time.Date(2025, 3, 12, 12, 0, 0, 0, loc),
want: false,
},
{
name: "local start day overlaps",
date: time.Date(2025, 3, 13, 12, 0, 0, 0, loc),
want: true,
},
{
name: "local end day overlaps",
date: time.Date(2025, 3, 14, 12, 0, 0, 0, loc),
want: true,
},
{
name: "day after no eclipse",
date: time.Date(2025, 3, 15, 12, 0, 0, 0, loc),
want: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
info, ok := LunarEclipseOnDate(tc.date)
if ok != tc.want {
t.Fatalf("LunarEclipseOnDate(%v) got %v want %v", tc.date, ok, tc.want)
}
if !ok {
return
}
if info.Type != LunarEclipseTotal {
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, LunarEclipseTotal)
}
if info.Maximum.Location() != loc {
t.Fatalf("maximum location mismatch: got %q want %q", info.Maximum.Location(), loc)
}
if info.PenumbralStart.Day() != 13 || info.PenumbralEnd.Day() != 14 {
t.Fatalf("unexpected local date span: start=%v end=%v", info.PenumbralStart, info.PenumbralEnd)
}
})
}
}
func TestLunarEclipseSearchSemantics(t *testing.T) {
loc := time.FixedZone("CST", 8*3600)
current := ClosestLunarEclipseDanjon(time.Date(2025, 3, 14, 12, 0, 0, 0, loc))
if current.Type != LunarEclipseTotal {
t.Fatalf("unexpected current eclipse type: got %s want %s", current.Type, LunarEclipseTotal)
}
assertSameEclipse(t, "ClosestLunarEclipse(default)", ClosestLunarEclipse(current.Maximum), current, time.Second)
last := LastLunarEclipseDanjon(current.Maximum)
assertSameEclipse(t, "LastLunarEclipseDanjon(current.Maximum)", last, current, time.Second)
closest := ClosestLunarEclipseDanjon(current.Maximum)
assertSameEclipse(t, "ClosestLunarEclipseDanjon(current.Maximum)", closest, current, time.Second)
next := NextLunarEclipseDanjon(current.Maximum)
if !next.Maximum.After(current.Maximum) {
t.Fatalf("NextLunarEclipseDanjon should be strictly future: current=%v next=%v", current.Maximum, next.Maximum)
}
if next.Type != LunarEclipseTotal {
t.Fatalf("unexpected next eclipse type: got %s want %s", next.Type, LunarEclipseTotal)
}
wantNextMax := time.Date(2025, 9, 8, 2, 12, 58, 0, loc)
assertTimeClose(t, "next.Maximum", next.Maximum, wantNextMax, 2*time.Minute)
}
func TestLunarEclipseInfoKeepsLocation(t *testing.T) {
loc := time.FixedZone("UTC+08", 8*3600)
testCases := []struct {
name string
calc func(time.Time) LunarEclipseInfo
}{
{name: "danjon", calc: ClosestLunarEclipseDanjon},
{name: "chauvenet", calc: ClosestLunarEclipseChauvenet},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
info := tc.calc(time.Date(2023, 10, 29, 12, 0, 0, 0, loc))
if info.Type != LunarEclipsePartial {
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, LunarEclipsePartial)
}
for _, item := range []struct {
name string
tm time.Time
}{
{name: "PenumbralStart", tm: info.PenumbralStart},
{name: "PartialStart", tm: info.PartialStart},
{name: "Maximum", tm: info.Maximum},
{name: "PartialEnd", tm: info.PartialEnd},
{name: "PenumbralEnd", tm: info.PenumbralEnd},
} {
if item.tm.Location() != loc {
t.Fatalf("%s location mismatch: got %q want %q", item.name, item.tm.Location(), loc)
}
}
for _, point := range info.ContactPoints {
if point.Time.Location() != loc {
t.Fatalf("contact %s location mismatch: got %q want %q", point.Label, point.Time.Location(), loc)
}
}
})
}
}
func TestLunarEclipseContactPoints(t *testing.T) {
loc := time.FixedZone("UTC+08", 8*3600)
info := ClosestLunarEclipse(time.Date(2026, 3, 3, 12, 0, 0, 0, loc))
if info.Type != LunarEclipseTotal {
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, LunarEclipseTotal)
}
if got, want := len(info.ContactPoints), 6; got != want {
t.Fatalf("contact point count = %d, want %d", got, want)
}
points := make(map[string]LunarEclipseContactPoint, len(info.ContactPoints))
for _, point := range info.ContactPoints {
points[point.Label] = point
}
u1 := points["U1"]
assertFloatClose(t, "U1.ContactPositionAngle", u1.ContactPositionAngle, 96.181711, 1e-3)
assertFloatClose(t, "U1.ContactClockwiseAngle", u1.ContactClockwiseAngle, 263.818289, 1e-3)
u2 := points["U2"]
assertFloatClose(t, "U2.ContactPositionAngle", u2.ContactPositionAngle, 243.025171, 1e-3)
assertFloatClose(t, "U2.MoonCenterPositionAngle", u2.MoonCenterPositionAngle, 243.025171, 1e-3)
}
func TestLunarEclipseChauvenetRemainsAvailable(t *testing.T) {
date := time.Date(2025, 3, 14, 12, 0, 0, 0, time.UTC)
defaultInfo := ClosestLunarEclipse(date)
chauvenetInfo := ClosestLunarEclipseChauvenet(date)
assertFloatClose(t, "Chauvenet.PenumbralMagnitude", chauvenetInfo.PenumbralMagnitude, 2.285431290, 1e-6)
assertFloatClose(t, "Chauvenet.UmbralMagnitude", chauvenetInfo.UmbralMagnitude, 1.182811712, 1e-6)
if !(chauvenetInfo.PenumbralMagnitude > defaultInfo.PenumbralMagnitude) {
t.Fatalf("expected Chauvenet penumbral magnitude > Danjon: chauvenet=%.6f danjon=%.6f", chauvenetInfo.PenumbralMagnitude, defaultInfo.PenumbralMagnitude)
}
if !(chauvenetInfo.PenumbralStart.Before(defaultInfo.PenumbralStart) && chauvenetInfo.PenumbralEnd.After(defaultInfo.PenumbralEnd)) {
t.Fatalf("expected Chauvenet penumbral span to be wider: chauvenet=(%v,%v) danjon=(%v,%v)", chauvenetInfo.PenumbralStart, chauvenetInfo.PenumbralEnd, defaultInfo.PenumbralStart, defaultInfo.PenumbralEnd)
}
}
func TestLunarEclipseDefaultFallsBackForUltraShallowPenumbralEdge(t *testing.T) {
date := time.Date(-780, 12, 13, 12, 0, 0, 0, time.UTC)
defaultInfo := ClosestLunarEclipse(date)
danjonInfo := ClosestLunarEclipseDanjon(date)
chauvenetInfo := ClosestLunarEclipseChauvenet(date)
if defaultInfo.Type != LunarEclipsePenumbral {
t.Fatalf("default type mismatch: got %s want %s", defaultInfo.Type, LunarEclipsePenumbral)
}
if !defaultInfo.HasSaros || defaultInfo.Saros.Series != 61 || defaultInfo.Saros.Member != 1 || defaultInfo.Saros.Count != 78 {
t.Fatalf("default shallow Saros mismatch: got has=%v saros=%+v", defaultInfo.HasSaros, defaultInfo.Saros)
}
if danjonInfo.Maximum.Equal(defaultInfo.Maximum) {
t.Fatalf("default fallback should differ from explicit Danjon in this edge case: default=%v danjon=%v", defaultInfo.Maximum, danjonInfo.Maximum)
}
if !defaultInfo.Maximum.Equal(chauvenetInfo.Maximum) {
t.Fatalf("default fallback should reuse Chauvenet edge event timing: default=%v chauvenet=%v", defaultInfo.Maximum, chauvenetInfo.Maximum)
}
if !(defaultInfo.PenumbralMagnitude > 0 && defaultInfo.PenumbralMagnitude <= lunarEclipseDefaultFallbackMaxPenumbralMagnitude) {
t.Fatalf("default fallback penumbral magnitude out of narrow edge range: %.9f", defaultInfo.PenumbralMagnitude)
}
}
func TestLunarEclipseAgainstNASABaseline(t *testing.T) {
// NASA GSFC lunar eclipse catalog / plot pages:
// - 2023 Oct 28 partial: LE2023Oct28P.pdf
// - 2025 Mar 14 total: LE2025Mar14T.pdf
testCases := []struct {
name string
date time.Time
wantType LunarEclipseType
wantPenumbralMag float64
wantUmbralMag float64
wantPenumbralStart time.Time
wantPartialStart time.Time
wantTotalStart time.Time
wantMaximum time.Time
wantTotalEnd time.Time
wantPartialEnd time.Time
wantPenumbralEnd time.Time
}{
{
name: "2023-10-28 partial",
date: time.Date(2023, 10, 28, 0, 0, 0, 0, time.UTC),
wantType: LunarEclipsePartial,
wantPenumbralMag: 1.1181,
wantUmbralMag: 0.122,
wantPenumbralStart: time.Date(2023, 10, 28, 18, 1, 43, 0, time.UTC),
wantPartialStart: time.Date(2023, 10, 28, 19, 35, 18, 0, time.UTC),
wantMaximum: time.Date(2023, 10, 28, 20, 14, 6, 0, time.UTC),
wantPartialEnd: time.Date(2023, 10, 28, 20, 52, 53, 0, time.UTC),
wantPenumbralEnd: time.Date(2023, 10, 28, 22, 26, 19, 0, time.UTC),
},
{
name: "2025-03-14 total",
date: time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
wantType: LunarEclipseTotal,
wantPenumbralMag: 2.2595,
wantUmbralMag: 1.1784,
wantPenumbralStart: time.Date(2025, 3, 14, 3, 57, 28, 0, time.UTC),
wantPartialStart: time.Date(2025, 3, 14, 5, 9, 40, 0, time.UTC),
wantTotalStart: time.Date(2025, 3, 14, 6, 26, 6, 0, time.UTC),
wantMaximum: time.Date(2025, 3, 14, 6, 58, 41, 0, time.UTC),
wantTotalEnd: time.Date(2025, 3, 14, 7, 31, 26, 0, time.UTC),
wantPartialEnd: time.Date(2025, 3, 14, 8, 47, 56, 0, time.UTC),
wantPenumbralEnd: time.Date(2025, 3, 14, 10, 0, 9, 0, time.UTC),
},
}
const timeTolerance = 2 * time.Minute
const umbralMagnitudeTolerance = 0.02
const penumbralMagnitudeTolerance = 0.1
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assertSameEclipse(t, "ClosestLunarEclipse(default)", ClosestLunarEclipse(tc.date), ClosestLunarEclipseDanjon(tc.date), time.Second)
info := ClosestLunarEclipse(tc.date)
if info.Type != tc.wantType {
t.Fatalf("type mismatch: got %s want %s", info.Type, tc.wantType)
}
assertFloatClose(t, "PenumbralMagnitude", info.PenumbralMagnitude, tc.wantPenumbralMag, penumbralMagnitudeTolerance)
assertFloatClose(t, "UmbralMagnitude", info.UmbralMagnitude, tc.wantUmbralMag, umbralMagnitudeTolerance)
assertTimeClose(t, "PenumbralStart", info.PenumbralStart, tc.wantPenumbralStart, timeTolerance)
assertTimeClose(t, "PartialStart", info.PartialStart, tc.wantPartialStart, timeTolerance)
assertTimeClose(t, "TotalStart", info.TotalStart, tc.wantTotalStart, timeTolerance)
assertTimeClose(t, "Maximum", info.Maximum, tc.wantMaximum, timeTolerance)
assertTimeClose(t, "TotalEnd", info.TotalEnd, tc.wantTotalEnd, timeTolerance)
assertTimeClose(t, "PartialEnd", info.PartialEnd, tc.wantPartialEnd, timeTolerance)
assertTimeClose(t, "PenumbralEnd", info.PenumbralEnd, tc.wantPenumbralEnd, timeTolerance)
})
}
}
func TestPenumbralLunarEclipseKeepsNegativeUmbralMagnitude(t *testing.T) {
info := ClosestLunarEclipse(time.Date(2024, 3, 25, 0, 0, 0, 0, time.UTC))
if info.Type != LunarEclipsePenumbral {
t.Fatalf("type mismatch: got %s want %s", info.Type, LunarEclipsePenumbral)
}
if !(info.UmbralMagnitude < 0) {
t.Fatalf("expected negative umbral magnitude for penumbral eclipse, got %.12f", info.UmbralMagnitude)
}
if !(info.PenumbralMagnitude > 0) {
t.Fatalf("expected positive penumbral magnitude, got %.12f", info.PenumbralMagnitude)
}
}
func assertSameEclipse(t *testing.T, name string, got, want LunarEclipseInfo, tolerance time.Duration) {
t.Helper()
if got.Type != want.Type {
t.Fatalf("%s type mismatch: got %s want %s", name, got.Type, want.Type)
}
assertTimeClose(t, name+".Maximum", got.Maximum, want.Maximum, tolerance)
}
func assertTimeClose(t *testing.T, name string, got, want time.Time, tolerance time.Duration) {
t.Helper()
diff := got.Sub(want)
if diff < 0 {
diff = -diff
}
if diff > tolerance {
t.Fatalf("%s mismatch: got %v want %v diff=%v", name, got, want, diff)
}
}
func assertFloatClose(t *testing.T, name string, got, want, tolerance float64) {
t.Helper()
diff := got - want
if diff < 0 {
diff = -diff
}
if diff > tolerance {
t.Fatalf("%s mismatch: got %.6f want %.6f diff=%.6f", name, got, want, diff)
}
}