Files
astro/moon/occultation_path_test.go
T

237 lines
10 KiB
Go
Raw Normal View History

package moon
import (
"fmt"
"math"
"testing"
"time"
)
func TestFindStarOccultationPathsHR4799(t *testing.T) {
location := time.FixedZone("CST", 8*3600)
star := StarCoordinate{
ID: "HR 4799",
RA: 189.1975,
Dec: -5.831944444444,
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
Frame: CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: -28,
ProperMotionDecMasPerYear: -18,
}
paths, err := FindStarOccultationPaths(
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
star, OccultationPathOptions{
Step: 5 * time.Minute,
TargetSpacingKM: 200,
})
if err != nil {
t.Fatalf("FindStarOccultationPaths() error = %v", err)
}
if len(paths) != 1 {
t.Fatalf("FindStarOccultationPaths() returned %d paths, want 1", len(paths))
}
path := paths[0]
t.Logf("start=%s %.6f %.6f greatest=%s %.6f %.6f width=%.3f end=%s %.6f %.6f center=%d north=%d south=%d",
path.Start.Time, path.Start.Longitude, path.Start.Latitude,
path.Greatest.Time, path.Greatest.Longitude, path.Greatest.Latitude, path.Greatest.WidthKM,
path.End.Time, path.End.Longitude, path.End.Latitude,
len(path.CenterLine), len(path.NorthernLimit), len(path.SouthernLimit))
if !path.Start.Time.Before(path.Greatest.Time) || !path.Greatest.Time.Before(path.End.Time) {
t.Fatalf("path times are not ordered: start=%v greatest=%v end=%v", path.Start.Time, path.Greatest.Time, path.End.Time)
}
if !path.Complete {
t.Fatal("global path must be marked complete")
}
if math.Abs(path.Start.Time.Sub(time.Date(2025, 6, 5, 17, 45, 26, 200000000, location)).Seconds()) > 5 {
t.Fatalf("path start is too far from reference: got %v", path.Start.Time)
}
if math.Abs(path.End.Time.Sub(time.Date(2025, 6, 5, 22, 18, 53, 100000000, location)).Seconds()) > 5 {
t.Fatalf("path end is too far from reference: got %v", path.End.Time)
}
if math.Abs(path.Start.Longitude-63.97388) > 0.5 || math.Abs(path.Start.Latitude-39.11772) > 0.5 {
t.Fatalf("path start point is too far from reference: got %.6f %.6f", path.Start.Longitude, path.Start.Latitude)
}
if math.Abs(path.End.Longitude-172.33859) > 0.5 || math.Abs(path.End.Latitude-(-16.67159)) > 0.5 {
t.Fatalf("path end point is too far from reference: got %.6f %.6f", path.End.Longitude, path.End.Latitude)
}
if math.Abs(path.Greatest.Longitude-121.55381) > 0.1 || math.Abs(path.Greatest.Latitude-6.79657) > 0.1 {
t.Fatalf("greatest point is too far from reference: got %.6f %.6f", path.Greatest.Longitude, path.Greatest.Latitude)
}
if len(path.CenterLine) == 0 || len(path.NorthernLimit) == 0 || len(path.SouthernLimit) == 0 {
t.Fatalf("path limits missing: center=%d north=%d south=%d", len(path.CenterLine), len(path.NorthernLimit), len(path.SouthernLimit))
}
if math.Abs(path.Greatest.Time.Sub(time.Date(2025, 6, 5, 20, 2, 7, 700000000, location)).Seconds()) > 5 {
t.Fatalf("greatest time is too far from reference: got %v", path.Greatest.Time)
}
if math.Abs(path.Greatest.WidthKM-3571.9) > 20 {
t.Fatalf("unexpected occultation width: got %.3f km", path.Greatest.WidthKM)
}
if len(path.NorthernLimit) != len(path.SouthernLimit) {
t.Fatalf("limit counts differ: north=%d south=%d", len(path.NorthernLimit), len(path.SouthernLimit))
}
last := len(path.NorthernLimit) - 1
if last < 1 || !path.NorthernLimit[0].Time.Equal(path.Start.Time) || !path.SouthernLimit[0].Time.Equal(path.Start.Time) ||
!path.NorthernLimit[last].Time.Equal(path.End.Time) || !path.SouthernLimit[last].Time.Equal(path.End.Time) {
t.Fatalf("limits do not cover the global start/end")
}
best, err := FindBestStarOccultations(
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
star, OccultationSearchOptions{MaxEvents: 1},
)
if err != nil || len(best) != 1 {
t.Fatalf("FindBestStarOccultations() = %d events, %v; want one event", len(best), err)
}
if math.Abs(best[0].Greatest.Sub(path.Greatest.Time).Seconds()) > 0.1 ||
math.Abs(best[0].Observer.Longitude-path.Greatest.Longitude) > 1e-6 ||
math.Abs(best[0].Observer.Latitude-path.Greatest.Latitude) > 1e-6 {
t.Fatalf("best observer does not match global greatest: best=%v %+v path=%v %.9f %.9f",
best[0].Greatest, best[0].Observer, path.Greatest.Time, path.Greatest.Longitude, path.Greatest.Latitude)
}
}
func TestFindStarOccultationPathsHR4799LimitsRemainContinuous(t *testing.T) {
location := time.FixedZone("CST", 8*3600)
paths, err := FindStarOccultationPaths(
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
hr4799CoordinateForTest(),
OccultationPathOptions{Step: 30 * time.Second},
)
if err != nil {
t.Fatalf("FindStarOccultationPaths() error = %v", err)
}
if len(paths) != 1 {
t.Fatalf("FindStarOccultationPaths() returned %d paths, want 1", len(paths))
}
for _, limit := range []struct {
name string
points []OccultationPathPoint
}{
{name: "northern", points: paths[0].NorthernLimit},
{name: "southern", points: paths[0].SouthernLimit},
} {
for i := 1; i < len(limit.points); i++ {
distance := occultationPathPointDistanceKM(limit.points[i-1], limit.points[i])
if distance > 750 {
t.Fatalf("%s limit jumps %.1f km between %v and %v", limit.name, distance,
limit.points[i-1].Time, limit.points[i].Time)
}
}
for i := 1; i+1 < len(limit.points); i++ {
detour := occultationPathPointDistanceKM(limit.points[i-1], limit.points[i]) +
occultationPathPointDistanceKM(limit.points[i], limit.points[i+1]) -
occultationPathPointDistanceKM(limit.points[i-1], limit.points[i+1])
if detour > 100 {
t.Fatalf("%s limit has a %.1f km three-point detour at %v", limit.name, detour, limit.points[i].Time)
}
}
}
}
func occultationPathPointDistanceKM(a, b OccultationPathPoint) float64 {
lat1 := a.Latitude * math.Pi / 180
lat2 := b.Latitude * math.Pi / 180
dLat := lat2 - lat1
dLon := (b.Longitude - a.Longitude) * math.Pi / 180
dLon = math.Atan2(math.Sin(dLon), math.Cos(dLon))
h := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1)*math.Cos(lat2)*math.Sin(dLon/2)*math.Sin(dLon/2)
return 2 * 6378.1366 * math.Asin(math.Sqrt(math.Min(1, h)))
}
func TestFindStarOccultationPathsReturnsFullPathOutsideQueryWindow(t *testing.T) {
location := time.FixedZone("CST", 8*3600)
greatest := time.Date(2025, 6, 5, 20, 2, 7, 700000000, location)
start := greatest.Add(-time.Minute)
end := greatest.Add(time.Minute)
paths, err := FindStarOccultationPaths(start, end, hr4799CoordinateForTest(), OccultationPathOptions{Step: 5 * time.Minute})
if err != nil {
t.Fatalf("FindStarOccultationPaths() error = %v", err)
}
if len(paths) != 1 {
t.Fatalf("FindStarOccultationPaths() returned %d paths, want 1", len(paths))
}
path := paths[0]
if !path.Complete || !path.Start.Time.Before(start) || !path.End.Time.After(end) {
t.Fatalf("path was clipped to query window: complete=%v start=%v window=%v..%v end=%v", path.Complete, path.Start.Time, start, end, path.End.Time)
}
}
func TestGlobalStarOccultationQueriesSelectByGeometricGreatest(t *testing.T) {
star := hr4799CoordinateForTest()
dayStart := time.Date(2025, 6, 5, 0, 0, 0, 0, time.UTC)
paths, err := FindStarOccultationPaths(
dayStart, dayStart.Add(24*time.Hour), star,
OccultationPathOptions{Step: 5 * time.Minute},
)
if err != nil || len(paths) != 1 {
t.Fatalf("full-day FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
start := paths[0].Greatest.Time.Add(-500 * time.Microsecond)
end := start.Add(time.Millisecond)
narrowPaths, err := FindStarOccultationPaths(start, end, star, OccultationPathOptions{Step: 5 * time.Minute})
if err != nil || len(narrowPaths) != 1 {
t.Fatalf("narrow FindStarOccultationPaths() paths=%d err=%v, want one", len(narrowPaths), err)
}
if !occultationTimeWithinNumericalWindowForTest(narrowPaths[0].Greatest.Time, start, end) {
t.Fatalf("path greatest %v is outside query window %v..%v", narrowPaths[0].Greatest.Time, start, end)
}
best, err := FindBestStarOccultations(start, end, star, OccultationSearchOptions{})
if err != nil || len(best) != 1 {
t.Fatalf("narrow FindBestStarOccultations() events=%d err=%v, want one", len(best), err)
}
if !occultationTimeWithinNumericalWindowForTest(best[0].Greatest, start, end) {
t.Fatalf("best greatest %v is outside query window %v..%v", best[0].Greatest, start, end)
}
}
func occultationTimeWithinNumericalWindowForTest(value, start, end time.Time) bool {
const tolerance = 10 * time.Millisecond
return !value.Before(start.Add(-tolerance)) && !value.After(end.Add(tolerance))
}
func TestOccultationPathOptionsValidate(t *testing.T) {
if err := (OccultationPathOptions{Step: -time.Second}).Validate(); err == nil {
t.Fatal("negative path step should be rejected")
}
if err := (OccultationPathOptions{Step: 500 * time.Millisecond}).Validate(); err == nil {
t.Fatal("subsecond positive path step should be rejected")
}
if err := (OccultationPathOptions{Step: time.Second}).Validate(); err != nil {
t.Fatalf("one-second path step rejected: %v", err)
}
if err := (OccultationPathOptions{TargetSpacingKM: math.NaN()}).Validate(); err == nil {
t.Fatal("non-finite target spacing should be rejected")
}
if err := (OccultationPathOptions{TargetSpacingKM: 0.5}).Validate(); err == nil {
t.Fatal("sub-kilometer target spacing should be rejected")
}
if err := (OccultationPathOptions{TargetSpacingKM: 1}).Validate(); err != nil {
t.Fatalf("one-kilometer target spacing rejected: %v", err)
}
}
func BenchmarkFindStarOccultationPathTargetSpacing(b *testing.B) {
location := time.FixedZone("CST", 8*3600)
star := hr4799CoordinateForTest()
for _, spacing := range []float64{1, 10, 200} {
b.Run(fmt.Sprintf("%.0fkm", spacing), func(b *testing.B) {
for i := 0; i < b.N; i++ {
paths, err := FindStarOccultationPaths(
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
star, OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: spacing},
)
if err != nil || len(paths) != 1 {
b.Fatalf("FindStarOccultationPaths() paths=%d err=%v", len(paths), err)
}
}
})
}
}