Files
astro/basic/occultation_planet_footprint_test.go
T

333 lines
11 KiB
Go
Raw Normal View History

package basic
import (
"errors"
"math"
"testing"
"time"
)
func TestPlanetOccultationHorizonArcStaysBetweenContacts(t *testing.T) {
config, _ := planetOccultationConfigFor(OccultationSaturn)
tt := occultationTimeToTT(time.Date(2025, 1, 5, 17, 0, 0, 0, time.UTC))
frame, ok := planetOccultationPathFrameAt(tt, config)
if !ok {
t.Fatal("missing frame")
}
fine := planetOccultationHorizonCircle(tt, frame, time.UTC, 3600)
for _, tc := range []struct {
from, to int
want []int
}{
{20, 180, []int{100, 180}},
{180, 20, []int{100, 20}},
{3590, 130, []int{0, 100, 130}},
{130, 3590, []int{100, 0, 3590}},
{1790, 1930, []int{1800, 1900, 1930}},
{1930, 1790, []int{1900, 1800, 1790}},
{20, 30, []int{25, 30}},
{30, 20, []int{25, 20}},
} {
arc := planetOccultationHorizonArc(tt, frame, []OccultationPathPoint{fine[tc.to], fine[tc.from]}, time.UTC, 36)
if len(arc) != len(tc.want) {
t.Errorf("arc %d -> %d has %d points, want %d", tc.from, tc.to, len(arc), len(tc.want))
continue
}
for i, index := range tc.want {
if distance := occultationPathDistanceKM(arc[i], fine[index]); distance > 1e-4 {
t.Errorf("arc %d -> %d point %d misses circle sample %d by %.6f km", tc.from, tc.to, i, index, distance)
}
}
}
}
func TestPlanetOccultationSaturnFootprintsContainCenterLine(t *testing.T) {
config, ok := planetOccultationConfigFor(OccultationSaturn)
if !ok {
t.Fatal("Saturn occultation config is unavailable")
}
seedTT := occultationTimeToTT(time.Date(2025, time.February, 1, 4, 0, 48, 0, time.UTC))
for _, contact := range []struct {
name string
frameAt occultationPathFrameFunc
}{
{name: "partial", frameAt: func(tt float64) (occultationPathFrame, bool) {
return planetOccultationPathFrameAt(tt, config)
}},
{name: "total", frameAt: func(tt float64) (occultationPathFrame, bool) {
return planetOccultationTotalPathFrameAt(tt, config)
}},
} {
startTT, endTT, found := occultationPathWindowForFrame(
seedTT, seedTT-occultationPathSearchSpanDays, seedTT+occultationPathSearchSpanDays,
contact.frameAt, true,
)
if !found {
t.Fatalf("%s center interval is unavailable", contact.name)
}
checked := 0
for tt := startTT + 2.0/1440.0; tt < endTT-2.0/1440.0; tt += 5.0 / 1440.0 {
frame, frameOK := contact.frameAt(tt)
center, _, centerOK := occultationEarthLineIntersection(frame.moon, frame.axis)
footprint, footprintOK := planetOccultationFootprintAt(tt, contact.frameAt, time.UTC)
if !frameOK || !centerOK || !footprintOK {
t.Fatalf("%s center or footprint is unavailable at TT %.9f", contact.name, tt)
}
longitude, latitude := occultationPathGeodetic(tt, center)
if !planetOccultationFootprintContains(footprint, longitude, latitude) {
t.Fatalf("%s footprint does not contain center %.6f, %.6f at TT %.9f",
contact.name, longitude, latitude, tt)
}
checked++
}
if checked < 10 {
t.Fatalf("%s checked only %d center samples", contact.name, checked)
}
}
}
func TestPlanetOccultationMars20250729GreatestIsInsideTotalFootprint(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
paths, err := FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), OccultationMars,
OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900,
RiseSetStep: time.Minute, DisableFootprints: true,
IncludeFootprintTimeline: true, FootprintTimelineStep: 5 * time.Minute,
},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
if !path.HasTotalBand || len(path.TotalBandFootprints) == 0 {
t.Fatalf("Mars path has no total band support: hasTotal=%v footprints=%d",
path.HasTotalBand, len(path.TotalBandFootprints))
}
greatest := path.Greatest
foundGreatestFootprint := false
for _, footprint := range path.TotalBandFootprints {
if !footprint.Time.Equal(greatest.Time) {
continue
}
foundGreatestFootprint = true
if !planetOccultationFootprintContains(footprint, greatest.Longitude, greatest.Latitude) {
t.Fatalf("greatest point %.6f, %.6f is outside total footprint at %v",
greatest.Longitude, greatest.Latitude, greatest.Time)
}
}
if !foundGreatestFootprint {
t.Fatalf("total compact support omitted greatest time %v", greatest.Time)
}
}
func TestPlanetOccultationPathRejectsExcessiveAggregateSampling(t *testing.T) {
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
_, err := FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), OccultationSaturn,
OccultationPathOptions{Step: time.Second},
)
if !errors.Is(err, ErrOccultationPathSamplingLimit) {
t.Fatalf("FindPlanetOccultationPaths() error = %v, want ErrOccultationPathSamplingLimit", err)
}
}
func TestPlanetOccultationFootprintsHaveIndependentSampleBudget(t *testing.T) {
times := occultationPathSampleTimesWithLimit(
0, 1, 0.500001, 1.0/86400.0, planetOccultationFootprintMaxSamples,
)
if len(times) > planetOccultationFootprintMaxSamples {
t.Fatalf("footprint sample count = %d, maximum %d", len(times), planetOccultationFootprintMaxSamples)
}
foundGreatest := false
for _, sample := range times {
if sample == 0.500001 {
foundGreatest = true
break
}
}
if !foundGreatest {
t.Fatal("bounded footprint samples omitted greatest")
}
}
func TestPlanetOccultationHorizonCircleUsesTopocentricLunarHorizon(t *testing.T) {
config, ok := planetOccultationConfigFor(OccultationSaturn)
if !ok {
t.Fatal("Saturn occultation config is unavailable")
}
tt := occultationTimeToTT(time.Date(2024, time.July, 24, 16, 30, 0, 0, time.UTC))
frame, ok := planetOccultationTotalPathFrameAt(tt, config)
if !ok {
t.Fatal("Saturn total-occultation frame is unavailable")
}
points := planetOccultationHorizonCircle(tt, frame, time.UTC, 360)
if len(points) != 360 {
t.Fatalf("horizon point count = %d, want 360", len(points))
}
for index, point := range points {
if altitude := math.Abs(point.MoonAltitude); altitude > 5e-5 {
t.Fatalf("horizon point %d lunar altitude = %.12f degrees, want zero", index, point.MoonAltitude)
}
}
}
func planetOccultationFootprintContains(
footprint PlanetOccultationFootprint,
longitude, latitude float64,
) bool {
for _, polygon := range footprint.Polygons {
inside := false
for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 {
currentX := math.Remainder(polygon[current].Longitude-longitude, 360)
previousX := math.Remainder(polygon[previous].Longitude-longitude, 360)
currentY := polygon[current].Latitude
previousY := polygon[previous].Latitude
if math.Abs(currentX-previousX) > 180 {
if currentX < previousX {
currentX += 360
} else {
previousX += 360
}
}
if (currentY > latitude) == (previousY > latitude) {
continue
}
intersectionX := previousX + (latitude-previousY)*(currentX-previousX)/(currentY-previousY)
if intersectionX > 0 {
inside = !inside
}
}
if inside {
return true
}
}
return false
}
// 导出的瞬时足迹必须停在月球地平线切口上:边界、闭合多边形与修复面都不得含地平线以下顶点。
func TestPlanetOccultationFootprintsClipAtLunarHorizon(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
saturnStart := time.Date(2025, time.January, 5, 0, 0, 0, 0, zone)
for _, testCase := range []struct {
name string
options OccultationPathOptions
}{
{
name: "DenseFootprints",
options: OccultationPathOptions{
Algorithm: OccultationPathAlgorithmExact, Step: 20 * time.Minute,
TargetSpacingKM: 900, RiseSetStep: time.Minute,
},
},
{
name: "TimelineFootprints",
options: OccultationPathOptions{
Algorithm: OccultationPathAlgorithmExact, Step: 20 * time.Minute,
TargetSpacingKM: 900, DisableFootprints: true, IncludeFootprintTimeline: true,
FootprintTimelineStep: 5 * time.Minute, RiseSetStep: time.Minute,
},
},
} {
t.Run(testCase.name, func(t *testing.T) {
paths, err := FindPlanetOccultationPaths(
saturnStart, saturnStart.Add(24*time.Hour), OccultationSaturn, testCase.options,
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
below, checked := 0, 0
for _, footprints := range [][]PlanetOccultationFootprint{path.PartialFootprints, path.TotalFootprints} {
belowPart, checkedPart := planetOccultationFootprintHorizonViolations(footprints)
below += belowPart
checked += checkedPart
}
if checked < 1000 {
t.Fatalf("checked only %d footprint vertices", checked)
}
if below != 0 {
t.Fatalf("%d of %d footprint vertices lie below the lunar horizon", below, checked)
}
})
}
}
func planetOccultationFootprintHorizonViolations(
footprints []PlanetOccultationFootprint,
) (below, checked int) {
for _, footprint := range footprints {
for _, rings := range [][][]OccultationPathPoint{
footprint.Boundaries, footprint.Polygons, footprint.InteriorPolygons,
} {
for _, ring := range rings {
for _, point := range ring {
checked++
if point.MoonAltitude < 0 {
below++
}
}
}
}
}
return below, checked
}
func BenchmarkOccultationPlanetFootprints(b *testing.B) {
b.Run("Saturn20240821Default", func(b *testing.B) {
benchmarkPlanetOccultationFootprints(
b, OccultationSaturn, time.Date(2024, time.August, 21, 0, 0, 0, 0, time.UTC),
OccultationPathOptions{},
)
})
b.Run("Mars20250729Compact", func(b *testing.B) {
benchmarkPlanetOccultationFootprints(
b, OccultationMars, time.Date(2025, time.July, 29, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)),
OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
DisableFootprints: true, IncludeFootprintTimeline: true, FootprintTimelineStep: 5 * time.Minute,
},
)
})
}
// benchmarkPlanetOccultationFootprints 只度量足迹装配:窗口与帧函数取自一次真实路径求解,避免混入路径与升落成本。
func benchmarkPlanetOccultationFootprints(
b *testing.B,
planet OccultationPlanet,
start time.Time,
options OccultationPathOptions,
) {
paths, err := FindPlanetOccultationPaths(start, start.Add(24*time.Hour), planet, options)
if err != nil || len(paths) != 1 {
b.Fatalf("paths=%d err=%v", len(paths), err)
}
config, ok := planetOccultationConfigFor(planet)
if !ok {
b.Fatal("missing occultation config")
}
path := paths[0]
cache := newPlanetOccultationEventCache(config)
greatestTT := occultationTimeToTT(path.Greatest.Time)
cache.preparePathEphemeris(greatestTT, options.Algorithm)
startTT, endTT := occultationTimeToTT(path.Start.Time), occultationTimeToTT(path.End.Time)
location := start.Location()
frameAt := cache.outerFrameAt
b.ReportAllocs()
b.ResetTimer()
for index := 0; index < b.N; index++ {
if options.DisableFootprints {
band := planetOccultationBandFootprints(startTT, endTT, greatestTT, frameAt, location, nil)
timeline := planetOccultationTimelineFootprints(startTT, endTT, greatestTT, frameAt, options, location)
if len(band) == 0 || len(timeline) == 0 {
b.Fatalf("band=%d timeline=%d", len(band), len(timeline))
}
continue
}
if footprints := planetOccultationFootprints(startTT, endTT, greatestTT, frameAt, options, location); len(footprints) == 0 {
b.Fatal("no footprints")
}
}
}