357 lines
13 KiB
Go
357 lines
13 KiB
Go
|
|
package eclipse
|
||
|
|
|
||
|
|
import (
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"b612.me/astro/basic"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestLocalSolarEclipseOnDateByLocalDay(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(2024, 4, 7, 12, 0, 0, 0, loc),
|
||
|
|
want: false,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "local event day overlaps",
|
||
|
|
date: time.Date(2024, 4, 8, 12, 0, 0, 0, loc),
|
||
|
|
want: true,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "day after no eclipse",
|
||
|
|
date: time.Date(2024, 4, 9, 12, 0, 0, 0, loc),
|
||
|
|
want: false,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, tc := range testCases {
|
||
|
|
t.Run(tc.name, func(t *testing.T) {
|
||
|
|
info, ok := LocalSolarEclipseOnDate(tc.date, lon, lat, height)
|
||
|
|
if ok != tc.want {
|
||
|
|
t.Fatalf("LocalSolarEclipseOnDate(%v) got %v want %v", tc.date, ok, tc.want)
|
||
|
|
}
|
||
|
|
if !ok {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if info.Type != SolarEclipsePartial {
|
||
|
|
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, SolarEclipsePartial)
|
||
|
|
}
|
||
|
|
if info.GreatestEclipse.Location() != loc {
|
||
|
|
t.Fatalf("greatest eclipse location mismatch: got %q want %q", info.GreatestEclipse.Location(), loc)
|
||
|
|
}
|
||
|
|
if info.PartialStart.Day() != 8 || info.PartialEnd.Day() != 8 {
|
||
|
|
t.Fatalf("unexpected local date span: begin=%v end=%v", info.PartialStart, info.PartialEnd)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLocalSolarEclipseVisibilityFilter(t *testing.T) {
|
||
|
|
loc := time.FixedZone("JST", 9*3600)
|
||
|
|
date := time.Date(2024, 4, 9, 12, 0, 0, 0, loc)
|
||
|
|
lon, lat, height := 139.6917, 35.6895, 0.0
|
||
|
|
|
||
|
|
geometricInfo, geometricOK := GeometricLocalSolarEclipseOnDate(date, lon, lat, height)
|
||
|
|
if !geometricOK {
|
||
|
|
t.Fatalf("expected geometric local eclipse on date")
|
||
|
|
}
|
||
|
|
if geometricInfo.Type != SolarEclipsePartial {
|
||
|
|
t.Fatalf("unexpected geometric eclipse type: got %s want %s", geometricInfo.Type, SolarEclipsePartial)
|
||
|
|
}
|
||
|
|
if geometricInfo.VisibleAtGreatest {
|
||
|
|
t.Fatalf("expected geometric eclipse to be below horizon at greatest: %+v", geometricInfo)
|
||
|
|
}
|
||
|
|
|
||
|
|
visibleInfo, visibleOK := LocalSolarEclipseOnDate(date, lon, lat, height)
|
||
|
|
if visibleOK {
|
||
|
|
t.Fatalf("expected visible filter to reject invisible eclipse, got %+v", visibleInfo)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLocalSolarEclipseSearchSemantics(t *testing.T) {
|
||
|
|
loc := time.UTC
|
||
|
|
lon, lat, height := -0.1278, 51.5074, 0.0
|
||
|
|
current := ClosestLocalSolarEclipseNASABulletinSplitK(time.Date(2025, 3, 29, 12, 0, 0, 0, loc), lon, lat, height)
|
||
|
|
if current.Type != SolarEclipsePartial {
|
||
|
|
t.Fatalf("unexpected current eclipse type: got %s want %s", current.Type, SolarEclipsePartial)
|
||
|
|
}
|
||
|
|
|
||
|
|
assertSameLocalSolarEclipse(
|
||
|
|
t,
|
||
|
|
"ClosestLocalSolarEclipse(default)",
|
||
|
|
ClosestLocalSolarEclipse(current.GreatestEclipse, lon, lat, height),
|
||
|
|
current,
|
||
|
|
time.Second,
|
||
|
|
)
|
||
|
|
|
||
|
|
last := LastLocalSolarEclipseNASABulletinSplitK(current.GreatestEclipse, lon, lat, height)
|
||
|
|
assertSameLocalSolarEclipse(t, "LastLocalSolarEclipseNASABulletinSplitK(current.GreatestEclipse)", last, current, time.Second)
|
||
|
|
|
||
|
|
closest := ClosestLocalSolarEclipseNASABulletinSplitK(current.GreatestEclipse, lon, lat, height)
|
||
|
|
assertSameLocalSolarEclipse(t, "ClosestLocalSolarEclipseNASABulletinSplitK(current.GreatestEclipse)", closest, current, time.Second)
|
||
|
|
|
||
|
|
next := NextLocalSolarEclipseNASABulletinSplitK(current.GreatestEclipse, lon, lat, height)
|
||
|
|
if !next.GreatestEclipse.After(current.GreatestEclipse) {
|
||
|
|
t.Fatalf("NextLocalSolarEclipseNASABulletinSplitK should be strictly future: current=%v next=%v", current.GreatestEclipse, next.GreatestEclipse)
|
||
|
|
}
|
||
|
|
if next.Type != SolarEclipsePartial {
|
||
|
|
t.Fatalf("unexpected next eclipse type: got %s want %s", next.Type, SolarEclipsePartial)
|
||
|
|
}
|
||
|
|
assertSolarTimeClose(t, "NextLocalSolarEclipseNASABulletinSplitK", next.GreatestEclipse, time.Date(2026, 8, 12, 18, 13, 21, 0, time.UTC), 2*time.Minute)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLocalSolarEclipseSearchSkipsInvisibleCurrentCandidate(t *testing.T) {
|
||
|
|
loc := time.FixedZone("JST", 9*3600)
|
||
|
|
lon, lat, height := 139.6917, 35.6895, 0.0
|
||
|
|
current, ok := GeometricLocalSolarEclipseOnDate(time.Date(2024, 4, 9, 12, 0, 0, 0, loc), lon, lat, height)
|
||
|
|
if !ok {
|
||
|
|
t.Fatalf("expected geometric local eclipse on date")
|
||
|
|
}
|
||
|
|
if current.VisibleAtGreatest {
|
||
|
|
t.Fatalf("expected current geometric eclipse to be below horizon: %+v", current)
|
||
|
|
}
|
||
|
|
|
||
|
|
next := NextLocalSolarEclipseNASABulletinSplitK(current.GreatestEclipse, lon, lat, height)
|
||
|
|
if next.Type == SolarEclipseNone || next.GreatestEclipse.IsZero() {
|
||
|
|
t.Fatalf("expected search to skip the invisible current candidate and find a future visible eclipse")
|
||
|
|
}
|
||
|
|
if !next.GreatestEclipse.After(current.GreatestEclipse) {
|
||
|
|
t.Fatalf("expected strictly future local solar eclipse: current=%v next=%v", current.GreatestEclipse, next.GreatestEclipse)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLocalSolarEclipseInfoKeepsLocation(t *testing.T) {
|
||
|
|
loc := time.FixedZone("UTC+08", 8*3600)
|
||
|
|
lon, lat, height := -104.1, 25.3, 1234.0
|
||
|
|
testCases := []struct {
|
||
|
|
name string
|
||
|
|
calc func(time.Time, float64, float64, float64) LocalSolarEclipseInfo
|
||
|
|
}{
|
||
|
|
{name: "nasa", calc: ClosestLocalSolarEclipseNASABulletinSplitK},
|
||
|
|
{name: "iau", calc: ClosestLocalSolarEclipseIAUSingleK},
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, tc := range testCases {
|
||
|
|
t.Run(tc.name, func(t *testing.T) {
|
||
|
|
info := tc.calc(time.Date(2024, 4, 8, 12, 0, 0, 0, loc), lon, lat, height)
|
||
|
|
if info.Type != SolarEclipseTotal {
|
||
|
|
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, SolarEclipseTotal)
|
||
|
|
}
|
||
|
|
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: "GreatestEclipse", tm: info.GreatestEclipse},
|
||
|
|
{name: "PartialStart", tm: info.PartialStart},
|
||
|
|
{name: "PartialEnd", tm: info.PartialEnd},
|
||
|
|
{name: "CentralStart", tm: info.CentralStart},
|
||
|
|
{name: "CentralEnd", tm: info.CentralEnd},
|
||
|
|
} {
|
||
|
|
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 TestLocalSolarEclipseContactPoints(t *testing.T) {
|
||
|
|
loc := time.FixedZone("UTC+08", 8*3600)
|
||
|
|
info := ClosestLocalSolarEclipse(
|
||
|
|
time.Date(2024, 4, 8, 12, 0, 0, 0, loc),
|
||
|
|
-96.7970,
|
||
|
|
32.7767,
|
||
|
|
0,
|
||
|
|
)
|
||
|
|
if info.Type != SolarEclipseTotal {
|
||
|
|
t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, SolarEclipseTotal)
|
||
|
|
}
|
||
|
|
if got, want := len(info.ContactPoints), 4; got != want {
|
||
|
|
t.Fatalf("contact point count = %d, want %d", got, want)
|
||
|
|
}
|
||
|
|
|
||
|
|
points := make(map[string]LocalSolarEclipseContactPoint, len(info.ContactPoints))
|
||
|
|
for _, point := range info.ContactPoints {
|
||
|
|
points[point.Label] = point
|
||
|
|
}
|
||
|
|
assertSolarFloatClose(t, "C1.ContactPositionAngle", points["C1"].ContactPositionAngle, 226.219228, 1e-3)
|
||
|
|
assertSolarFloatClose(t, "C2.ContactPositionAngle", points["C2"].ContactPositionAngle, 19.137089, 1e-3)
|
||
|
|
assertSolarFloatClose(t, "C2.MoonCenterPositionAngle", points["C2"].MoonCenterPositionAngle, 199.137089, 1e-3)
|
||
|
|
assertSolarFloatClose(t, "C4.ContactClockwiseAngle", points["C4"].ContactClockwiseAngle, 310.781438, 1e-3)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLocalSolarEclipseContactPointsFromMergedFrameLabels(t *testing.T) {
|
||
|
|
frames := []basic.LocalSolarEclipseDiagramFrame{
|
||
|
|
{
|
||
|
|
JDE: 2460409.25,
|
||
|
|
SunRadius: 1,
|
||
|
|
MoonRadius: 1.05,
|
||
|
|
PositionAngle: 199.137089,
|
||
|
|
Label: "Greatest",
|
||
|
|
Labels: []string{"C2", "Greatest", "C3"},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
points := localSolarEclipseContactPointsFromFrames(frames, time.UTC)
|
||
|
|
if got, want := len(points), 2; got != want {
|
||
|
|
t.Fatalf("contact point count = %d, want %d", got, want)
|
||
|
|
}
|
||
|
|
if points[0].Label != "C2" || points[1].Label != "C3" {
|
||
|
|
t.Fatalf("labels = %#v, want [C2 C3]", []string{points[0].Label, points[1].Label})
|
||
|
|
}
|
||
|
|
assertSolarFloatClose(t, "C2.ContactPositionAngle", points[0].ContactPositionAngle, 19.137089, 1e-6)
|
||
|
|
assertSolarFloatClose(t, "C3.ContactPositionAngle", points[1].ContactPositionAngle, 19.137089, 1e-6)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLocalSolarEclipseIAUSingleKRemainsAvailable(t *testing.T) {
|
||
|
|
date := time.Date(2024, 4, 8, 12, 0, 0, 0, time.UTC)
|
||
|
|
lon, lat, height := -104.1, 25.3, 0.0
|
||
|
|
defaultInfo := ClosestLocalSolarEclipse(date, lon, lat, height)
|
||
|
|
iauInfo := ClosestLocalSolarEclipseIAUSingleK(date, lon, lat, height)
|
||
|
|
|
||
|
|
if defaultInfo.Type != SolarEclipseTotal || iauInfo.Type != SolarEclipseTotal {
|
||
|
|
t.Fatalf("unexpected eclipse types: default=%s iau=%s", defaultInfo.Type, iauInfo.Type)
|
||
|
|
}
|
||
|
|
assertSolarTimeClose(t, "GreatestEclipse", iauInfo.GreatestEclipse, defaultInfo.GreatestEclipse, time.Second)
|
||
|
|
|
||
|
|
if !(iauInfo.CentralEnd.Sub(iauInfo.CentralStart) > defaultInfo.CentralEnd.Sub(defaultInfo.CentralStart)) {
|
||
|
|
t.Fatalf("expected IAU central duration > NASA duration: iau=%v nasa=%v", iauInfo.CentralEnd.Sub(iauInfo.CentralStart), defaultInfo.CentralEnd.Sub(defaultInfo.CentralStart))
|
||
|
|
}
|
||
|
|
if !(iauInfo.Magnitude > defaultInfo.Magnitude) {
|
||
|
|
t.Fatalf("expected IAU magnitude > NASA magnitude: iau=%.9f nasa=%.9f", iauInfo.Magnitude, defaultInfo.Magnitude)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLocalSolarEclipseAgainstNASABaseline(t *testing.T) {
|
||
|
|
chicagoLoc := time.FixedZone("CDT", -5*3600)
|
||
|
|
|
||
|
|
testCases := []struct {
|
||
|
|
name string
|
||
|
|
date time.Time
|
||
|
|
lon float64
|
||
|
|
lat float64
|
||
|
|
height float64
|
||
|
|
wantType SolarEclipseType
|
||
|
|
wantGreatest time.Time
|
||
|
|
wantPartialStart time.Time
|
||
|
|
wantPartialEnd time.Time
|
||
|
|
wantMagnitude float64
|
||
|
|
wantObscuration float64
|
||
|
|
wantSunAltitude float64
|
||
|
|
wantSunAzimuth float64
|
||
|
|
wantCentralDuration time.Duration
|
||
|
|
}{
|
||
|
|
{
|
||
|
|
name: "2024-04-08 chicago partial",
|
||
|
|
date: time.Date(2024, 4, 8, 12, 0, 0, 0, chicagoLoc),
|
||
|
|
lon: -87.65,
|
||
|
|
lat: 41.85,
|
||
|
|
height: 0,
|
||
|
|
wantType: SolarEclipsePartial,
|
||
|
|
wantGreatest: time.Date(2024, 4, 8, 14, 7, 0, 0, chicagoLoc),
|
||
|
|
wantPartialStart: time.Date(2024, 4, 8, 12, 51, 0, 0, chicagoLoc),
|
||
|
|
wantPartialEnd: time.Date(2024, 4, 8, 15, 22, 0, 0, chicagoLoc),
|
||
|
|
wantMagnitude: 0.942,
|
||
|
|
wantObscuration: 0.938,
|
||
|
|
wantSunAltitude: 52,
|
||
|
|
wantSunAzimuth: 211,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "2024-04-08 greatest total",
|
||
|
|
date: time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC),
|
||
|
|
lon: -104.1,
|
||
|
|
lat: 25.3,
|
||
|
|
height: 0,
|
||
|
|
wantType: SolarEclipseTotal,
|
||
|
|
wantGreatest: time.Date(2024, 4, 8, 18, 17, 15, 0, time.UTC),
|
||
|
|
wantPartialStart: time.Date(2024, 4, 8, 16, 58, 0, 0, time.UTC),
|
||
|
|
wantPartialEnd: time.Date(2024, 4, 8, 19, 40, 0, 0, time.UTC),
|
||
|
|
wantMagnitude: 1.0566,
|
||
|
|
wantObscuration: 1.0,
|
||
|
|
wantSunAltitude: 70,
|
||
|
|
wantSunAzimuth: 150,
|
||
|
|
wantCentralDuration: 4*time.Minute + 28*time.Second,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "2024-10-02 greatest annular",
|
||
|
|
date: time.Date(2024, 10, 2, 0, 0, 0, 0, time.UTC),
|
||
|
|
lon: -114.5,
|
||
|
|
lat: -22.0,
|
||
|
|
height: 0,
|
||
|
|
wantType: SolarEclipseAnnular,
|
||
|
|
wantGreatest: time.Date(2024, 10, 2, 18, 44, 59, 0, time.UTC),
|
||
|
|
wantPartialStart: time.Date(2024, 10, 2, 17, 3, 0, 0, time.UTC),
|
||
|
|
wantPartialEnd: time.Date(2024, 10, 2, 20, 33, 0, 0, time.UTC),
|
||
|
|
wantMagnitude: 0.9326,
|
||
|
|
wantObscuration: 0.871,
|
||
|
|
wantSunAltitude: 69,
|
||
|
|
wantSunAzimuth: 31,
|
||
|
|
wantCentralDuration: 7*time.Minute + 25*time.Second,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
const (
|
||
|
|
partialTimeTolerance = 90 * time.Second
|
||
|
|
greatestTimeTolerance = 45 * time.Second
|
||
|
|
floatTolerance = 0.01
|
||
|
|
altitudeTolerance = 1.0
|
||
|
|
azimuthTolerance = 1.5
|
||
|
|
durationTolerance = 5 * time.Second
|
||
|
|
)
|
||
|
|
|
||
|
|
for _, tc := range testCases {
|
||
|
|
t.Run(tc.name, func(t *testing.T) {
|
||
|
|
info := ClosestLocalSolarEclipse(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)
|
||
|
|
}
|
||
|
|
|
||
|
|
assertSolarTimeClose(t, "GreatestEclipse", info.GreatestEclipse, tc.wantGreatest, greatestTimeTolerance)
|
||
|
|
assertSolarTimeClose(t, "PartialStart", info.PartialStart, tc.wantPartialStart, partialTimeTolerance)
|
||
|
|
assertSolarTimeClose(t, "PartialEnd", info.PartialEnd, tc.wantPartialEnd, partialTimeTolerance)
|
||
|
|
assertSolarFloatClose(t, "Magnitude", info.Magnitude, tc.wantMagnitude, floatTolerance)
|
||
|
|
assertSolarFloatClose(t, "Obscuration", info.Obscuration, tc.wantObscuration, floatTolerance)
|
||
|
|
assertSolarFloatClose(t, "SunAltitude", info.SunAltitude, tc.wantSunAltitude, altitudeTolerance)
|
||
|
|
assertSolarFloatClose(t, "SunAzimuth", info.SunAzimuth, tc.wantSunAzimuth, azimuthTolerance)
|
||
|
|
|
||
|
|
if tc.wantCentralDuration > 0 {
|
||
|
|
duration := info.CentralEnd.Sub(info.CentralStart)
|
||
|
|
assertSolarTimeClose(
|
||
|
|
t,
|
||
|
|
"CentralDuration",
|
||
|
|
time.Unix(0, int64(duration)),
|
||
|
|
time.Unix(0, int64(tc.wantCentralDuration)),
|
||
|
|
durationTolerance,
|
||
|
|
)
|
||
|
|
} else if info.HasCentral || !info.CentralStart.IsZero() || !info.CentralEnd.IsZero() {
|
||
|
|
t.Fatalf("expected no central phase, got %+v", info)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func assertSameLocalSolarEclipse(t *testing.T, name string, got, want LocalSolarEclipseInfo, tolerance time.Duration) {
|
||
|
|
t.Helper()
|
||
|
|
if got.Type != want.Type {
|
||
|
|
t.Fatalf("%s type mismatch: got %s want %s", name, got.Type, want.Type)
|
||
|
|
}
|
||
|
|
assertSolarTimeClose(t, name+".GreatestEclipse", got.GreatestEclipse, want.GreatestEclipse, tolerance)
|
||
|
|
}
|