astro/eclipse/lunar_local_test.go

351 lines
14 KiB
Go
Raw Normal View History

package eclipse
import (
"testing"
"time"
)
func TestLocalLunarEclipseOnDateByLocalDay(t *testing.T) {
loc := time.FixedZone("CDT", -5*3600)
lon, lat, height := -87.65, 41.85, 0.0
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 := LocalLunarEclipseOnDate(tc.date, lon, lat, height)
if ok != tc.want {
t.Fatalf("LocalLunarEclipseOnDate(%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 TestLocalLunarEclipseVisibilityFilter(t *testing.T) {
chicagoLoc := time.FixedZone("CDT", -5*3600)
chicagoDate := time.Date(2023, 10, 28, 12, 0, 0, 0, chicagoLoc)
chicagoLon, chicagoLat, chicagoHeight := -87.65, 41.85, 0.0
geometricInfo, geometricOK := GeometricLocalLunarEclipseOnDate(chicagoDate, chicagoLon, chicagoLat, chicagoHeight)
if !geometricOK {
t.Fatalf("expected geometric local eclipse on date")
}
if geometricInfo.Type != LunarEclipsePartial {
t.Fatalf("unexpected geometric eclipse type: got %s want %s", geometricInfo.Type, LunarEclipsePartial)
}
if geometricInfo.VisibleAtMaximum {
t.Fatalf("expected geometric eclipse to be below horizon at maximum: %+v", geometricInfo)
}
visibleInfo, visibleOK := LocalLunarEclipseOnDate(chicagoDate, chicagoLon, chicagoLat, chicagoHeight)
if visibleOK {
t.Fatalf("expected visible filter to reject invisible eclipse, got %+v", visibleInfo)
}
londonDate := time.Date(2025, 3, 14, 12, 0, 0, 0, time.UTC)
londonInfo, londonOK := LocalLunarEclipseOnDate(londonDate, -0.1278, 51.5074, 0)
if !londonOK {
t.Fatalf("expected visible local eclipse in London")
}
if londonInfo.VisibleAtMaximum {
t.Fatalf("expected London maximum to be below horizon, got %+v", londonInfo)
}
}
func TestLocalLunarEclipseSearchSemantics(t *testing.T) {
loc := time.UTC
lon, lat, height := -0.1278, 51.5074, 0.0
current := ClosestLocalLunarEclipseDanjon(time.Date(2025, 3, 14, 12, 0, 0, 0, loc), lon, lat, height)
if current.Type != LunarEclipseTotal {
t.Fatalf("unexpected current eclipse type: got %s want %s", current.Type, LunarEclipseTotal)
}
assertSameLocalLunarEclipse(t, "ClosestLocalLunarEclipse(default)", ClosestLocalLunarEclipse(current.Maximum, lon, lat, height), current, time.Second)
last := LastLocalLunarEclipseDanjon(current.Maximum, lon, lat, height)
assertSameLocalLunarEclipse(t, "LastLocalLunarEclipseDanjon(current.Maximum)", last, current, time.Second)
closest := ClosestLocalLunarEclipseDanjon(current.Maximum, lon, lat, height)
assertSameLocalLunarEclipse(t, "ClosestLocalLunarEclipseDanjon(current.Maximum)", closest, current, time.Second)
next := NextLocalLunarEclipseDanjon(current.Maximum, lon, lat, height)
if !next.Maximum.After(current.Maximum) {
t.Fatalf("NextLocalLunarEclipseDanjon 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, 7, 18, 11, 49, 0, loc)
assertTimeClose(t, "next.Maximum", next.Maximum, wantNextMax, 2*time.Minute)
}
func TestLocalLunarEclipseSearchBeyondFiveYears(t *testing.T) {
loc := time.FixedZone("CDT", -5*3600)
lon, lat, height := -87.65, 41.85, 0.0
current := ClosestLocalLunarEclipseDanjon(time.Date(2025, 3, 14, 12, 0, 0, 0, loc), lon, lat, height)
if current.Type != LunarEclipseTotal {
t.Fatalf("unexpected current eclipse type: got %s want %s", current.Type, LunarEclipseTotal)
}
next := NextLocalLunarEclipseDanjon(current.Maximum, lon, lat, height)
if next.Type == LunarEclipseNone || next.Maximum.IsZero() {
t.Fatalf("expected a future visible local lunar eclipse beyond the old 60-lunation window")
}
if !next.Maximum.After(current.Maximum) {
t.Fatalf("expected strictly future local lunar eclipse: current=%v next=%v", current.Maximum, next.Maximum)
}
}
func TestLocalTotalLunarEclipseSearch(t *testing.T) {
loc := time.FixedZone("CDT", -5*3600)
lon, lat, height := -87.65, 41.85, 0.0
date := time.Date(2025, 3, 13, 0, 0, 0, 0, loc)
next, ok := NextLocalTotalLunarEclipse(date, lon, lat, height)
if !ok {
t.Fatal("expected to find next local total lunar eclipse")
}
if next.Type != LunarEclipseTotal || !next.HasTotal {
t.Fatalf("unexpected next total lunar eclipse: %+v", next)
}
assertTimeClose(t, "NextLocalTotalLunarEclipse", next.Maximum, time.Date(2025, 3, 14, 1, 58, 47, 0, loc), 2*time.Minute)
last, ok := LastLocalTotalLunarEclipse(next.Maximum, lon, lat, height)
if !ok {
t.Fatal("expected to find previous local total lunar eclipse")
}
if last.Type != LunarEclipseTotal || !last.HasTotal {
t.Fatalf("unexpected last total lunar eclipse: %+v", last)
}
assertTimeClose(t, "LastLocalTotalLunarEclipse", last.Maximum, next.Maximum, time.Second)
}
func TestLocalTotalLunarEclipseClosest(t *testing.T) {
loc := time.FixedZone("CDT", -5*3600)
lon, lat, height := -87.65, 41.85, 0.0
date := time.Date(2025, 3, 14, 0, 0, 0, 0, loc)
info, ok := ClosestLocalTotalLunarEclipse(date, lon, lat, height)
if !ok {
t.Fatal("expected to find closest local total lunar eclipse")
}
if info.Type != LunarEclipseTotal || !info.HasTotal {
t.Fatalf("unexpected closest total lunar eclipse: %+v", info)
}
assertTimeClose(t, "ClosestLocalTotalLunarEclipse", info.Maximum, time.Date(2025, 3, 14, 1, 58, 47, 0, loc), 2*time.Minute)
}
func TestLocalTotalLunarEclipseVisibleRequiresTotalPhaseVisibility(t *testing.T) {
info, ok := LocalLunarEclipseOnDate(time.Date(2025, 3, 14, 12, 0, 0, 0, time.UTC), -0.1278, 51.5074, 0)
if !ok {
t.Fatalf("expected visible local eclipse in London")
}
if info.Type != LunarEclipseTotal || !info.HasTotal {
t.Fatalf("unexpected eclipse type: %+v", info)
}
if !localLunarEclipseVisible(info) {
t.Fatalf("expected some phase to be visible")
}
if localTotalLunarEclipseVisible(info) {
t.Fatalf("expected total phase below horizon to be rejected")
}
}
func TestLocalLunarEclipseInfoKeepsLocation(t *testing.T) {
loc := time.FixedZone("JST", 9*3600)
lon, lat, height := 139.6917, 35.6895, 1234.0
testCases := []struct {
name string
calc func(time.Time, float64, float64, float64) LocalLunarEclipseInfo
}{
{name: "danjon", calc: ClosestLocalLunarEclipseDanjon},
{name: "chauvenet", calc: ClosestLocalLunarEclipseChauvenet},
}
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), lon, lat, height)
if info.Type != LunarEclipsePartial {
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, LunarEclipsePartial)
}
if info.Longitude != lon || info.Latitude != lat || info.Height != height {
t.Fatalf("observer metadata mismatch: got (%f,%f,%f)", info.Longitude, info.Latitude, info.Height)
}
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)
}
}
})
}
}
func TestLocalLunarEclipseChauvenetRemainsAvailable(t *testing.T) {
date := time.Date(2025, 3, 14, 12, 0, 0, 0, time.FixedZone("CDT", -5*3600))
lon, lat, height := -87.65, 41.85, 0.0
defaultInfo := ClosestLocalLunarEclipse(date, lon, lat, height)
chauvenetInfo := ClosestLocalLunarEclipseChauvenet(date, lon, lat, height)
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 TestLocalLunarEclipseAgainstNASABaseline(t *testing.T) {
testCases := []struct {
name string
date time.Time
lon float64
lat float64
height float64
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
wantMoonAltitude float64
}{
{
name: "2023-10-29 tokyo partial",
date: time.Date(2023, 10, 29, 12, 0, 0, 0, time.FixedZone("JST", 9*3600)),
lon: 139.6917,
lat: 35.6895,
height: 0,
wantType: LunarEclipsePartial,
wantPenumbralMag: 1.1181,
wantUmbralMag: 0.122,
wantPenumbralStart: time.Date(2023, 10, 29, 03, 01, 43, 0, time.FixedZone("JST", 9*3600)),
wantPartialStart: time.Date(2023, 10, 29, 04, 35, 18, 0, time.FixedZone("JST", 9*3600)),
wantMaximum: time.Date(2023, 10, 29, 05, 14, 06, 0, time.FixedZone("JST", 9*3600)),
wantPartialEnd: time.Date(2023, 10, 29, 05, 52, 53, 0, time.FixedZone("JST", 9*3600)),
wantPenumbralEnd: time.Date(2023, 10, 29, 07, 26, 19, 0, time.FixedZone("JST", 9*3600)),
wantMoonAltitude: 9.1,
},
{
name: "2025-03-14 chicago total",
date: time.Date(2025, 3, 14, 12, 0, 0, 0, time.FixedZone("CDT", -5*3600)),
lon: -87.65,
lat: 41.85,
height: 0,
wantType: LunarEclipseTotal,
wantPenumbralMag: 2.2595,
wantUmbralMag: 1.1784,
wantPenumbralStart: time.Date(2025, 3, 13, 22, 57, 28, 0, time.FixedZone("CDT", -5*3600)),
wantPartialStart: time.Date(2025, 3, 14, 0, 9, 40, 0, time.FixedZone("CDT", -5*3600)),
wantTotalStart: time.Date(2025, 3, 14, 1, 26, 6, 0, time.FixedZone("CDT", -5*3600)),
wantMaximum: time.Date(2025, 3, 14, 1, 58, 41, 0, time.FixedZone("CDT", -5*3600)),
wantTotalEnd: time.Date(2025, 3, 14, 2, 31, 26, 0, time.FixedZone("CDT", -5*3600)),
wantPartialEnd: time.Date(2025, 3, 14, 3, 47, 56, 0, time.FixedZone("CDT", -5*3600)),
wantPenumbralEnd: time.Date(2025, 3, 14, 5, 0, 9, 0, time.FixedZone("CDT", -5*3600)),
wantMoonAltitude: 48.2,
},
}
const timeTolerance = 2 * time.Minute
const umbralMagnitudeTolerance = 0.02
const penumbralMagnitudeTolerance = 0.1
const altitudeTolerance = 1.5
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
info := ClosestLocalLunarEclipse(tc.date, tc.lon, tc.lat, tc.height)
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)
assertFloatClose(t, "MoonAltitude", info.MoonAltitude, tc.wantMoonAltitude, altitudeTolerance)
})
}
}
func TestLocalPenumbralLunarEclipseKeepsNegativeUmbralMagnitude(t *testing.T) {
cdt := time.FixedZone("CDT", -5*3600)
info := ClosestLocalLunarEclipse(time.Date(2024, 3, 25, 2, 0, 0, 0, cdt), -87.65, 41.85, 0)
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 assertSameLocalLunarEclipse(t *testing.T, name string, got, want LocalLunarEclipseInfo, 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)
}