feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -7,6 +7,40 @@ import (
|
||||
"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 {
|
||||
@@ -52,6 +86,42 @@ func TestPlanetOccultationSaturnFootprintsContainCenterLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -82,6 +152,27 @@ func TestPlanetOccultationFootprintsHaveIndependentSampleBudget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -114,3 +205,128 @@ func planetOccultationFootprintContains(
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user