2bf8478639
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
333 lines
11 KiB
Go
333 lines
11 KiB
Go
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")
|
|
}
|
|
}
|
|
}
|